React ⚛️
January 12

Детальный React. Реконсиляция, рендеры, Fiber, виртуальное дерево

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

На момент написания статьи, последняя стабильная версия React 18.2.0. За 10 лет разработчики Facebook проделали колосальный объем работы, было реализовано много фич и сделано не мало оптимизаций. За эти годы, так же, происходили и кардинальные архитектурные изменения. Очевидно, одной статьи мало, чтобы покрыть всю механику React, поэтому, данная публикация станет первой в серии статей о внутреннем устройстве React. Здесь мы познакомимся с основными сущностями и архитектурными решениями.

Точка входа

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

Без примеров, все так, обойтись не получится. Взглянем на типичный React-код для Web-приложения.

import ReactDOM from 'react-dom/client';

const root = ReactDOM.createRoot(document.getElementById('root'));

root.render(<div>My React App</div>);

Этот код использует пакет react-dom/client для создания Root-контейнера и, далее, рендера DIV-элемента в этот контейнер. Именно здесь и находится тот самый пусковой механизм React, точнее, здесь их сразу два: создание контейнера посредством createRoot, и запуск процесса рендеринга в контейнере. Но обо всем по порядку.

Реконсиляция

Как мы знаем из открытых источников, прежде чем изменения попадут в DOM, React сначала производит все необходимые модификации в так называемом виртуальном дереве. После чего, уже это виртуальное дерево "попадает" в реальный DOM. Процесс согласования виртуального дерева с реальным DOM и называется реконсиляцией.

Дополнительную сложность процессу создает тот факт, что сегодня существуют разные платформы, где итоговый UI может быть выведен (на экран или, например, в строку или файл). В частности, сам React предусматривает рендеринг в Web, серверный рендеринг (SSR), рендеринг на мобильных устройствах (React Native) и др.

В этой связи, команда React выделила отдельный пакет react-reconcile в качестве некоего абстрактного API. Он работает в двух режимах:

  • Mutation mode - для платформ, позволяющих мутировать итоговое дерево (т.е. имеют методы, схожие с appendChild/removeChild)
  • Persistent mode - для платформ с иммутабельными деревьями. В этом случае, на каждое изменение, все дерево клонируется целиком, производятся модификации, а затем все дерево полностью заменяется на модифицированное

Сам этот пакет не осуществляет конечную привязку к DOM, а только обеспечивает всю необходимую механику по подготовке и манипуляции элементами. Сама же непосредственная привязка к DOM осуществляется средствами внешнего провайдера, реализующего API пакета react-reconciler. Реализации провайдером заключается в выставлении конкретных флагов-настроек и описании методов-колбэков, таких как createInstance, appendChild, removeChild и др.. Такой подход позволяет создавать разные провайдеры для разных случаев и платформ.

Рендеры

Провайдеры, реализующие API пакета react-reconciler, условно, называются рендеры. Теоретически, пакет react-reconciler задумывался как полноценный API, но, с момента его создания в 2017-м работы по нему ведутся довольно активно, периодически случаются существенные изменения, поэтому, формально, пакет считается unstable, использовать его напрямую в своих проектах стоит с осторожностью.

Сам же React предлагает несколько реализаций рендеров "из коробки".

  • React DOM - этот рендер мы уже видели в примере выше. Он обеспечивает привязку к DOM-дереву браузера
  • React Native - этот рендер обеспечивает нативный рендеринг на мобильных платформах
  • React ART - позволяет рисовать векторную графику средствами React. Фактически, является реактивной оболочкой для библиотеки ART.

Fiber

Прежде чем двинуться дальше, важно познакомиться с базовой сущностью движка React.

Fiber - это внутренний объект React, представляющий задачу ("работу"), которую движок запланировал к выполнению или уже выполнил.

На основании этих объектов и будет работать пакет react-reconciler, о котором мы говорили чуть выше.

/packages/react-reconciler/src/ReactInternalTypes.js#L67

