Подробный обзор React Fiber


Это перевод статьи Макса Корецки Inside Fiber: in-depth overview of the new reconciliation algorithm in React.

React — это JavaScript-библиотека для создания пользовательских интерфейсов. В ее основе лежит механизм, который отслеживает изменения стейта и отображает обновленный стейт на экране. В действительности, мы знаем этот процесс как сверку (reconciliation). Мы вызываем метод setState, и фреймворк проверяет, изменились ли стейт или пропсы, и ререндерит компонент.

Документация React предоставляют хороший обзор механизма сверки на высоком уровне: роль элементов React, методы жизненного цикла и метод render, а также дифференцирующий алгоритм, применяемый к дочерним компонентам. Дерево иммутабельных элементов React, возвращаемое из метода render, обычно называется “виртуальным DOM” (virtual DOM). Этот термин помог объяснить React людям на ранней стадии, но он также вызвал недопонимание и больше не используется в документации React. В этой статье я буду называть его деревом элементов React.

Кроме дерева элементов React, во фреймворке всегда было дерево внутренних элементов (компоненты, DOM ноды и т.д.), используемых для хранения стейта. Начиная с версии 16, React выпустил новую реализацию этого дерева внутренних элементов и алгоритма под названием Fiber, который управляет им. Чтобы узнать о преимуществах архитектуры Fiber, ознакомьтесь со статьей The How and why on React’s use of connected list in Fiber.

Это первая статья из серии, цель которой — научить вас внутренней архитектуре React. В этой статье я хочу дать подробный обзор важных концепций и структур данных, имеющих отношение к алгоритму. Как только у нас будет достаточно информации, мы исследуем алгоритм и основные функции, используемые для обхода и обработки дерева Fiber. В следующих частях цикла я покажу, как React использует алгоритм для выполнения первого рендеринга и обновления стейта и пропсов. Оттуда мы перейдем к деталям реализации планировщика (scheduler), процесса сверки дочерних компонентов и механизма построения списка эффектов.

Это довольно продвинутый материал. Я рекомендую вам прочитать эту статью, чтобы понять магию, лежащую в основе внутренней работы React. Эта серия статей также послужит вам отличным руководством, если вы планируете начать вносить свой вклад в React. Я часто использую реверс-инжиниринг, поэтому здесь будет много ссылок на источники из последней версии 16.6.0.

Обратите внимание, что вам не нужно знать ничего из этого, чтобы использовать React. Эта статья о том, как работает React внутри.

Настройка

Вот простое приложение, которое я буду использовать в этой серии статей. У нас есть кнопка, которая просто увеличивает отображаемое на экране число:

И вот реализация:

Пример доступен здесь. Это простой компонент, который возвращает два дочерних элемента button и span из метода render. Как только вы нажимаете на кнопку, стейт компонента обновляется внутри обработчика. Это приводит к обновлению текста в элементе span.

Во время сверки React выполняет много работы. Например, вот список высокоуровневых операций, выполняемых во время первого рендера и после обновления состояния в нашем простом приложении:

  • обновить пропс count в стейте ClickCounter
  • получить дочерние компоненты ClickCounter и сравнить их пропсы
  • обновить пропсы для span элемента

В процессе сверки выполняются и другие действия, такие как вызов методов жизненного цикла или обновление ссылок. Все эти действия в Fiber архитектуре в совокупности называются “работой” (work). Тип работы обычно зависит от типа React элемента. Например, для классового компонента React должен создать инстанс, в то время как для функционального компонента этого делать не нужно. В React есть много типов элементов, например, классовые и функциональные компоненты, хост-компоненты (DOM-ноды), порталы и т.д. Тип элемента React определяется первым параметром функции createElement. Эта функция обычно используется в методе renderдля создания элемента.

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

От React элементов к Fiber нодам

Каждый компонент React имеет UI-представление, которое мы можем назвать шаблоном, который возвращается из метода redner. Вот шаблон для нашего компонента ClickCounter:

React элементы

Как только шаблон проходит через компилятор JSX, вы получаете в итоге кучу React элементов. Это то, что на самом деле возвращается из метода render компонентов React, а не HTML. Поскольку нам не нужно использовать JSX, метод render для нашего компонента ClickCounter можно переписать следующим образом:

Вызовы метода React.createElement в методе render создадут две структуры данных, как указано ниже:

