В этой статье используется базовая компоновка с родительским и дочерними компонентами для демонстрации внутренних процессов архитектуры Fiber, на которую опирается React для передачи пропсов в дочерние компоненты.
Предыдущая статья серии (ссылка на перевод) - Fiber изнутри: Погружение в новый алгоритм согласования React
Содержание:
Введение
В своей предыдущей статье Fiber изнутри: погружение в новый алгоритм согласования React я заложил фундамент, необходимый для понимания технических деталей процесса обновления, который я опишу в этой статье.
Там я описал основные структуры данных и концепции, которые я буду использовать в текущей статье, в частности fiber-узлы, текущее и work in progress деревья, побочные эффекты и список эффектов. Я также представил высокоуровневый обзор основного алгоритма и объяснил разницу между фазами render
и commit
. Если вы еще не читали ту статью, я рекомендую вам начать с нее.
Я также познакомил вас с примером приложения с кнопкой, которая просто увеличивает число, отображаемое на экране:
Вы можете поиграть с этим примером здесь. Он реализован как простой компонент, который возвращает два дочерних элемента button
и span
из метода render
. Когда вы нажимаете на кнопку, состояние компонента обновляется внутри обработчика. Это приводит к обновлению текста элемента span
:
class ClickCounter extends React.Component {
constructor(props) {
super(props);
this.state = {count: 0};
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
this.setState((state) => {
return {count: state.count + 1};
});
}
componentDidUpdate() {}
render() {
return [
<button key="1" onClick={this.handleClick}>Update counter</button>,
<span key="2">{this.state.count}</span>
]
}
}
Здесь я также добавил метод жизненного цикла componentDidUpdate
к компоненту. Это необходимо для демонстрации того, как React добавляет effects для вызова этого метода на этапе commit
.
В этой статье я хочу показать вам, как React обрабатывает обновления состояния и строит список эффектов. Мы рассмотрим, что происходит в высокоуровневых функциях в фазах render
и commit
.
В частности, мы увидим, как в completeWork React:
обновляет свойство
count
вstate
компонентаClickCounter
вызывает метод
render
для получения списка дочерних элементов и выполняет сравнениеобновляет props элемента
span
.
И как commitRoot React:
обновляет свойство
textContent
элементаspan
вызывает метод жизненного цикла
componentDidUpdate
.
Но перед этим давайте быстро посмотрим, как планируется работа, когда мы вызываем setState
в обработчике клика.
Обратите внимание, что вам не нужно знать ничего из этого, чтобы использовать React. Эта статья о том, как React работает внутри.
Планирование обновлений
Когда мы нажимаем на кнопку, срабатывает событие click
, и React выполняет обратный вызов, который мы передаем в пропсе кнопки. В нашем приложении он просто увеличивает счетчик и обновляет состояние:
class ClickCounter extends React.Component {
...
handleClick() {
this.setState((state) => {
return {count: state.count + 1};
});
}
}
Каждый React компонент имеет связанный с ним updater
, который действует как мост между компонентами и ядром React. Это позволяет реализовать setState
по-разному в ReactDOM, React Native, рендеринге на стороне сервера и утилитах тестирования.
В этой статье мы рассмотрим реализацию объекта updater в ReactDOM, который использует Fiber reconciler. Для компонента ClickCounter
это classComponentUpdater. Он отвечает за получение экземпляра Fiber, постановку обновлений в очередь и планирование работы.
Когда обновления ставятся в очередь, они, по сути, просто добавляются в очередь обновлений для обработки на Fiber узле. В нашем случае Fiber узел, соответствующий компоненту ClickCounter
, будет иметь следующую структуру:
{
stateNode: new ClickCounter,
type: ClickCounter,
updateQueue: {
baseState: {count: 0}
firstUpdate: {
next: {
payload: (state) => { return {count: state.count + 1} }
}
},
...
},
...
}
Как вы видите, функция в updateQueue.firstUpdate.next.payload
- это обратный вызов, который мы передали в setState
в компоненте ClickCounter
. Он представляет первое обновление, которое должно быть обработано на этапе render
.
Обработка обновлений fiber-узла ClickCounter
Глава о цикле работы в моей предыдущей статье объясняет роль глобальной переменной nextUnitOfWork
. В частности, там говорится, что эта переменная хранит ссылку на fiber-узел из workInProgress
дерева, которому предстоит выполнить определенную работу. Когда React обходит fiber-дерево, он использует эту переменную, чтобы узнать, есть ли еще какой-нибудь fiber-узел с незавершенной работой.
Начнем с предположения, что метод setState
был вызван. React добавляет обратный вызов из setState
в updateQueue
на fiber-узле ClickCounter
и планирует работу. React вступает в фазу render
. Он начинает обход с самого верхнего fiber-узла HostRoot
, используя функцию renderRoot. Однако он обходит (пропускает) уже обработанные узлы Fiber, пока не найдет узел с незавершенной работой. На данный момент есть только один fiber-узел, над которым еще нужно поработать. Это fiber-узел ClickCounter
.
Вся работа, выполненная на клонированной копии этого fiber-узла, сохраняется в поле alternate
. Если альтернативный узел еще не создан, React создает копию в функции createWorkInProgress перед обработкой обновлений. Предположим, что переменная nextUnitOfWork
содержит ссылку на альтернативный fiber-узел ClickCounter
.
beginWork
Во-первых, наш fiber попадает в функцию beginWork.
Поскольку эта функция выполняется для каждого fiber-узла в дереве, это хорошее место для установки точки останова, если вы хотите отладить
render
фазу. Я часто так делаю и проверяю тип fiber-узла, чтобы определить нужный мне узел.
Функция beginWork
- это, по сути, большой оператор switch
, который определяет тип работы, которую необходимо выполнить для fiber-узла с помощью тега (свойство tag), а затем выполняет соответствующую функцию для выполнения этой работы. В случае CountClicks
это классовый компонент, поэтому будет взята эта ветвь:
function beginWork(current$$1, workInProgress, ...) {
...
switch (workInProgress.tag) {
...
case FunctionalComponent: {...}
case ClassComponent:
{
...
return updateClassComponent(current$$1, workInProgress, ...);
}
case HostComponent: {...}
case ...
}
и мы попадаем в функцию updateClassComponent. В зависимости от того, является ли это первым рендерингом компонента, возобновлением работы или React обновлением, React либо создает экземпляр и монтирует компонент, либо просто обновляет его:
function updateClassComponent(current, workInProgress, Component, ...) {
...
const instance = workInProgress.stateNode;
let shouldUpdate;
if (instance === null) {
...
// При первом проходе нам понадобится сконструировать экземпляр
constructClassInstance(workInProgress, Component, ...);
mountClassInstance(workInProgress, Component, ...);
shouldUpdate = true;
} else if (current === null) {
// При возобновлении у нас уже будет экземпляр, который мы можем использовать повторно
shouldUpdate = resumeMountClassInstance(workInProgress, Component, ...);
} else {
shouldUpdate = updateClassInstance(current, workInProgress, ...);
}
return finishClassComponent(current, workInProgress, Component, shouldUpdate, ...);
}
Обработка обновлений fiber-узла ClickCounter
У нас уже есть экземпляр компонента ClickCounter
, поэтому мы попадаем в updateClassInstance. Именно здесь React выполняет большую часть работы для классовых компонентов. Вот наиболее важные операции, выполняемые в функции, в порядке их выполнения:
вызов хука
UNSAFE_componentWillReceiveProps()
(устарел)обработка обновлений в
updateQueue
и генерация нового состояниявызов
getDerivedStateFromProps
с этим новым состоянием и получение результатавызов
shouldComponentUpdate
, чтобы убедиться, что компонент хочет обновиться; еслиfalse
, пропустить весь процесс рендеринга, включая вызовrender
для этого компонента и его дочерних компонентов; в противном случае продолжить обновлениевызов
UNSAFE_componentWillUpdate
(устарел)добавление эффекта для запуска хука жизненного цикла
componentDidUpdate
Хотя эффект вызова
componentDidUpdate
добавлен вrender
фазе, метод будет выполнен в следующейcommit
фазе.
обновление
state
иprops
на экземпляре компонента
state
и props
должны быть обновлены в экземпляре компонента до вызова метода render
, поскольку вывод метода render
обычно зависит от state
и props
. Если этого не сделать, то метод будет возвращать один и тот же результат каждый раз.
Вот упрощенная версия функции:
function updateClassInstance(current, workInProgress, ctor, newProps, ...) {
const instance = workInProgress.stateNode;
const oldProps = workInProgress.memoizedProps;
instance.props = oldProps;
if (oldProps !== newProps) {
callComponentWillReceiveProps(workInProgress, instance, newProps, ...);
}
let updateQueue = workInProgress.updateQueue;
if (updateQueue !== null) {
processUpdateQueue(workInProgress, updateQueue, ...);
newState = workInProgress.memoizedState;
}
applyDerivedStateFromProps(workInProgress, ...);
newState = workInProgress.memoizedState;
const shouldUpdate = checkShouldComponentUpdate(workInProgress, ctor, ...);
if (shouldUpdate) {
instance.componentWillUpdate(newProps, newState, nextContext);
workInProgress.effectTag |= Update;
workInProgress.effectTag |= Snapshot;
}
instance.props = newProps;
instance.state = newState;
return shouldUpdate;
}
Я удалил некоторый вспомогательный код в приведенном выше фрагменте. Например, прежде чем вызывать методы жизненного цикла или добавлять эффекты для их запуска, React проверяет, реализует ли компонент метод с помощью оператора typeof
. Вот, например, как React проверяет наличие метода componentDidUpdate
перед добавлением эффекта:
if (typeof instance.componentDidUpdate === 'function') {
workInProgress.effectTag |= Update;
}
Итак, теперь мы знаем, какие операции выполняются для fiber-узла компонента ClickCounter
на render фазе. Давайте теперь посмотрим, как эти операции изменяют значения на fiber-узлах. Когда React начинает работу, fiber-узел компонента ClickCounter
выглядит следующим образом:
{
effectTag: 0,
elementType: class ClickCounter,
firstEffect: null,
memoizedState: {count: 0},
type: class ClickCounter,
stateNode: {
state: {count: 0}
},
updateQueue: {
baseState: {count: 0},
firstUpdate: {
next: {
payload: (state, props) => {…}
}
},
...
}
}
После завершения работы мы получаем fiber-узел, который выглядит следующим образом:
{
effectTag: 4,
elementType: class ClickCounter,
firstEffect: null,
memoizedState: {count: 1},
type: class ClickCounter,
stateNode: {
state: {count: 1}
},
updateQueue: {
baseState: {count: 1},
firstUpdate: null,
...
}
}
Обратите внимание на различия в значениях свойств.
После применения обновления значение свойства count
изменяется на 1
в memoizedState
и baseState
в updateQueue
. React также обновил состояние в экземпляре компонента ClickCounter
.
На данный момент у нас больше нет обновлений в очереди, поэтому firstUpdate
равно null
. И что важно, у нас есть изменения в свойстве effectTag
. Оно больше не 0
, его значение 4
. В двоичном формате это 100
, что означает, что установлен третий бит, который как раз и является битом для Update
side-effect tag:
export const Update = 0b00000000100;
Итак, в заключение, при работе над родительским fiber-узлом ClickCounter
, React вызывает методы жизненного цикла премутации, обновляет состояние и определяет соответствующие побочные эффекты.
Согласование дочерних элементов fiber-узла ClickCounter
После этого React приступает к finishClassComponent. Здесь React вызывает метод render
на экземпляре компонента и применяет свой алгоритм диффинга (сравнения) к дочерним элементам, возвращаемым компонентом. Высокоуровневый обзор описан в документации. Вот соответствующая часть:
При сравнении двух элементов React DOM одного типа, React просматривает атрибуты обоих, сохраняет один и тот же базовый узел DOM и обновляет только измененные атрибуты.
Однако если копнуть глубже, то можно узнать, что на самом деле сравниваются fiber-узлы с React-элементами. Но я не буду сейчас вдаваться в подробности, поскольку процесс довольно сложный. Я напишу отдельную статью, которая будет посвящена именно процессу согласования дочерних элементов.
Если вам не терпится узнать подробности самостоятельно, ознакомьтесь с функцией reconcileChildrenArray, поскольку в нашем приложении метод
render
возвращает массив React элементов.
На этом этапе важно понять две вещи. Во-первых, когда React проходит через процесс согласования дочерних элементов, он создает или обновляет узлы Fiber для дочерних элементов React, возвращенных из метода render
. Функция finishClassComponent
возвращает ссылку на первого ребенка текущего узла Fiber. Она будет присвоена nextUnitOfWork
и обработана позже в цикле работ. Во-вторых, React обновляет пропсы дочерних компонентов в рамках работы, выполняемой для родителя. Для этого он использует данные из элементов React, возвращаемых из метода render
.
Например, вот как выглядит fiber-узел, соответствующий элементу span
, до того, как React выверит дочерние элементы для fiber-узла ClickCounter
:
{
stateNode: new HTMLSpanElement,
type: "span",
key: "2",
memoizedProps: {children: 0},
pendingProps: {children: 0},
...
}
Как вы можете видеть, свойство children
в обоих memoizedProps
и pendingProps
равно 0
. Вот структура элемента React, возвращаемого из render
для элемента span
:
{
$$typeof: Symbol(react.element)
key: "2"
props: {children: 1}
ref: null
type: "span"
}
Как вы можете видеть, есть разница между пропсами в fiber-узле и возвращаемым React-элементом. Внутри функции createWorkInProgress`, которая используется для создания альтернативных fiber-узлов, React скопирует обновленные свойства из элемента React в fiber-узел.
Таким образом, после того как React завершит сверку дочерних элементов для компонента ClickCounter
, fiber-узел span
получит обновленные pendingProps
. Они будут соответствовать значению в элементе span
React:
{
stateNode: new HTMLSpanElement,
type: "span",
key: "2",
memoizedProps: {children: 0},
pendingProps: {children: 1},
...
}
Позже, когда React будет выполнять работу для fiber-узла span
, он скопирует их в memoizedProps
и добавит эффекты для обновления DOM.
Вот и вся работа, которую React выполняет для fiber-узла ClickCounter
на этапе render. Поскольку кнопка является первым дочерним компонентом ClickCounter
, она будет назначена переменной nextUnitOfWork
. С ней ничего нельзя сделать, поэтому React перейдет к ее сиблингу, которым является fiber-узел span
. Согласно алгоритму описанному здесь, это происходит в функции completeUnitOfWork
.
Обработка обновлений для fiber-узла span
Итак, переменная nextUnitOfWork
теперь указывает на альтернативу fiber-узла span
, и React начинает работать над ним. Аналогично шагам, выполненным для ClickCounter
, мы начинаем с функции beginWork.
Поскольку наш узел span
имеет тип HostComponent
, на этот раз в операторе switch React берет эту ветвь:
function beginWork(current$$1, workInProgress, ...) {
...
switch (workInProgress.tag) {
case FunctionalComponent: {...}
case ClassComponent: {...}
case HostComponent:
return updateHostComponent(current, workInProgress, ...);
case ...
}
и попадает в функцию updateHostComponent. Вы можете увидеть параллель с функцией updateClassComponent
, вызываемой для компонентов класса. Для функционального компонента это будет updateFunctionComponent
и так далее. Все эти функции вы можете найти в файле ReactFiberBeginWork.js.
Согласование дочерних элементов для fiber-узла span
В нашем случае для fiber-узла span
в updateHostComponent
не происходит ничего важного.
Завершение работы для fiber-узла span
После завершения beginWork
узел попадает в функцию completeWork
. Но перед этим React необходимо обновить memoizedProps
на fiber-узле span. Вы можете помнить, что при согласовании дочерних элементов для компонента ClickCounter
, React обновил pendingProps
у fiber-узла span
:
{
stateNode: new HTMLSpanElement,
type: "span",
key: "2",
memoizedProps: {children: 0},
pendingProps: {children: 1},
...
}
Поэтому, как только beginWork
завершается для fiber-узла span
, React обновляет pendingProps
для соответствия memoizedProps
:
function performUnitOfWork(workInProgress) {
...
next = beginWork(current$$1, workInProgress, nextRenderExpirationTime);
workInProgress.memoizedProps = workInProgress.pendingProps;
...
}
Затем он вызывает функцию completeWork
, которая по сути является большим оператором switch
, подобным тому, который мы видели в beginWork
:
function completeWork(current, workInProgress, ...) {
...
switch (workInProgress.tag) {
case FunctionComponent: {...}
case ClassComponent: {...}
case HostComponent: {
...
updateHostComponent(current, workInProgress, ...);
}
case ...
}
}
Поскольку наш fiber-узел span
является HostComponent
, он запускает функцию updateHostComponent. В этой функции React в основном делает следующее:
подготавливает обновления DOM
добавляет их в
updateQueue
fiber-узлаspan
добавляет эффект для обновления DOM
До выполнения этих операций fiber-узел span
выглядит следующим образом:
{
stateNode: new HTMLSpanElement,
type: "span",
effectTag: 0
updateQueue: null
...
}
и когда работа завершена, это выглядит следующим образом:
{
stateNode: new HTMLSpanElement,
type: "span",
effectTag: 4,
updateQueue: ["children", "1"],
...
}
Обратите внимание на разницу в полях effectTag
и updateQueue
. Теперь это не 0
, а 4
. В двоичном формате это 100
, что означает, что установлен третий бит, который как раз и является битом для тега побочного эффекта Update
. Это единственная работа, которую React должен выполнить для этого узла во время следующей фазы commit. В поле updateQueue
хранится полезная нагрузка, которая будет использована для обновления.
Как только React обработает ClickCounter
и его дочерние узлы, он завершает фазу render
. Теперь он может назначить завершенное альтернативное дерево свойству finishedWork
у FiberRoot
. Это новое дерево, которое должно быть выведено на экран. Оно может быть обработано сразу после фазы render
или получено позже, когда браузер предоставит React время.
Effects list (список эффектов)
В нашем случае, поскольку узел span
и компонент ClickCounter
имеют побочные эффекты, React добавит ссылку на fiber-узел span
в свойство firstEffect
компонента HostFiber
.
React строит список эффектов в функции compliteUnitOfWork. Вот как выглядит дерево Fiber с эффектами для обновления текста узла span
и вызовами хуков на ClickCounter
:
А вот линейный список узлов с эффектами:
Commit фаза
Эта фаза начинается с функции completeRoot. Прежде чем приступить к работе, она устанавливает свойство finishedWork
для FiberRoot
в null
:
root.finishedWork = null;
В отличие от первой фазы render
, фаза commit
всегда синхронна, поэтому она может безопасно обновить HostRoot
, чтобы указать, что работа по фиксации изменений началась.
На фазе commit
React обновляет DOM и вызывает метод жизненного цикла постмутации componentDidUpdate
. Для этого он просматривает список эффектов, составленный во время предыдущей фазы render
, и применяет их.
У нас есть следующие эффекты, определенные в фазе render
для наших узлов span
и ClickCounter
:
{ type: ClickCounter, effectTag: 5 }
{ type: 'span', effectTag: 4 }
Значение тега эффекта для ClickCounter
равно 5
**** или 101
в двоичном виде и определяет работу Update
, которая в основном транслируется в метод жизненного цикла componentDidUpdate
в случае классовых компонентов. Наименьший значащий бит также устанавливается, чтобы сигнализировать, что вся работа была завершена для этого fiber-узла в фазе render
.
Значение тега эффекта для span
равно 4
или 100
в двоичном формате и определяет работу update
для обновления DOM компонента узла. В случае элемента span
, React должен будет обновить textContent
у элемента.
Применение эффектов
Давайте посмотрим, как React применяет эти эффекты. Функция commitRoot, которая используется для применения эффектов, состоит из 3 подфункций:
function commitRoot(root, finishedWork) {
commitBeforeMutationLifecycles()
commitAllHostEffects();
root.current = finishedWork;
commitAllLifeCycles();
}
Каждая из этих подфункций реализует цикл, который итерирует список эффектов и проверяет их типы. Когда он находит эффект, относящийся к цели функции, он применяет его. В нашем случае она вызовет метод жизненного цикла componentDidUpdate
для компонента ClickCounter
и обновит текст элемента span
.
Первая функция commitBeforeMutationLifeCycles ищет эффект Snapshot и вызывает метод getSnapshotBeforeUpdate
. Но, поскольку мы не реализовали метод на компоненте ClickCounter
, React не добавил эффект на этапе render
. Поэтому в нашем случае эта функция ничего не делает.
Обновления DOM
Далее React переходит к функции commitAllHostEffects. Здесь React изменит текст на элементе span
с 0
на 1
. Для fiber-узла ClickCounter
ничего делать не нужно, потому что узлы, соответствующие классовым компонентам, не имеют никаких обновлений DOM.
Суть функции в том, что она выбирает нужный тип эффекта и применяет соответствующие операции. В нашем случае нам нужно обновить текст на элементе span
, поэтому здесь мы берем ветвь Update
:
function updateHostEffects() {
switch (primaryEffectTag) {
case Placement: {...}
case PlacementAndUpdate: {...}
case Update:
{
var current = nextEffect.alternate;
commitWork(current, nextEffect);
break;
}
case Deletion: {...}
}
}
Спустившись в commitWork
, мы в конечном итоге попадем в функцию updateDOMProperties. Она принимает полезную нагрузку updateQueue
, которая была добавлена на этапе render
к узлу Fiber, и обновляет свойство textContent
элемента span
:
function updateDOMProperties(domElement, updatePayload, ...) {
for (let i = 0; i < updatePayload.length; i += 2) {
const propKey = updatePayload[i];
const propValue = updatePayload[i + 1];
if (propKey === STYLE) { ...}
else if (propKey === DANGEROUSLY_SET_INNER_HTML) {...}
else if (propKey === CHILDREN) {
setTextContent(domElement, propValue);
} else {...}
}
}
После выполнения обновлений DOM, React присваивает дерево finishedWork
в HostRoot
. Это устанавливает альтернативное дерево в качестве текущего:
root.current = finishedWork;
Вызов хуков жизненного цикла после мутации
Последняя оставшаяся функция - commitAllLifecycles. Здесь React вызывает методы жизненного цикла после мутации. Во время фазы render
React добавил эффект Update
к компоненту ClickCounter
. Это один из эффектов, который ищет функция commitAllLifecycles
и вызывает метод componentDidUpdate
:
function commitAllLifeCycles(finishedRoot, ...) {
while (nextEffect !== null) {
const effectTag = nextEffect.effectTag;
if (effectTag & (Update | Callback)) {
const current = nextEffect.alternate;
commitLifeCycles(finishedRoot, current, nextEffect, ...);
}
if (effectTag & Ref) {
commitAttachRef(nextEffect);
}
nextEffect = nextEffect.nextEffect;
}
}
Функция также обновляет refs, но поскольку у нас их нет, эта функциональность не будет использоваться. Метод вызывается в функции commitLifeCycles:
function commitLifeCycles(finishedRoot, current, ...) {
...
switch (finishedWork.tag) {
case FunctionComponent: {...}
case ClassComponent: {
const instance = finishedWork.stateNode;
if (finishedWork.effectTag & Update) {
if (current === null) {
instance.componentDidMount();
} else {
...
instance.componentDidUpdate(prevProps, prevState, ...);
}
}
}
case HostComponent: {...}
case ...
}
Вы также можете видеть, что в этой функции React вызывает метод componentDidMount
для компонентов, которые были отрендерены в первый раз.
Предыдущая статья серии (ссылка на перевод) - Fiber изнутри: Погружение в новый алгоритм согласования React