// A Fiber is work on a Component that needs to be done or was done. There can
// be more than one per component.
export type Fiber = {
  // These first fields are conceptually members of an Instance. This used to
  // be split into a separate type and intersected with the other Fiber fields,
  // but until Flow fixes its intersection bugs, we've merged them into a
  // single type.
  
  // An Instance is shared between all versions of a component. We can easily
  // break this out into a separate object to avoid copying so much to the
  // alternate versions of the tree. We put this on a single object for now to
  // minimize the number of objects created during the initial render.
  
  // Tag identifying the type of fiber.
  tag: WorkTag,
  
  // Unique identifier of this child.
  key: null | string,
  
  // The value of element.type which is used to preserve the identity during
  // reconciliation of this child.
  elementType: any,
  
  // The resolved function/class/ associated with this fiber.
  type: any,
  
  // The local state associated with this fiber.
  stateNode: any,
  
  // Conceptual aliases
  // parent : Instance -> return The parent happens to be the same as the
  // return fiber since we've merged the fiber and instance.
  
  // Remaining fields belong to Fiber
  
  // The Fiber to return to after finishing processing this one.
  // This is effectively the parent, but there can be multiple parents (two)
  // so this is only the parent of the thing we're currently processing.
  // It is conceptually the same as the return address of a stack frame.
  return: Fiber | null,
  
  // Singly Linked List Tree Structure.
  child: Fiber | null,
  sibling: Fiber | null,
  index: number,
  
  // The ref last used to attach this node.
  // I'll avoid adding an owner field for prod and model that as functions.
  ref:
    | null
    | (((handle: mixed) => void) & {_stringRef: ?string, ...})
    | RefObject,
    
  refCleanup: null | (() => void),
    
  // Input is the data coming into process this fiber. Arguments. Props.
  pendingProps: any, // This type will be more specific once we overload the tag.
  memoizedProps: any, // The props used to create the output.
  
  // A queue of state updates and callbacks.
  updateQueue: mixed,
  
  // The state used to create the output
  memoizedState: any,
  
  // Dependencies (contexts, events) for this fiber, if it has any
  dependencies: Dependencies | null,
  
  // Bitfield that describes properties about the fiber and its subtree. E.g.
  // the ConcurrentMode flag indicates whether the subtree should be async-by-
  // default. When a fiber is created, it inherits the mode of its
  // parent. Additional flags can be set at creation time, but after that the
  // value should remain unchanged throughout the fiber's lifetime, particularly
  // before its child fibers are created.
  mode: TypeOfMode,
  
  // Effect
  flags: Flags,
  subtreeFlags: Flags,
  deletions: Array<Fiber> | null,
  
  // Singly linked list fast path to the next fiber with side-effects.
  nextEffect: Fiber | null,
  
  // The first and last fiber with side-effect within this subtree. This allows
  // us to reuse a slice of the linked list when we reuse the work done within
  // this fiber.
  firstEffect: Fiber | null,
  lastEffect: Fiber | null,
  
  lanes: Lanes,
  childLanes: Lanes,
  
  // This is a pooled version of a Fiber. Every fiber that gets updated will
  // eventually have a pair. There are cases when we can clean up pairs to save
  // memory if we need to.
  alternate: Fiber | null,
  
  // Time spent rendering this Fiber and its descendants for the current update.
  // This tells us how well the tree makes use of sCU for memoization.
  // It is reset to 0 each time we render and only updated when we don't bailout.
  // This field is only set when the enableProfilerTimer flag is enabled.
  actualDuration?: number,
  
  // If the Fiber is currently active in the "render" phase,
  // This marks the time at which the work began.
  // This field is only set when the enableProfilerTimer flag is enabled.
  actualStartTime?: number,
  
  // Duration of the most recent render time for this Fiber.
  // This value is not updated when we bailout for memoization purposes.
  // This field is only set when the enableProfilerTimer flag is enabled.
  selfBaseDuration?: number,
  
  // Sum of base times for all descendants of this Fiber.
  // This value bubbles up during the "complete" phase.
  // This field is only set when the enableProfilerTimer flag is enabled.
  treeBaseDuration?: number,
  
  // Conceptual aliases
  // workInProgress : Fiber ->  alternate The alternate used for reuse happens
  // to be the same as work in progress.
  // __DEV__ only
  
  _debugSource?: Source | null,
  _debugOwner?: Fiber | null,
  _debugIsCurrentlyTiming?: boolean,
  _debugNeedsRemount?: boolean,
  
  // Used to verify that the order of hooks does not change between renders.
  _debugHookTypes?: Array<HookType> | null,
};

Как мы видим, у объекта довольно много свойств. В рамках этой статьи мы не сможем разобрать их все, но взглянем на те, что позволят нам понять механику работы движка.

tag

Важный параметр, указывающий на тип сущности. На данный момент их всего 28 штук

