В сети много статей и докладов, которые описывают React Fiber, но, к сожалению, они сейчас уже не актуальны. Заметив это, я решил разобраться и актуализировать информацию. Моими основными помощниками были исходники и отладчик, поэтому здесь вы увидите множество ссылок на код из репозитория React. Теперь я хочу поделиться результатами своей работы с вами.

Тема разделена на две статьи. Первая статья расскажет о процессе обновления и внесения изменений в DOM. Вторая статья посвящена реализации не блокирующего рендеринга - Concurrent React. Данная статья является первой из двух.

Хочу заметить, что первая статья, возможно, не принесет значительной практической пользы, однако описанный в ней материал рекомендуем для понимания реализации Concurrent React. Эта статья будет особенно полезна для тех, кто хочет разобраться в работе инструмента изнутри, а также извлечь интересные идеи по написанию и структурированию кода.

React Fiber

React Fiber решает две основные задачи:

  1. Инкрементальный процесс рендеринга — способность разделять работу рендера на части. Здесь концепция и реализация во многом не поменялись, хотя и претерпели некоторые изменения.

  2. Возможность не блокирующего рендеринга, при котором рендер не должен блокировать взаимодействия пользователя с сайтом и отображение анимации. Здесь все наоборот: концепция долго разрабатывалась и видоизменялась. Подробности о том, каким образом была решена эта задача, я раскрываю в следующей статье. Важно заметить, что реализация первого аспекта, инкрементального рендеринга, является базой для реализации второго.

Под рендером следует понимать процесс обновления компонентов и вычисления изменений между предыдущим и текущим рендерами для последующего внесения изменений в DOM.

Далее мы разберем, как команда React решила первую задачу. В этой статье рассмотрим процесс инкрементального рендеринга, но разберем его в рамках всей работы по обновлению: обновление компонентов и внесение изменений в DOM.

Работа по обновлению

Вся работа по обновлению делится на 2 фазы:

  1. Фаза render, которую иногда также называют reconciliation. В этой фазе React применяет обновления к компонентам, сравнивает предыдущее состояние приложения с текущим и выясняет, какую работу по обновлению нужно выполнить.

  2. Фаза 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.

Процесс можно обобщить следующим образом:

  1. С помощью функции renderWithHooks выполняется вызов компонента. В процессе этого вызова создаются дочерние ReactElements.

  2. Затем на основе 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. Таким образом, можем выполнять рендеринг не весь за раз, а по частям, постоянно анализирую, нет ли срочных обновлений, которые необходимо отрендерить, а также давая возможность браузеру выполнить отрисовку, не блокируя при этом его. Но подробнее об этом в следующей статье.

Комментарии (0)