В сети много статей и докладов, которые описывают React Fiber, но, к сожалению, они сейчас уже не актуальны. Заметив это, я решил разобраться и актуализировать информацию. Моими основными помощниками были исходники и отладчик, поэтому здесь вы увидите множество ссылок на код из репозитория React. Теперь я хочу поделиться результатами своей работы с вами.
Тема разделена на две статьи. Первая статья расскажет о процессе обновления и внесения изменений в DOM. Вторая статья посвящена реализации не блокирующего рендеринга - Concurrent React и доступна по ссылке. Данная статья является первой из двух.
Хочу заметить, что первая статья, возможно, не принесет значительной практической пользы, однако описанный в ней материал рекомендуем для понимания реализации Concurrent React. Эта статья будет особенно полезна для тех, кто хочет разобраться в работе инструмента изнутри, а также извлечь интересные идеи по написанию и структурированию кода.
React Fiber
React Fiber решает две основные задачи:
Инкрементальный процесс рендеринга — способность разделять работу рендера на части. Здесь концепция и реализация во многом не поменялись, хотя и претерпели некоторые изменения.
Возможность не блокирующего рендеринга, при котором рендер не должен блокировать взаимодействия пользователя с сайтом и отображение анимации. Здесь все наоборот: концепция долго разрабатывалась и видоизменялась. Подробности о том, каким образом была решена эта задача, я раскрываю в следующей статье. Важно заметить, что реализация первого аспекта, инкрементального рендеринга, является базой для реализации второго.
Под рендером следует понимать процесс обновления компонентов и вычисления изменений между предыдущим и текущим рендерами для последующего внесения изменений в DOM.
Далее мы разберем, как команда React решила первую задачу. В этой статье рассмотрим процесс инкрементального рендеринга, но разберем его в рамках всей работы по обновлению: обновление компонентов и внесение изменений в DOM.
Работа по обновлению
Вся работа по обновлению делится на 2 фазы:
Фаза render, которую иногда также называют reconciliation. В этой фазе React применяет обновления к компонентам, сравнивает предыдущее состояние приложения с текущим и выясняет, какую работу по обновлению нужно выполнить.
Фаза commit. Используя наработки из предыдущей фазы, вызываются методы жизненного цикла и хуки, а также обновляется DOM.
Для объяснения фаз мне потребуется вспомогательный пример. Я буду использовать данное приложение, реализующее обычный счетчик:
function CounterResult({ count }) {
return (
<span>{count}</span>
)
}
function Button({ onClick, children }) {
return (
<button onClick={onClick}>{children}</button>
)
}
function ClickCounter() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
setCount((value) => value + 1)
}, [])
return (
<div>
<Button onClick={handleClick}>Click me!</Button>
<CounterResult count={count} />
</div>
);
}
Фаза render / reconciliation
Мы не будем начинать разбор этой фазы с самого начала. Вместо этого предлагаю сначала освоить некоторые базовые понятия процесса рендеринга, чтобы в дальнейшем повествование было более плавным и последовательным.
React Elements
Если представить наш пример без использования JSX, то оно будет выглядеть вот так:
function CounterResult({ count }) {
return React.createElement("span", null, count);
}
function Button({ onClick, children }) {
return React.createElement("button", { onClick: onClick }, children);
}
function ClickCounter() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
setCount(value => value + 1);
}, []);
return React.createElement("div", null,
React.createElement(Button, { onClick: handleClick }, "Click me!"),
React.createElement(CounterResult, { count: count }));
}
Можно заметить, что каждый компонент создаст и вернет React Element. В результате работы React над нашим приложением мы получи 6 React Elements: ClickCounter
, div
, Button
, button
, CounterResult
, span
. Каждый является обычным объектом примерно такого вида:
{
$$typeof: Symbol(react.element),
key: null,
props: {},
ref: null,
type: ClickCounter,
}
Каждый React Element несет в себе информацию о текущем состоянии каждого элемента приложения.
Fiber Node
Fiber Node - это основной элемент в архитектуре React Fiber. Fiber Node создается из React Element с помощью функции createFiberFromElement (это не единственная функция, которая создает Fiber Node, но именно та, которая используется в нашем случае). Но сам инстанс Fiber Node создается глубже в функции createFiber. Вот как оно выглядит:
function createFiber(tag: WorkTag, pendingProps: mixed, key: null | string, mod: TypeOfMode): Fiber {
return new FiberNode(tag, pendingProps, key, mode);
}
Давайте рассмотрим структуру Fiber Node. Вот ее основные поля, которые помогут нам в дальнейшем (react/packages/react-reconciler/src/ReactInternalTypes.js):
export type Fiber = {
// Тип Fiber Node react/packages/shared/ReactWorkTags.js
tag: WorkTag,
// Функция, класс или тэг, связанный с этой Fiber Node
type: any,
// Узел DOM
stateNode: any,
// Поля для формирования Fiber Tree
return: Fiber | null,
child: Fiber | null,
sibling: Fiber | null,
// Используется для хранения информации о различных состояниях и действиях,
// которые должны быть выполнены в контексте этого узла.
flags: Flags,
subtreeFlags: Flags,
// Ссылка на Fiber Node в другом дереве.
// Из workInProgressTree идет указатель в currentTree и наоборот
alternate: Fiber | null,
};
Fiber Tree
Используя поля return
, child
и sibling
, мы можем сформировать Fiber Tree. Fiber Tree отображает структуру и состояние всего нашего приложения. В случае нашего примера он будет выглядеть следующим образом:
Что такое Root
React создает объект fiber root для каждого такого дерева. В fiber root хранится ссылка на Fiber Tree. Fiber Tree начинается со специального типа узла - Root и выступает родителем для вашего начального компонента
Но у нас будет не одно, а целых два таких дерева: currentTree и workInProgressTree. currentTree отражает текущее, видимое состояние нашего приложения, в то время как workInProgressTree отражает новое состояние, которое необходимо применить. В ходе фазы render, workInProgressTree и его узлы будут создаваться и изменяться. Узлы workInProgressTree формируются на основе узлов currentTree с помощью функции createWorkInProgress. В этом процессе создается Fiber Node для workInProgressTree с помощью нам уже известной функции createFiber
.
Инкрементальный рендеринг
Теперь мы можем приступить к разбору того, как происходит инкрементальный рендеринг. Суть его в следующем: мы проходимся по каждому узлу workInProgressTree и выполняем некую работу над ним. Таким образом, рендеринг происходит постепенно, обрабатывая каждый узел и имея возможность прервать работу перед обработкой следующего узла.
Все начинается с создания узла Root для workInProgressTree на основе Root из currentTree. Здесь мы впервые благодарим себя за проделанную подготовительную работу, потому что мы уже знаем, что создание Root узла происходит с помощью функции createWorkInProgress
. В случае первого рендера нашего приложения узел Root в currentTree будет совершенно пустым.
Далее мы запускаем цикл прохода по узлам workInProgressTree. На данный момент у нас есть только один такой узел - Root, и мы начинаем с него.
function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
В данном цикле мы вызываем функцию performUnitOfWork до тех пор, пока у нас есть узлы workInProgress, которые необходимо обработать.
function performUnitOfWork(unitOfWork: Fiber): void {
const current = unitOfWork.alternate;
const next = beginWork(current, unitOfWork, renderLanes);
unitOfWork.memoizedProps = unitOfWork.pendingProps;
if (next === null) {
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
}
Функция performUnitOfWork
выполняет всю работу над Fiber Node, связанную с обновлением компонентов и вычислением изменений между предыдущим рендером и текущим для последующего внесения изменений в DOM.
Выполнение этой функции можно разбить на два этапа: beginWork и completeUnitOfWork.
Мы подробно разберем эти этапы позже, но сейчас хочу рассказать о том, что общего между ними. Обе функции заполняют значения полей flags и subtreeFlags, которые присутствуют в Fiber Node. flags
и subtreeFlags
определяют различные состояния и действия, которые должны быть выполнены в контексте этого узла. Все возможные значения этих полей хранятся в файле react/packages/react-reconciler/src/ReactFiberFlags.js.
Примеры:
Update: Указывает, что Fiber Node требует обновления.
Snapshot: Указывает, что необходимо вызвать хук
getSnapshotBeforeUpdate
.
Также стоит уточнить, что эти поля хранят двоичные значения. Так, Update = 100, а Snapshot = 10000000000.
Effects List
Вероятно, вы могли слышать про effects и effects list, но предполагаю, что эта теория устарела, так как я не нашел, чтобы на этапе render как-то формировался effects list. Да, у Fiber есть поля nextEffect
, firstEffect
, lastEffect
, но они не заполняются и не используются в фазе render, а только в фазе commit. Так что утверждение, что в фазе render формируется effects list, по которому в фазе commit мы ходим и выполняем какие то действия с узлом, не верно.
beginWork
Функция beginWork
выполняет вызов компонента и создает Fiber Nodes для дочерних узлов. Под вызовом компонента я подразумеваю процесс выполнения тела компонента. В классовых компонентах этот процесс начинается с вызова метода render()
. В функциональных компонентах он представляет собой выполнение самой функции компонента. В нашем случае вызов будет выполнен для узлов ClickCounter
, Button
и CounterResult
с помощью функции renderWithHooks
. На выходе мы получаем дочерние React Elements. Далее происходит создание Fiber Node для дочерних элементов. Fiber Nodes создаются на основе React Elements.
React Elements мы создали в момент вызова компонента и с помощью функции React.createElement
. Я не зря привел пример нашего приложения без JSX, чтобы акцентировать внимание на том, что в return
выполняется создание React Element.
Если мы создаем Fiber Node впервые (то есть для текущей Fiber Node нет соответствующего узла в currentTree), то создание происходит с помощью функции createFiberFromElement
. Если же мы обновляем Fiber Node (для текущей Fiber Node есть соответствующий узел в currentTree), то создается узел с помощью функции createWorkInProgress
на основе узла из currentTree и созданного React Element.
Процесс можно обобщить следующим образом:
С помощью функции
renderWithHooks
выполняется вызов компонента. В процессе этого вызова создаются дочерние ReactElements.Затем на основе React Elements и узлов из currentTree создаются дочерние Fiber Nodes.
Эти два этапа обеспечивают последовательное построение workInProgressTree.
Кроме того, в процессе выполнения функции мы устанавливаем значения в поле flags
. Используя побитовое ИЛИ: flags |= Snapshot
, мы можем сохранять в поле flags
несколько значений для данного узла.
В конце работы beginWork
наше дерево будет выглядеть так:
Значение 1 обозначает PerformedWork - над узлом была выполнена работа. Данный флаг проставляется для классовых или функциональных компонент.
completeUnitOfWork
Функция completeUnitOfWork
активируется не всегда, а только в случае, когда next === null
. Здесь next
- это следующий дочерний узел. Таким образом, условие next === null
говорит нам о том, что у текущего узла нет ни одного дочернего элемента, который требуется обработать. В этом случае срабатывает функция completeUnitOfWork
.
Данная функция выполняет процедуру всплытия до ближайшего узла, у которого есть непроверенный соседний узел (sibling
). Во время всплытия мы задаем значение для поля subtreeFlags
следующим образом:
subtreeFlags |= child.subtreeFlags;
subtreeFlags |= child.flags;
Это поле содержит флаги всех его потомков. Благодаря этому, просмотрев родительский узел, мы можем понять, какие состояния и изменения хранят его потомки.
Кроме того, в ходе выполнения completeUnitOfWork
определяются изменения, которые следует внести в узел. Модифицированный DOM-элемент помещается в поле stateNode
, а узел помечается флагом Update.
Далее я приведу пример дерева, полученного в процессе выполнения completeUnitOfWork
. Однако этот пример будет описывать ситуацию, в которой была нажата кнопка, и значение счетчика изменилось с 0 на 1.
Первый раз всплытие начнется с узла button
, так как у него нет дочерних узлов, которые надо обработать. Мы всплывем до узла Button
, у которого есть необработанный sibling
узел - CounterResult
. Узел span
имеет flags = 100
, что обозначает Update. Таким образом, в конце прохода всего дерева узел Root
знает о том, что среди его потомков есть узлы с флагами PerformedWork и Update.
На этом разбор фазы render мы заканчиваем и переходим к фазе commit.
Фаза commit
В этой фазе мы уже работаем с деревом finishedWork, которое является workInProgressTree из фазы render:
const finishedWork = root.current.alternate;
root.finishedWork = finishedWork;
На стадии commit, используя построенное дерево и опираясь на поля flags
и subtreeFlags
, мы выполняем методы жизненного цикла, хуки и обновляем DOM.
Фаза commit начинается с функции commitRoot, но основная логика находится в commitRootImpl. В этой функции мы рассмотрим четыре основных подфункции, которые вызываются в следующем порядке:
commitBeforeMutationEffects - выполняет действия перед обновлением DOM. Например
getSnapshotBeforeUpdate
.commitMutationEffects - выполняет изменения DOM; отрисовка происходит позже, когда у браузера будет доступное время.
commitLayoutEffects - выполняет
useLayoutEffect
.flushPassiveEffects - выполняет
useEffect
.
Теперь давайте разберемся, как поля flags
и subtreeFlags
помогают нам. Допустим, у нас в subtreeFlags
есть значение 10000000000 - это флаг Snapshot. Перед выполнением commitBeforeMutationEffects
мы можем посмотреть на subtreeFlags
у узла Root
и понять, есть ли у потомков узел, на котором необходимо выполнить getSnapshotBeforeUpdate
. Эта проверка производится с помощью побитового И: subtreeFlags & Snapshot
. Таким образом, мы можем постепенно спускаться по дереву и в конечном итоге найти нужный нам узел.
То же самое применимо и для нашего примера. В случае выполнения commitMutationEffects
нам необходимо внести изменения в DOM. Как нам найти узлы, которые нужно обновить? Мы проверяем условие subtreeFlags & Update
, чтобы понять, есть ли у его потомков узлы, которые необходимо обновить. Таким образом, мы спускаемся по всему дереву и доходим до нашего узла span
.
Заключение
Мы с вами разобрали работу инкрементального рендеринга, который, разбивая рендеринг на части, помогает реализовать Concurrency. Таким образом, можем выполнять рендеринг не весь за раз, а по частям, постоянно анализирую, нет ли срочных обновлений, которые необходимо отрендерить, а также давая возможность браузеру выполнить отрисовку, не блокируя при этом его. Но подробнее об этом в следующей статье.