export const FunctionComponent = 0;
export const ClassComponent = 1;
export const IndeterminateComponent = 2; // Before we know whether it is function or class
export const HostRoot = 3; // Root of a host tree. Could be nested inside another node.
export const HostPortal = 4; // A subtree. Could be an entry point to a different renderer.
export const HostComponent = 5;
export const HostText = 6;
export const Fragment = 7;
export const Mode = 8;
export const ContextConsumer = 9;
export const ContextProvider = 10;
export const ForwardRef = 11;
export const Profiler = 12;
export const SuspenseComponent = 13;
export const MemoComponent = 14;
export const SimpleMemoComponent = 15;
export const LazyComponent = 16;
export const IncompleteClassComponent = 17;
export const DehydratedFragment = 18;
export const SuspenseListComponent = 19;
export const ScopeComponent = 21;
export const OffscreenComponent = 22;
export const LegacyHiddenComponent = 23;
export const CacheComponent = 24;
export const TracingMarkerComponent = 25;
export const HostHoistable = 26;
export const HostSingleton = 27;

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

key

Тот самый React key, без которого не обходится ни один массив (и не только массив)

type

Непосредственно тип Fiber. Тут может быть ссылка на класс компонента, функцию функционального компонента, тэг браузерного элемента, или, например, символ-указатель на хук, контекст или какой-либо другой эффект.

stateNode

Ссылка на сопоставленную ноду в DOM-дереве.

child, sibling, index

Ссылки на дочерний элемент, соседний элемент и индекс самого этого Fiber на своем уровне (в случае, если сам Fiber является элементом массива)

Эти ссылки нужны для реализации паттерна Линейный односвязный список.

ref

Свойство ref, переданное в компонент

pendingProps, memoizedProps

Слепки объекта props.

  • memoizedProps - старые (текущие) свойства компонента
  • pendingProps - новые свойства компонента

Именно они и будут, в будущем, сравниваться, чтобы принять решение, требуется ли компоненту перерисовка

memoizedState

Текущее состояние компонента. В отличие от pendingProps/memoizedProps, у компонента нет будущего состояния, только текущее, т.к. оно не передается из вне, а "живет" внутри компонента. Слепок следующего состояния будет посчитан непосредственно во время фазы render, при чем алгоритм расчета может отличаться в зависимости от того, функциональный это компонент или классовый.

flags, subtreeFlags

32-битное число для самого Fiber и его детей, соответственно. Является маской для набора флагов, отражающих фазу/состояние текeщего Fiber. При проходе дерева, на каждой итерации движок будет проверять флаги тем или иным образом и уводить Fiber в соответствующую алгоритмическую веткую.

deletions

Массив Fiber-ов, требующих удаления из дерева.

nextEffect

Ссылка на следующий Fiber, представляющий side-effect (например, хук). Как и в случае с child, является реализацие паттерна Линейный односвязный список.

firstEffect, lastEffect

Первый и последний Fiber-ы, представляющие side-effect в данном поддереве.

lanes, childLanes

32-битное число для самого Fiber и его детей, соответственно. Определяет Fiber в какую-либо полосу. Нужно для приоритизации задач. Задачи и некоторых полос более приоритетны по отношению к другим полосам, и будут выполнены в первую очередь.

alternate

Одно из ключевых свойств Fiber. Здесь будет храниться копия самого Fiber во время фазы render. Все изменения будет происходить в этой копии. Сам Fiber будет изменен в фазе commit.

FiberRoot

Дополнительно, для работы с Root-контейнером, в React предусмотрен отдельный тип FiberRoot.

Состав его свойств отличается от состава свойств Fiber, здесь их гораздо больше, например, представлены свойства для работы в режиме Suspence, а так же свойствва для профилировщика.

/packages/react-reconciler/src/ReactInternalTypes.js#L334

export type FiberRoot = {
  ...BaseFiberRootProperties,
  ...SuspenseCallbackOnlyFiberRootProperties,
  ...UpdaterTrackingOnlyFiberRootProperties,
  ...TransitionTracingOnlyFiberRootProperties,
  ...
};

Из всего набора свойств FiberRoot, сегодня нам будут интересны только некоторые

tag

Аналогично Fiber, но может быть только двух значений

export const LegacyRoot = 0;
export const ConcurrentRoot = 1;

LegacyRoot - устаревший вариант создания контейнера. Вплоть до версии React 17 включительно, контейнер создавался и отрисовывался посредством вызова одного метода render