Вы видите, что React добавляет свойство $$typeof к этим объектам, чтобы однозначно идентифицировать их как элементы React. Затем мы видим свойства type, key и props, которые описывают элемент. Значения берутся из того, что вы передаете в функцию React.createElement. Обратите внимание, что React представляет текстовое содержимое елемента как свойство children. Также обработчик кликов является частью пропсов элемента button. Есть и другие поля на React элементах, такие как ref, которые выходят за рамки этой статьи.

React элемент ClickCounter не имеет пропсов и key

Fiber ноды

При сверке данные от каждого React элемента, возвращаемые методом render, сливаются в дерево fiber нод. Каждый React элемент имеет соответствующую fiber ноду . В отличие от элементов React, fiber-ноды не создаются заново при каждом рендеринге. Это мутабельные структуры данных, содержащие состояние компонентов и DOM.

Ранее мы обсуждали, что в зависимости от типа React элемента фреймворк должен выполнять различные виды задач. В нашем примере, для компонентов класса ClickCounter он вызывает методы жизненного цикла и метод render, в то время как для хост-компонента (DOM ноды) он выполняет мутацию DOM. Таким образом, каждый элемент React преобразуется в fiber ноду соответствующего типа, которая описывает задачи, которые необходимо выполнить.

Вы можете думать о fiber ноде как о структуре данных, которая представляет собой некоторую работу, или, другими словами, единицу работы. Архитектура fiber также обеспечивает удобный способ отслеживания, планирования, приостановки и прерывания работы.

Когда React элемент впервые преобразуется в fiber ноду, React использует данные этого элемента для создания fiber в функции createFiberFromTypeAndProps. В последующих обновлениях React повторно использует fiber ноды и просто обновляет необходимые свойства, используя данные от соответствующего элемента React. Также может понадобиться переместить ноду в иерархии на основе пропса key или удалить его, если соответствующий элемент React больше не возвращается из метода render.

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

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

Все fiber ноды соединяются через связанный список, используя следующие свойства: дочерние, child, sibling и return. Для более подробной информации о том, почему это работает таким образом, смотрите мою статью The how and why on React’s use of links list in Fiber, если вы еще не читали ее.

Current и WorkInProgress деревья fiber нод

После первого рендеринга React получает дерево fiber нод, которое отражает состояние приложения, использовавшегося для рендеринга. Это дерево часто называют current. Когда React начинает работать над обновлениями, он строит так называемое дерево WorkInProgress, которое отражает будущее состояние, которое будет выводиться на экран.

Все работы выполняются на нодах из дерева WorkInProgress. Когда React проходит через current дерево, для каждой существующей ноды он создает альтернативную, которая составляет дерево WorkInProgress. Эта нода создается с использованием данных из React элементов, возвращаемых render методом. Как только обновления будут обработаны и все связанные с ними работы будут завершены, React будет иметь альтернативное дерево, готовое к отображению на экране. Как только дерево WorkInProgress отображается на экране, оно становится current деревом.

Одним из ключевых принципов React является последовательность. React всегда обновляет DOM за один проход — он не показывает частичных результатов. Дерево WorkInProgress служит для пользователя “черновиком”, так что сначала React может обрабатывать все компоненты, а затем показывать их изменения на экране.

В исходном коде вы увидите множество функций, которые используют fiber ноды как из current дерева, так и из дерева WorkInProgress. Вот одна из таких функций:

Каждая fiber нода содержит ссылку на свой аналог из другого дерева в поле alternate. Нода из current дерева указывает на ноду из дерева WorkInProgress и наоборот.

Побочные эффекты

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

Скорее всего, вы ранее выполняли загрузку данных, подписку или измение DOM-элементов вручную из React. Мы называем эти операции “побочными эффектами” (или “эффектами” для краткости), потому что они могут повлиять на другие компоненты и не могут быть выполнены во время рендеринга.

Вы можете увидеть, как большинство обновлений стейта и пропсов приводят к побочным эффектам. А поскольку применение эффектов — это разновидность работы, fiber нода является удобным механизмом для отслеживания эффектов в дополнение к обновлениям. Каждая fiber нода может иметь эффекты, связанные с ним. Они содержатся в поле effectTag.

Таким образом, эффекты в Fiber в основном определяют работу, которая должна быть проделана для инстансов после обработки обновлений. Для хост-компонентов (DOM) работа состоит из добавления, обновления или удаления элементов. Для классовых компонентов может потребоваться обновление рефов и вызов методов жизненного цикла componentDidMount и componentDidUpdate. Существуют и другие эффекты, соответствующие другим типам fiber нод.

