Подробности о том, как происходит рендеринг в React и как влияет на рендеринг применение контекста.
Я часто сталкиваюсь с недопониманием относительно того, как, почему и когда React повторно рендерит компоненты и каким образом применение контекста и React-Redux влияет на время и объем повторного рендеринга. С десяток раз понабивав на клавиатуре различные вариации ответов на эти вопросы, я подумал, что имеет смысл составить одно общее пояснение и при каждом удобном случае ссылаться на него. Учтите, что вся собранная здесь информация уже гуляет по сети и рассматривалась в ряде других замечательных статей и публикаций в блогах. Некоторые из них перечислены в качестве справки в конце оригинальной публикации, в разделе Further Information. Собрать разрозненные сведения в единую картину бывает нелегко, поэтому я надеюсь, что моя статья поможет кому-то разобраться в теме.
Что такое рендеринг?
Рендеринг — это процесс, в рамках которого React опрашивает ваши компоненты, требуя от них актуальное описание той секции пользовательского интерфейса, за которую они отвечают, основываясь на текущей комбинации пропсов (props
) и состояния (state
).
Обзор процесса рендеринга
React начинает процесс рендеринга с корня дерева компонентов и циклически спускается вниз, чтобы найти все компоненты, помеченные как требующие обновления. Для каждого помеченного компонента React вызывает либо classComponentInstance.render()
(для классовых компонентов), либо FunctionComponent()
(для функциональных компонентов) и сохраняет результат рендеринга.
Результат рендеринга компонентов обычно представлен в виде JSX-кода, который затем компилируется и развертывается как JS-код, принимая вид серии вызовов React.createElement()
. Функция createElement
возвращает React-элементы, представляющие собой простые JS-объекты, описывающие желаемую структуру пользовательского интерфейса. Пример:
// This JSX syntax:
return <SomeComponent a={42} b="testing">Text here</SomeComponent>
// is converted to this call:
return React.createElement(SomeComponent, {a: 42, b: "testing"}, "Text Here")
// and that becomes this element object:
{type: SomeComponent, props: {a: 42, b: "testing"}, children: ["Text Here"]}
Собрав результаты рендеринга всего дерева компонентов, React делает сравнение с новым деревом объектов (его часто называют «виртуальной DOM») и составляет список всех изменений, которые нужно внести в «настоящую» DOM, чтобы привести ее к желаемому в данный момент виду. Процесс сопоставления двух деревьев и вычисления разницы между ними называется согласованием.
Затем React одним махом применяет все рассчитанные изменения к DOM в синхронном режиме.
Примечание. Команда разработчиков React в последние годы старается отойти от термина «виртуальная DOM». Вот что об этом говорит Ден Абрамов (Dan Abramov):
Было бы здорово, если бы мы могли отказаться от термина «виртуальная DOM». В нем был смысл в 2013 году, когда люди предполагали, что React создает заново DOM-узлы при каждом рендеринге. Но теперь такие предположения — редкость. «Виртуальная DOM» звучит как какой-то хитрый механизм для обхода проблем с DOM. Но идея React совсем не про это.React исповедует принцип «UI как значение» и обращается с UI, как будто речь идет о строке или массиве. UI можно сохранить в переменной, передать куда-то, применять к нему управление потоком исполнения JavaScript и т. д. В этих возможностях и заключается вся суть, а не в том, что мы путем хитрых сравнений пытаемся сократить число изменений в DOM.И далеко не всегда речь идет об изменениях в DOM, например
<Message recipientId={10} />
не имеет отношения к DOM. Концептуально такая запись означает ленивый вызов функции:Message.bind(null, { recipientId: 10 })
.
Этапы рендеринга и фиксации
Команда разработчиков React разделила этот процесс на два этапа:
этап рендеринга (render phase) — рендеринг всех компонентов и вычисление изменений;
этап фиксации (commit phase) — процесс применения изменений к DOM.
Как только React обновит DOM на этапе фиксации, он соответствующим образом актуализирует все рефы (refs), чтобы они указывали на запрошенные DOM-узлы и экземпляры компонентов. Затем он синхронно выполняет методы жизненного цикла компонентов componentDidMount и componentDidUpdate и хуки useLayoutEffect.
После небольшого тайм-аута React выполняет все хуки useEffect. Этот момент также известен как этап пассивных эффектов (passive effects).
Ознакомиться с визуализацией методов жизненного цикла компонентов можно с помощью этой наглядной схемы (к сожалению, на ней пока не отмечено время, которое затрачивается на обработку хуков эффектов).
В грядущем конкурентном режиме React появится возможность ставить на паузу этап рендеринга, давая возможность браузеру обработать события. После этого React может продолжить, сбросить или пересчитать рендеринг. По завершении этапа рендеринга React все равно синхронно запустит этап фиксации на том же шаге.
Важно понимать, что «рендеринг» не означает «обновление DOM», то есть компонент может отрендериться без каких-либо видимых изменений. Когда React рендерит компонент:
после рендеринга компонента может быть возвращен тот же результат, что и в прошлый раз, — изменения при этом не требуются;
в конкурентном режиме React может рендерить компонент несколько раз, но при этом каждый раз сбрасывать результат рендеринга, если другие обновления делают текущие результаты рендеринга неактуальными.
Как в React реализован рендеринг?
Очередь рендеринга
По завершении первичного рендеринга можно воспользоваться одним из нескольких способов, чтобы сообщить React о постановке в очередь повторного рендеринга:
классовые компоненты:
this.setState()
;this.forceUpdate()
;
функциональные компоненты:
хуки
useState
для задания состояния;редюсеры
useReducer
для вызова функций dispatch;
прочее:
повторный вызов
ReactDOM.render(<App>)
(что эквивалентно вызовуforceUpdate()
для корневого компонента).
Поведение рендеринга по умолчанию
Важно помнить следующий факт:
По умолчанию после рендеринга родительского компонента React рекурсивно рендерит все дочерние компоненты внутри родителя!
Допустим, у нас есть дерево компонентов A > B > C > D
, которые мы уже отобразили на странице. Пользователь щелкает кнопку в компоненте B, которая приращает счетчик. Происходит следующее:
Мы вызываем функцию
setState()
в компоненте B, которая ставит в очередь повторный рендеринг B.React начинает проход рендеринга с самого верха дерева.
React видит, что
A
не помечен как нуждающийся в обновлении, и проходит мимо.React видит пометку на
B
о необходимости обновления и производит его рендеринг. B, как и в прошлый раз, возвращает<C />
.C
изначально не имел пометки о необходимости обновления. Но поскольку отрендерился родительский компонентB
, React движется сверху вниз и попутно рендеритC
. КомпонентC
снова возвращает<D />
.D
также не нуждался в рендеринге, но поскольку отрендерился его родительский компонентC
, React движется еще ниже и также рендеритD
.
Другими словами:
Рендеринг компонента приводит по умолчанию к рендерингу всех вложенных в него компонентов!
Также следует учитывать другой важный момент:
При обычном рендеринге для React не имеет значения, изменились пропсы или нет, — он в любом случае будет рендерить дочерние компоненты, просто потому, что отрендерился родительский компонент.
Следовательно, вызов setState()
в корневом компоненте <App>
без каких-либо модификаторов поведения приведет к тому, что React повторно отрендерит абсолютно все элементы дерева компонентов. Все-таки изначально React продвигался в массы как решение, которое «действует так, словно оно перерисовывает приложение целиком при каждом обновлении».
Вполне вероятно, что большинство компонентов в дереве вернут тот же самый результат рендеринга, что и в прошлый раз, а это значит, что React не потребуется вносить какие-либо изменения в DOM. Но React все равно передаст компонентам запросы на рендеринг и попробует вычислить изменения в результатах рендеринга, расходуя время и ресурсы.
Следует помнить, что в рендеринге нет ничего страшного, — с его помощью React узнает, нужно ли ему внести какие-либо изменения в DOM!
Правила рендеринга в React
Одно из главных правил рендеринга в React заключается в том, что рендеринг должен быть «чистым» и не приводить к каким-либо побочным эффектам. Довольно скользкое и неоднозначное требование, ведь большинство побочных эффектов не очевидны и не приводят к каким-то фатальным ошибкам. Например, строго говоря, метод console.log()
считается побочным эффектом, но он не приводит к каким-то проблемам. Мутирование пропа — однозначно побочный эффект, но, вполне вероятно, ничего не сломается. Наличие AJAX-вызова посреди рендеринга тоже относится к очевидным побочным эффектам, что в ряде случаев приводит к неожиданному поведению приложения (в зависимости от типа запроса).
Себастьян Маркбидж (Sebastian Markbage) написал отличный документ, названный The Rules of React (Правила React). В нем он описывает ожидаемое поведение различных методов жизненного цикла React, включая render, и какие типы операций можно считать безопасными и «чистыми», а какие — нет. Документ стоит прочитать полностью, а ниже я приведу ключевые тезисы.
Логика рендеринга не может:
мутировать существующие переменные и объекты;
создавать случайные значения, такие как
Math.random()
илиDate.now()
;делать сетевые запросы;
ставить в очередь обновления состояния.
Логика рендеринга может:
мутировать новые объекты, созданные во время рендеринга;
генерировать ошибки;
«лениво инициализировать» еще не созданные данные, например кэшированное значение.
Метаданные и «волокна» компонентов
React хранит внутреннюю структуру данных, которая отслеживает все текущие экземпляры компонентов, существующие в приложении. Базовым элементом в этой структуре данных являются объекты, называемые «волокнами» (fiber). Они содержат поля метаданных, которые описывают следующее:
какой тип компонента из дерева следует отрендерить в текущий момент;
текущие пропсы и состояние, связанные с этим компонентом;
указатели на родительский, родственные и дочерние компоненты;
другие внутренние метаданные, с помощью которых React контролирует процесс рендеринга.
По следующей ссылке доступно определение типа Fiber в React 17.
Во время прохода рендеринга React будет прочесывать дерево объектов-волокон и попутно конструировать обновленное дерево по мере расчета новых результатов рендеринга.
Учтите, что эти «волокна» хранят реальные значения пропсов и состояния компонента. Когда вы пользуетесь props
и state
в своих компонентах, на самом деле React позволяет вам обращаться к значениям, хранящимся в объектах-волокнах. В частности, для классовых компонентов React явно копирует componentInstance.props = newProps в компонент, прежде чем его отрендерить. Итак, this.props
существует, но только потому, что React скопировал ссылку на него из своей внутренней структуры данных. В каком-то смысле компоненты выступают в роли фасада, за которым находятся объекты-волокна React.
Аналогичным образом работают хуки React, поскольку React хранит все хуки компонента в виде связного списка, прикрепленного к «волокну» компонента. Когда React рендерит функциональный компонент, он извлекает из «волокна» связный список с описаниями хуков, и при каждом вызове другого хука он возвращает соответствующие значения, хранящиеся в объекте с описаниями хуков (например, значения state и dispatch для useReducer).
Когда родительский компонент впервые рендерит определенный дочерний компонент, React создает объект-волокно, чтобы отслеживать этот «экземпляр» компонента. В случае классовых компонентов он напрямую вызывает const instance = new YourComponentType(props) и сохраняет реальный экземпляр компонента в объект-волокно. Для функциональных компонентов React просто вызывает YourComponentType(props) как функцию.
Продолжение читайте через пару дней во второй части.
Материал подготовлен в рамках курса «React.js Developer».
Всех желающих приглашаем на бесплатный двухдневный интенсив «Создаём игру "Сапёр"». На занятии мы:
— Создадим игру «Сапёр» с использованием React Hooks.
— Разберем принципы игры, алгоритм для построения поля и интерфейса игры.
— Сделаем 2 реализации — с использованием useEffect и useRedux.
— Разберем, как тестировать React Components с использованием React-Testing-Library.
>> РЕГИСТРАЦИЯ