import ReactDOM from 'react-dom';

ReactDOM.render(<div>My React App</div>, document.getElementById('root'));

С версии React 18 такой подход считается устаревшим и не рекомендуется к использованию. Вместо этого предлагается создавать контейнер через createRoot, который создает контейнер типа ConcurrentRoot, работа которого кардинально отличается от старой версии и, в том числе, поддерживает режим Suspense.

current

Динамическая ссылка на текущий Fiber, находящийся, в данный момент в работе. Ссылка будет меняться по мере обхода дерева пакетом react-reconciler.

pendingLanes, suspendedLanes, pingedLanes, expiredLanes, errorRecoveryDisabledLanes, finishedLanes

Различные флаги полос, используемые в процессе рендеринга.

Жизненный цикл

Создание Root-контейнера

Вернемся к нашему исходному примеру

import ReactDOM from 'react-dom/client';

const root = ReactDOM.createRoot(document.getElementById('root'));

Вызывая метод createRoot рендера ReactDOM, мы создаем экземпляр FiberRoot

root: FiberRootNode {
  callbackNode: null,
  callbackPriority: 0,
  containerInfo: div#root,
  context: null,
  current: FiberNode {
    actualDuration: 0,
    actualStartTime: -1,
    alternate: null,
    child: null,
    childLanes: 0,
    deletions: null,
    dependencies: null,
    elementType: null,
    flags: 0,
    index: 0,
    key: null,
    lanes: 0,
    memoizedProps: null,
    memoizedState: {
      element: null,
      isDehydrated: false,
      cache: null,
      transitions: null,
      pendingSuspenseBoundaries: null,
    },
    mode: 3,
    pendingProps: null,
    ref: null,
    return: null,
    selfBaseDuration: 0,
    sibling: null,
    stateNode: {/* ref to the FiberRootNode */},
    subtreeFlags: 0,
    tag: 3,
    treeBaseDuration: 0,
    type: null,
    updateQueue: {
      baseState: {
        cache: null,
        element: null,
        isDehydrated: false,
        pendingSuspenseBoundaries: null,
        transitions: null,
      },
      firstBaseUpdate: null,
      lastBaseUpdate: null,
      shared: {
        interleaved: null,
        lanes: 0,
        pending: null,
      },
      effects: null,
    },
    _debugHookTypes: null,
    _debugNeedsRemount: false,
    _debugOwner: null,
    _debugSource: null
  },
  effectDuration: 0,
  entangledLanes: 0,
  entanglements: (31) [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
  eventTimes: (31) [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
  expirationTimes: (31) [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1],
  expiredLanes: 0,
  finishedLanes: 0,
  finishedWork: null,
  identifierPrefix: "",
  memoizedUpdaters: Set(0) {size: 0},
  mutableReadLanes: 0,
  mutableSourceEagerHydrationData: null,
  onRecoverableError: ƒ reportError(),
  passiveEffectDuration: 0,
  pendingChildren: null,
  pendingContext: null,
  pendingLanes: 0,
  pendingUpdatersLaneMap: (31) [Set(0), Set(0), Set(0), Set(0), Set(0), Set(0), Set(0), Set(0), Set(0), Set(0), Set(0), Set(0), Set(0), Set(0), Set(0), Set(0), Set(0), Set(0), Set(0), Set(0), Set(0), Set(0), Set(0), Set(0), Set(0), Set(0), Set(0), Set(0), Set(0), Set(0), Set(0)],
  pingCache: null,
  pingedLanes: 0,
  suspendedLanes: 0,
  tag: 1,
  timeoutHandle: -1,
  _debugRootType: "createRoot()",
}

Вместе с контейнером создается и неинициализированный FiberNode с tag = 3 (HostRoot).

На этом этапе, так же инициализируется временный кэш (пока контейнер еще не смонтирован).

Гидрация Root-контейнера

Далее, мы впервые вызываем метод render на контейнере

root.render(<div>My React App</div>);

В первую очередь, реконсилер запрашивает полосу приоритета для текущей "работы" (текущего, еще не инициализированного FiberNode, хранящегося в .current), которая зависит от режима рендеринга (Suspense/Sync) и от того, смонтирован ли элемент или еще нет.

Вся последующая работа будет проводиться над FiberNode, как над абстрактным Root-элементом, т.е. реконсилер не нужно знать является ли нода контейнером или нет, весь последующий цикл будет одинаков для любой ноды, которая инициирует обновление.

Далее создается контекст самого React-компонента. В нашем примере, на этом этапе в работу включается JSX-парсер, который разберет наш HTML-like синтаксис в ReactComponent. Работу JSX в этой статье мы рассматривать не будем. Вместо JSX, мы могли бы создать элемент посредством React.createElement('div'), на реконсиляцию это никак не виляет (не считая накладных расходов на парсинг JSX).

Для полученного экземпляра ReactComponent так же запрашивается полоса Update. Если компонент содержит в себе другие компоненты, они так же будут определены позже, на следующих тиках и тоже встанут в соответствующую полосу.

Далее, в планировщик прописывается ссылка на текущую "работу" (Fiber), а так же весь Fiber ставится в очередь на обновление в специальный стэк concurrentQueues (для обработки возможных Suspenses).

Корневой Fiber (в нашем случае, Root-контейнер), помечается как обновленный (но еще незавершенный) посредством выставления флага

root.pendingLanes |= updateLane

Здесь же выставляются и флаги suspendedLanes, если требуется.

В конце процесса запускается проверка запланированных обновлений Root-нод.

Планировщик

Планировщик занимается обработкой поставленных в очередь задач (Fiber-ов) и всегда работает в микротаске или после завершение обработки текущего события. Конкретный рендер имеет возможность определить свою специфику работы планировщика посредством переопределения scheduleMicrotask в API пакета react-reconciler. Например React Native может использовать для этих целей queueMicrotask, если поддерживается платформой, а React DOM - промисы.

В нашем базовом примере проверка планировщика запустилась вручную на вызове render-метода. Далее, планировщик будет слушать React-события и запускать проверку по необходимости.

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

В конечном итоге, при обнаружении Fiber-компонента (Fiber, представляеющий React-компонент), запускается обработка данного Fiber.

Обработка Fiber

Ключевым объектом при обработке Fiber является копия текущей нода, хранимая в Fiber.alternate. Ссылка на этот Fiber.alternate прописывается в FiberRootNode.current. Далее, каждый последующий (соседний или дочерний) компонент будет прописывать в current ссылку на свой alternate, а все последующие механизмы будут обращаться к ней как к текущему объекту, находящемуся в работе. Таки образом, одновременно, в обработке находится всегда только один компонент, а остальные будут стоять в очереди, пока не наступит их черед.

Фазы

В момент начала обработки Fiber, запускается целый цикл операций над нодой, которые, условно можно разделить на четыре фазы.

Фаза begin

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

В нашем примере, как мы видели чуть ранее, Fiber.alternate пока еще пустой. Это говорит реконсилеру, что компонент точно еще не был ни разу смонтирован (или был размонтирован умышленно). В этом случае, текущая задача помечается флагом didReceiveUpdate = false, соответственно, все последующие операции не будут пытаться обновить компонент.

В случае наличия Fiber.alternate осуществляется сравнение memoizedProps /* old props */ !== pendingProps /* new props */, и если они не равны, задача помечается флагом didReceiveUpdate = true.

Сразу после этого происходит проверка Fiber.tag. В нашем случае Fiber.tag === 3 /* IndeterminateComponent */. Данный тэг говорит о том, что мы пока еще не знаем, какого типа компонент нас ожидает, а значит, он никак не может быть уже смонтирован.

Всего таких тэгов, которые указывают на несмонтированный компонент - три

  • IndeterminateComponent
  • LazyComponent
  • IncompleteClassComponent

В этих случаях будут запущены соответствующие, каждому из трех типов, методы, монтирующие компонент.

Все остальные типы тэгов назначаются уже после этапа монтирования, а значит для них будут запущены методы, только обновляющие компонент.

В нашем примере, для дальнейшей работы требуется определить тип компонента, который будет смонтирован, что происходить примерно следующим образом

if (
  !disableModulePatternComponents &&
  typeof value === 'object' &&
  value !== null &&
  typeof value.render === 'function' &&
  value.$typeof === undefined
) {
  workInProgress.tag = ClassComponent;
  //...
} else {
  workInProgress.tag = FunctionComponent;
  //...
}

Т.е., упрощая выше написанное, классом считается компонент, который представляет собой объект с методом render (в виде функции). В противном случае, компонент считается функциональным.

В случае с классовыми компонентами запускается цикл монтирования этого класса. Тут будут вызваны методы жизненного цикла компонента, установлены внутренние this.state и this.props.

Так как классовые компоненты считаются устаревшими, рассматривать процесс их монтирования в этой статье детально мы не будем, а сосредоточимся на функциональных компонентах.

В функциональных компонентах, на этом этапе, происходит вызов функции reconcileChildren

/packages/react-reconciler/src/ReactFiberBeginWork.new.js#L288

export function reconcileChildren(
  current: Fiber | null,
  workInProgress: Fiber,
  nextChildren: any,
  renderLanes: Lanes,
) {
  if (current === null) {
    // If this is a fresh new component that hasn't been rendered yet, we
    // won't update its child set by applying minimal side-effects. Instead,
    // we will add them all to the child before it gets rendered. That means
    // we can optimize this reconciliation pass by not tracking side-effects.
    workInProgress.child = mountChildFibers(
      workInProgress,      
      null,      
      nextChildren,      
      renderLanes,    
      );  
  } else {    
    // If the current child is the same as the work in progress, it means that    
    // we haven't yet started any work on these children. Therefore, we use    
    // the clone algorithm to create a copy of all the current children.    
    
    // If we had any progressed work already, that is invalid at this point so    
    // let's throw it out.    
    workInProgress.child = reconcileChildFibers(
      workInProgress,      
      current.child,      
      nextChildren,      
      renderLanes,    
    );  
  }
}

Не смотря на то, что мы уже знаем, нужно ли компонент монтировать или только обновлять, эта функция универсальная и используется во многих местах. Она еще раз проверяет наличие current (он же Fiber.alternate) и решает, по какому циклу дальше пойдет реконсиляция.

Фаза render

В этой фазе происходит формирование так называемого "виртуального дерева". Мы уже знаем тип Fiber (это может быть классовый компонент, фнкциональный, вызов хука, или, например, обновление контекста). Если Fiber является компонентом, его дальнейшая ветка жизненного цикла так же уже определена (будет ли он монтироваться или только обновляться).

Вернемся к нашему примеру. Реконсилер только что отработал функцию reconcileChildren, которая, в свою очередь вызвала mountChildFibers или reconcileChildFibers. Однако, в сущности, обе эти функции делают одну и ту же операцию - создают экземпляр класса ChildReconciler, точнее, класса такого нет, но есть соответствующий тип, а создание экземпляра - ни что иное, как вызов фабрики, возвращающей объект этого типа.

/packages/react-reconciler/src/ReactChildFiber.new.js#L1349

export const reconcileChildFibers = ChildReconciler(true);
export const mountChildFibers = ChildReconciler(false);

/packages/react-reconciler/src/ReactChildFiber.new.js#L265

function ChildReconciler(shouldTrackSideEffects) {
  ...
}

Разница между reconcileChildFibers и mountChildFibers только в том, что первая выставляет флаг shouldTrackSideEffects, который позже будет использоваться во время работы ChildReconciler, в основном для того, чтобы определить требуется ли удалить cам Fiber и/или его детей (несмонтированный компонент не надо удалять, как и его детей).

Внутри класса будет определен тип элемента, требующего монтирования/обновления и будут вызван соответствующие колбэки привязанного рендера (в нашем случае ReactDOM), а рендер, уже на своей стороне произведет соответствующие мутации склонированного Root-элемента (т.е. корня виртуального дерева для монтируемого/обновляемого Fiber).

Здесь же произойдет обход дочерних элементов первого уровня (в качестве детей может выступать как один компонент, так и массив). Каждый из дочерних элементов рекурсивно пройдет такую же реконсиляцию, как и его родитель и, в конечном итоге, попадет в то же виртуальное дерево как одна из веток дерева.

Фаза commit

В данной фазе происходят последние приготовления виртуального дерева, включая выполнение необходимых эффектов.

На каждом тике обработки единицы "работы" вызывается проверка завершенности этой работы. Для этого, в модуле ReactFiberWorkLoop предусмотрен специальный флаг-статус

/packages/react-reconciler/src/ReactFiberWorkLoop.new.js#L299

let workInProgressRootExitStatus: RootExitStatus = RootInProgress;

Который может принимать 7 статусов

/packages/react-reconciler/src/ReactFiberWorkLoop.new.js#L269

type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5 | 6;
const RootInProgress = 0;
const RootFatalErrored = 1;
const RootErrored = 2;
const RootSuspended = 3;
const RootSuspendedWithDelay = 4;
const RootCompleted = 5;
const RootDidNotComplete = 6;

По умолчанию, при старте цикла реконсиляции флаг имеет значение RootInProgress.

Вернемся ненадолго в предыдущей фазу render. Там, обнаруживая очередной дочерний Fiber, реконсилер ставит в качестве Root-элемента текущий дочерний Fiber и идет глубже, рекурсивно, уже по его детям. Дойдя до самого глубокого Fiber без детей и обработав его, реконсилер переходит к соседнему ребенку, если он имеется (для этого в Fiber есть ссылка sibling). Обработав все дочерние Fiber на этом уровне, реконсилер возвращается к родительскому Fiber и обрабатывает соседние элементы уже на этом уровне. Так будет происходить до тех пор, пока реконсилер не достигнет верхнего уровня, т.е. Fiber, у которого нет родителя. Завершив обработку верхнего уровня, реконсилер выставляет флаг-статус workInProgressRootExitStatus = RootCompleted, что служит сигналом к началу фазы commit для этого конкретного Root-элемента.

Получив статус RootCompleted на очередном тике, первым делом, в случае наличия Suspenses, реконсилер вызывает колбэк рендрера startSuspendingCommit, позволяющий рендеру подготовить внутренний стейт для приема Suspensey ресурсов. В основном, это нужно, чтобы инициировать загрузку стилей этих ресурсов.

Далее, реконсилер обходит все виртуальное дерево и привязывает листнеры ко всем таким ресурсам. Всё происходит одной синхронной транзакцией.

По завершении транзакции, реконсилер, посредством вызова waitForCommitToBeReady запрашивает рендер, готов ли он принять ресурсы или требуется подождать. Рендер, в свою очередь, возвращает функцию-подписчик, в которая асинхронно сообщит реконсилеру, можно ли завершать элементы. На этом этапе, например, React DOM ждет завершения загрузки стилей компонентов, только после этого их можно будет привязывать к реальному DOM.

Если Suspeney ресурсов больше нет, фаза переходит к следующим этапам.

Сбрасываются флаги и приоритеты. Очищается кэш элемента.

Далее, отдельными этапами присходит выполнение, так называемых beforeMutationEffects. Эта стадия разбита на несколько подэтапов. Сначала осуществляется обход и подготовка эффектов по всем дочерним элементам. Потом, в зависимости от типа Fiber, выполняются эффекты на текущем Fiber и далее, эффекты соседних элементом на этом уровне.

После этого наступает черед mutationEffects. Они выполняются на текущем Fiber.

Далее вызывается колбэк рендера resetAfterCommit. Это означает, что все мутации дерева были произведены и теперь у рендера есть возможность сделать что-нибудь в этой точке. Например, ReactDOM здесь восстанавливает выделение текста, если оно было.

В данный момент виртуальное дерево считается уже полностью подготовленным, но с реальным DOM еще не связано. Грубо говоря, это самое место для вызова, например, componentWillUnmount

Фаза layout

После того как вирутальное дерево полностью сформировано, и все Mutation-эффекты выполнены, пора приступить к его привязке к реальному DOM (или выводу в output каким-то другим способом).

В данный момент реконсилер уже дошел до Root-элемента. Всё что остается сделать, это произвести мутацию самого этого Root-элемента, после чего, вызвать колбэк comitMount рендера, который позволит рендеру сделать последние необходимые изменения. В случае с ReactDOM, выставить фокус, если элемент был текстовым полем с параметром autoFocus и прописать атрибут src, если элемент был тэгом img.

Далее, настало время позволить браузеру отрисовать изменения. Для этого браузер надо попросить отступить в конец фрейма посредством navigator.scheduling.isInputPending.

Дополнительно происходит инкремент счетчика количества перерендеров Root-элемента, с целью выявления бесконечного обновления.

Происходит сбор оставшихся флагов и таймеров.

На этом процесс реконсиляции можно считать завершенным. Как только произойдут новые изменения в каком-либо из Fiber, для этого Fiber будет запущен новый цикл реконсиляции начиная с того элемента, который был изменен (или добавлен).


Мои телеграмм-каналы:

EN - https://t.me/frontend_almanac
RU - https://t.me/frontend_almanac_ru

English version: https://blog.frontend-almanac.com/JqtGelofzm1