Список эффектов

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

Целью этого списка является помечать ноды, которые имеют обновления DOM или другие эффекты, связанные с ними. Этот список является подмножеством дерева finishedWork и связан с помощью свойства nextEfect вместо свойства child, используемого в current дереве и дереве WorkInProgress.

Дэн Абрамов предложил аналогию для списка эффектов. Он предлагает думать о ней как о елке, с “рождественскими гирляндами”, связывающими все эффектные узлы вместе. Чтобы визуализировать это, давайте представим себе следующее дерево fiber нод, где выделенные узлы имеют некоторую работу. Например, наше обновление привело к тому, что c2 был вставлен в DOM, d2 и c1 должны изменить аттрибуты, а b2 нужно запустить метод жизненного цикла. Список эффектов свяжет их вместе, чтобы React мог пропустить другие ноды позже:

Вы можете увидеть, как ноды с эффектами связаны между собой. При переборе нод React использует указатель firstEffect, чтобы определить, откуда начинается список. Таким образом, диаграмма выше может быть представлена в виде связного списка вроде этого:

Как видите, React действует в порядке от детей и до родителей.

Корневая нода fiber дерева

Каждое React приложение содержит один или несколько DOM элементов, которые действуют как контейнеры. В нашем случае это div элемент с ID container.

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

Эта корневая fiber нода хранит ссылку на fiber дерево. Она хранится в current свойстве fiber ноды:

Дерево fiber нод начинается со специального типа ноды, которым является HostRoot. Он создан внутри и выступает в качестве родительского компонента для вашего самого верхнего компонента. Есть связь между узлом HostRoot и FiberRoot через свойство StateNode:

Вы можете изучить fiber дерево, получив доступ к самой верхней ноде HostRoot через корневую fiber ноду. Или вы можете получить отдельную fiber ноду из такого инстанса компонента, как этот:

Структура fiber ноды

Теперь давайте рассмотрим структуру fiber нод, созданных для компонента ClickCounter.

и span DOM элемента:

У fiber нод достаточно много полей. Назначение полей alternate, effectTag иnextEffect я описал в предыдущих разделах. Теперь давайте посмотрим, зачем нам нужны другие.

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

type
Определяет функцию или класс, связанный с этой fiber нодой. Для классовых компонентов он указывает на конструктор, а для DOM элементов указывает HTML-тег. Я довольно часто использую это поле, чтобы понять, с каким элементом связана fiber нода.

tag
Определяет тип fiber ноды. Используется в алгоритме сверки для определения того, какие работы необходимо выполнить. Как упоминалось ранее, работа варьируется в зависимости от типа React элемента. Функция createFiberFromTypeAndProps сопоставляет React элемент с соответствующим типом fiber ноды. В нашем приложении свойство tag для ClickCountercomponent равно 1, что обозначает ClassComponent, а для span-элемента — 5, что означает HostComponent.

updateQueue
Очередь обновлений стейта, коллбеков и обновлений DOM.

memoizedState
Состояние fiber ноды, которое использовалось для вывода на экран. При обработке обновлений отображается текущее состояние, отображаемое на экране.

memoizedProps
Пропсы fiber ноды, которые были использованы для вывода во время предыдущего рендера.

pendingProps
Пропсы, которые были обновлены на основе новых данных в React элементах и должны быть применены к дочерним компонентам или DOM элементам.

key
Уникальный идентификатор группы дочерних элементов, чтобы помочь React выяснить, какие элементы были изменены, добавлены или удалены из списка. Это связано с описанной здесь функциональностью “списков и ключей” React.
Полную структуру fiber ноды можно найти здесь.

Я опустил несколько полей в объяснении выше. В частности, я пропустил указатели child, sibling и return, которые составляют структуру данных дерева, описанную в предыдущей статье. И категория полей типа expirationTime, childExpirationTime иmode которые специфичны для Scheduler


Общий алгоритм

React выполняет работу в два основных этапа: render и commit.

Во время первого рендера React применяет обновления к компонентам, запланированным через setState или React.render и выясняет, что необходимо обновить в пользовательском интерфейсе. Если это первый рендер, React создает новую fiber ноду для каждого элемента, возвращаемого из рендер метода. В следующих обновлениях fiber ноды для существующих элементов React используются повторно и обновляются. Результатом фазы является дерево fiber нод, отмеченных побочными эффектами. Эффекты описывают работу, которая должна быть выполнена в следующей commit фазе. Во время этой фазы React принимает дерево fiber нод, помеченное эффектами, и применяет его к инстансам. Он просматривает список эффектов и выполняет обновления DOM и другие изменения, видимые пользователю.

Важно понимать, что работа во время первого рендера может выполняться асинхронно. React может обработать один или несколько fiber нод в зависимости от доступного времени, затем остановиться, чтобы спрятать выполненную работу и уступить какому-то событию. Затем он продолжает с того места, на котором остановился. Иногда, однако, может потребоваться отказаться от проделанной работы и начать все сначала. Эти паузы стали возможны благодаря тому, что работа, выполненная на этом этапе, не приводит к каким-либо видимым изменениям для пользователя, таким как обновление DOM. Напротив, следующая commit фаза всегда синхроннна. Это связано с тем, что работа, выполняемая на этом этапе, приводит к изменениям, видимым пользователю, например, обновления DOM. Вот почему React должен сделать это за один проход.

Вызов методов жизненного цикла является одним из видов работ, выполняемых React. Некоторые методы вызываются во время renderфазы другие — во время commit фазы . Вот список жизненных циклов, вызываемых во время первой render фазы:

  • [UNSAFE_]componentWillMount (устарел)
  • [UNSAFE_]componentWillReceiveProps (устарел)
  • getDerivedStateFromProps
  • shouldComponentUpdate
  • [UNSAFE_]componentWillUpdate (устарел)
  • render

Как вы видите, некоторые старые методы жизненного цикла, которые выполняются в фазе render, отмечены как UNSAFE начиная с версии 16.3. В настоящее время их в документации называют легаси жизненными циклами. Они будут удалены в будущих релизах 16.x, а их аналоги без префикса UNSAFE будут удалены в 17.0. Подробнее об этих изменениях и предлагаемом пути миграции можно прочитать здесь.

Вам интересно, по какой причине это произошло?

Мы только что узнали, что поскольку render фаза не производит побочных эффектов, таких как обновления DOM, React может обрабатывать обновления асинхронно (потенциально даже делая это в нескольких потоках). Однако жизненный цикл, помеченный UNSAFE, часто неправильно понимается и используется не по назначению. Разработчики склонны помещать код с побочными эффектами внутри этих методов, что может вызвать проблемы с новым подходом асинхронного рендеринга. Хотя будут удалены только их коллеги без префикса UNSAFE, они все равно могут вызвать проблемы в предстоящем параллельном режиме (от которого вы можете отказаться).

Ниже приведен список методов жизненного цикла, выполняемых на второй commit фазе:

  • getSnapshotBeforeUpdate
  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

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

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

Render фаза

Алгоритм сверки всегда начинается с самой верхней fiber ноды HostRoot с помощью функции renderRoot. Однако, React пропускает уже обработанные fiber ноды, пока не обнаруживает ноду с незавершенной работой. Например, если вы вызываете setState глубоко в дереве компонентов, React будет начинаться сверху, но быстро пропускать родителей, пока не доберется до компонента, для которого был вызван метод setState.

Основные части рабочего цикла

Все fiber ноды обрабатываются в рабочем цикле. Ниже приведена реализация синхронной части цикла:

В приведенном выше коде, nextUnitOfWork содержит ссылку на fiber ноду из дерева workInProgress, у которого осталась незавершенная работа. Когда React обходит дерево fiber нод, он использует эту переменную, чтобы узнать, есть ли еще fiber нода с незавершенной работой. После обработки текущей fiber ноды переменная будет либо содержать ссылку на следующую ноду в дереве, либо содержать null. В этом случае React выходит из рабочего цикла и готов коммитить изменения.

Существует 4 основные функции, которые используются для перемещения по дереву и инициирования или завершения работы:

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

Обратите внимание, что прямые вертикальные соединения обозначают соседние элементы, в то время как изогнутые соединения обозначают дочерние элементы, например, b1 не имеет дочерних элементов, а b2 имеет один — c1.

Вот ссылка на видео, где можно приостановить воспроизведение и просмотреть текущую ноду и состояние функций. Концептуально, вы можете думать, что “begin” — это “шаг в” компонент, а “complete” — это “выйти” из него.

Начнем с первых двух функций performUnitOfWork и beginWork:

Функция performUnitOfWork, получает fiber ноду из дерева workInProgress и запускает работу, вызывая функцию beginWork. Эта функция запустит все действия, которые необходимо выполнить для fiber ноды. Для целей этой демонстрации мы просто записываем название ноды, чтобы указать, что работа была выполнена. Функция beginWork всегда возвращает указатель на следующую fiber ноду или null.

Если есть следующий дочерний элемент, то он будет присвоен переменной nextUnitOfWork в функции workLoop. Однако, если дочернего элемента нет, React знает, что он достиг конца ветки и может завершить работу над текущим элементом. После того, как работа будет завершена, ему нужно будет выполнить работу для соседних элементов и вернуться к родителям после этого. Это делается в полной completeUnitOfWork:

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

Как видно из реализации, и performUnitOfWork, и completeUnitOfWork используются в основном для итераций, в то время как основные действия выполняются в функциях beginWork и completeWork. В следующих статьях мы узнаем, что происходит с компонентом ClickCounter и span элементом, когда React начинает выполнять функцииbeginWork иcompleteWork.

Commit фаза

Фаза начинается с функции completeRoot. Здесь React обновляет DOM и вызывает методы жизненного цикла до и после мутации.

Когда React дойдет до этой фазы, у него будет 2 дерева и список эффектов. Первое дерево представляет состояние, отображаемое на экране в данный момент. Затем есть альтернативное дерево, построенное во время render фазы. Они называется finishedWork или workInProgress в исходниках и представляют состояние, которое должно быть отражено на экране. Это альтернативное дерево также связано с текущим деревом через указатели child и sibling.

Далее, есть список эффектов — подмножество элементов из finishedWork дерева, соединенных указателем nextEffect. Помните, что список эффектов является результатом выполнения render фазы. Весь смысл рендеринга состоял в том, чтобы определить, какие элементы нужно вставить, обновить или удалить, а какие компоненты должны вызвать методы жизненного цикла. И это то, что говорит нам список эффектов. И именно этот список элементов обходится во время commit фазы.

Для целей отладки доступ к current дереву можно получить через current свойство fiber ноды. Доступ к дереву finishedWork можно получить через alternate свойство ноду HostFiber в текущем дереве.

Основной функцией, которая выполняется во время commit фазы, является commitRoot. В основном, она делает следующее:

  • Вызывает метод getSnapshotBeforeUpdate на нодах, помеченных эффектом Snapshot
  • Вызывает метод componentWillUnmount на нодах, помеченных эффектом Deletion
  • Выполняет все обновления DOM дерева
  • Устанавливает дерево finishedWork как текущее
  • Вызывает метод componentDidMount на нодах, помеченных эффектом Placement
  • Вызывает метод componentDidUpdate на нодах, помеченных эффектом Update

После вызова метода getSnapshotBotBeforeUpdate перед мутацией, React коммитит все побочные эффекты в дереве. Он делает это за два прохода. Первый проход выполняет все обновления DOM дерева. Затем React присваивает дерево finishedWork дереву FiberRoot, отмечая дерево WorkInProgress как current дерево. Это делается после первого прохождения commit фазы, чтобы предыдущее дерево все еще оставалось текущим во время выполнения componentWillUnmount, но перед вторым проходом, чтобы законченная работа была текущей во время componentDidMount/Update. Во втором проходе React вызывает все остальные методы жизненного цикла и коллбеки. Эти методы выполняются отдельно, так что все обновления во всем дереве уже были совершены.

Вот функция, которая выполняет описанные выше действия:

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

Методы жизненного цикла перед мутацией

Вот, например, код, который итерируется по дереву эффектов и проверяет, имеет ли нода эффект Snapshot:

Для классового компонента этот эффект означает вызов метода getSnapshotBeforeUpdate.

Обновления DOM

commitAllHostEffects — это функция, в которой React выполняет обновление DOM. Функция определяет тип операции, которая должна быть выполнена для ноды, и выполняет ее:

Интересно, что React вызывает метод componentWillUnmount как часть процесса удаления в функции commitDeletion.

Методы жизненного цикла после мутации

commitAllLifecycles это функция, в которой React вызывает componentDidUpdate и componentDidMount.

Владимир Голотин

Written by

Разработчик в Breadhead

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade