Пишем свой React: Fiber, хуки и рендеринг под капотом
- воскресенье, 30 августа 2026 г. в 00:00:02
Каждый день мы пишем JSX и используем хуки. Но как это работает под капотом? Чтобы разобраться, мы с нуля напишем собственную версию React.
За основу мы возьмем отличный туториал Didact, но пойдем дальше: переведем все на TypeScript, добавим поддержку Фрагментов, реализуем useEffect и useContext. К концу стать React превратится для вас в понятный алгоритм)
Что мы знаем про JSX? Это разметка, которая расширяет возможности JavaScript и позволяет писать в нем разметку, вот пример:
const heading = <h1 title="foo">Заголовок</h1>
JSX — синтаксический сахар. Браузер его не понимает, поэтому в дело вступают транспиляторы (тот же Babel), превращая теги в вызовы createElement:
const heading = React. createElement( «h1», { title: «foo» }, «Заголовок» );
Вы можете поэкспериментировать с этим на данном сайте, только в конфиге впишите это:
{ «presets»: [ [ «react», { «runtime»: «classic» } ] ] }
Функция createElement в свою очередь отдает объект, на том же примере с заголовком будет вот такое:
const heading = { type: «h1», children: { title: «foo», children: «Заголовок» } }
Чтоб отрендерить что-то на нашу страницу нам необходимо воспользоваться функцией render(element, container), где element - то, что мы будем рендерить, а container — то, куда будем рендерить.
В туториале Didact отлично описано как настроить сборку и написать базовую версию функции render, но в этой статье мы копнем глубже (для лучшего понимания я рекомендую взглянуть на Didact). Для этого нам надо понять как работают workLoop и Fiber дерево.
В файле types.ts можно увидеть вот такое:
export interface ReactElement { type: ElementType props: Props } export interface Fiber { type?: ElementType props: Props parent?: Fiber | null child?: Fiber | null sibling?: Fiber | null dom?: HTMLElement | Text | null | undefined alternate?: Fiber | null effectTag?: EffectTag | undefined hooks?: Hook[] isStrict?: boolean | undefined }
Fiber и ReactElement легко перепутать. Разберем разницу.
Это легковесный JS объект, выглядит вот так:
{ type: «div», props: { id: «foo», children: [...], ... }, }
Это «описание» того, как элемент должен выглядеть в определенный момент времени.
У него нет: — Стейта — Хуков — Ссылки на свой DOM элемент — Ссылки на другие DOM элементы (parent, sibling, child)
С каждым рендером могут создаваться тысячи ReactElement-ов и сразу выбрасываться сборщиком мусора.
Это «живой» объект, он существует на протяжении жизни компонента и выглядит так:
{ type: «div», props: { id: «foo», ... }, parent: { ... }, child: { ... }, sibling: { ... }, dom: HTMLElement, alternate: { ... }, effectTag: «UPDATE», hooks: [...], isStrict: true, }
Он: — Хранит ссылку на состояние и хуки компонента (fiber.hooks) — Хранит ссылку на свой DOM элемент (fiber.dom) — Хранит ссылку на остальные файберы в дереве (parent, sibling, child) как связный список — Хранит ссылку на свой предыдущий рендер (fiber.alternate - для последующего возможного сравнения)
Как они взаимодействуют? Когда мы вызываем render или изменяем стейт, React берет дерево Fiber и сравнивает его с новыми ReactElement-ами для того, чтоб понять что изменилось.
Но как React обходит это дерево не блокируя браузер? Для этого нам нужно разобраться с render и workLoop.
Сама функция render довольно проста, но она закладывает две очень важные вещи в нашем коде:
устанавливает wipRoot — часть виртуального DOM (вы ведь знаете что это, не так ли?). Это «черновик» нашего дерева (Work-In-Progress Root), мы строим его в памяти и не трогаем реальное браузерное DOM дерево пока wipRoot не готов
устанавливает nextUnitOfWork на wipRoot — следующая задача на обработку, она будет обработана в следующей «итерации», когда браузер свободен (в функции workLoop)
Функция workLoop нужна для обработки наших nextUnitOfWork. Когда браузер не занят (не обрабатывает ввод пользователя в текстовое поле например, для этого мы будем использовать requestIdleCallback) мы обрабатываем наш nextUnitOfWork с помощью функции performUnitOfWork.
function workLoop(deadline) { let shouldYield = false; // Пока есть работа и браузер не занят — выполняем while (nextUnitOfWork && !shouldYield) { nextUnitOfWork = performUnitOfWork(nextUnitOfWork); shouldYield = deadline. timeRemaining() < 1; } // Если всю работу закончили — коммитим изменения в DOM if (!nextUnitOfWork && wipRoot) { commitRoot(); } // Планируем следующий вызов, когда браузер снова будет свободен requestIdleCallback(workLoop); } requestIdleCallback(workLoop);
React когда-то использовал requestIdleCallback, но сейчас они перешли на свой собственный планировщик на базе MessageChannel, так как requestIdleCallback имеет ограничения в разных браузерах и частоте вызовов. Но для нашего движка это отличный вариант, чтоб не изобретать велосипед.
В performUnitOfWork мы используем DFS (поиск в глубину): пытаемся спуститься к fiber.child, если его нет то пытаемся пойти к fiber.sibling, а если и его нет то мы поднимаемся к fiber.parent.
Например:
<div> <h1> <p /> <a /> </h1> <h2 /> </div>
От #root мы спускаемся к детям: <div> -> <h1> -> <p>.
У <p> нет детей, значит мы пытаемся пойти к fiber.sibling и получаем <a>, у него нет ни детей, ни братьев/сeстер, а значит мы поднимаемся к родителю: <h1>.
Мы уже разобрали детей <h1>, значит разберем его сиблинга: <h2>, у него нет ни детей, ни сиблингов, значит поднимаемся наверх: <div>.
У <div> тоже ничего нет, значит мы поднимаемся к #root (полностью прошли весь наш элемент).
Порядок обхода 1. #root ↓↑ (child) 2. <div> ↙ ↖ (sibling) 3. <h1> → 6. <h2> ↙ ↖ 4. <p> → 5. <a>
Когда цикл заканчивается и nextUnitOfWork становится null — это значит что wipRoot уже готов и мы можем вызывать функцию commitRoot для внесения изменений в браузерное DOM дерево.
Но просто обойти все узлы будет мало, нам нужно понять что именно изменилось с прошлого рендера. Для этого в performUnitOfWork мы получаем детей текущего файбера (вызываем компонент или читаем проп fiber. children) и передаем их в reconcileChildren.
Это «сердце» нашего движка. В нем мы сравниваем новых детей (что наш компонент вернул) и старый файбер (тот самый алгоритм reconciliation).
Мы идем по массиву новых элементов и старых файберов и смотрим на равенство типов (element. type === oldFiber. type):
Если тип совпал (был div и остался div), то мы создаем новый Fiber с тегом «UPDATE». DOM остается нетронутым, так как мы изменили только пропсы (не пересоздавая сам файбер)
Если тип не совпал (был h1, а стал h2, или у нас даже нет старого элемента), то мы создаем новый Fiber с нуля с тегом «PLACEMENT»
Если у нас был старый Fiber, но нет нового (например закрыли вкладку или аккордеон), тогда мы меняем тег на текущем файбере на «DELETION» и отправляем его в массив на удаление (state. deletions)
Вам может показаться что это очень похоже на алгоритм diffing, который используется в React — это он и есть! Только что мы разобрали логику того как это работает под копотом (код можно посмотреть в github).
Что насчет ключей (key)?
Если у нас есть список элементов (созданный с помощью .map() например), то они, в теории, могут менять свой порядок. Для избежания пересоздания DOM узлов мы используем проп key. В нашей функции есть Map, где ключ это наш key, а значение это Fiber.
Когда смотрим на новые элементы то мы просто используем нашу мапу. Если в мапе есть тот key, то мы можем просто переиспользовать этот Fiber (даже если индекс в массиве поменялся). Во время commit phase наш движок использует insertBefore для перемещения переиспользуемых DOM узлов на их новую позицию без пересоздавания их, экономя ресурсы.
function reconcileChildren(wipFiber, elements) { let oldFiber = wipFiber. alternate?. child; let prevSibling = null; elements. forEach((element, index) => { const sameType = oldFiber && element && element. type === oldFiber. type; let newFiber = null; if (sameType) { // Обновляем («UPDATE») newFiber = { type: oldFiber. type, props: element. props, dom: oldFiber. dom, parent: wipFiber, alternate: oldFiber, effectTag: «UPDATE», }; } else if (element && !sameType) { // Создаем новый («PLACEMENT») newFiber = { type: element. type, props: element. props, dom: null, parent: wipFiber, alternate: null, effectTag: «PLACEMENT», }; } // ... логика удаления и связывания (prevSibling.sibling = newFiber) }); }
Когда workLoop завершает обходить все дерево и nextUnitOfWork становится null, это значит что wipRoot готов и все effectTags расставлены. Сейчас время поработать с реальным DOM деревом.
Мы вызываем commitRoot и в ней мы рекурсивно проходимся через все Fiber и смотрим на их effectTags:
«PLACEMENT» — мы вставляем fiber. dom в DOM его родителя (с помощью appendChild или insertBefore для поддержания правильного порядка)
«UPDATE» — сравниваем старые и новые пропсы (className, onClick и подобные) и обновляем DOM
«DELETION» — удаляем DOM ноду из его родителя и запускаем cleanup функции для хуков при их наличии
Сначала мы удаляем все (в начале commitRoot мы запускаем state. deletions.forEach(commitWork)) и затем обрабатываем остальное дерево. После обновления DOM мы запускаем функцию runEffects.
Не у всех файберов есть ссылка на свой DOM элемент, у функциональных компонентов и фрагментов fiber. dom всегда будет null.
Поэтому, когда мы хотим вставить элемент в DOM мы не можем просто взять и вставить ссылку на его DOM (а мы не можем вставить null в DOM).
Для решения этой проблемы были введены две функции:
findDomNode — чтоб найти, куда вставлять элемент нам нужно подняться вверх по древу файберов (через fiber.parent) пока мы не наткнемся на обычный HTML тег (у которого есть реальный DOM)
getNextDomSibling — для правильной сортировки и вставки (через insertBefore). Если есть соседний файбер то нужно убедиться, что находим его реальный DOM узел, а если это какой-нибудь фрагмент, то нам нужно «зайти» в него и найти ближайший реальный узел внутри него.
Когда commitWork натыкается на Fiber с тегом "UPDATE", ему надо синхронизировать реальный DOM узел с новыми пропсами. Он вызывает функцию updateDom для того чтоб это сделать. Вот как она работает:
Сначала мы смотрим на старые пропсы (fiber.alternate. props): — Если это обычное свойство (id, className и подобные), которых нет на новых пропсах, то мы удаляем их — Если это event listener (любое свойство, которое начинается с on, например onClick), мы вызываем removeEventListener, чтоб отвязать старую функцию (для избежания утечек памяти)
Затем мы смотрим на новые пропсы — Если это обычное свойство, мы просто прикрепляем его к DOM узлу (например dom.id = "newId«). Но для style (объект) мы обрабатываем по-особому, чтоб они корректно отобразились — Если это event listener, то мы получаем название (например onClick становится click) и вызываем addEventListener для прикрепления новой функции
export function updateDom(dom, prevProps, nextProps) { const prev = prevProps || {} const next = nextProps || {} // Удаляем старые event listeners Object. keys(prev) .filter(isEvent) .filter((key) => !(key in next) || isNew(prev, next)(key)) .forEach((name) => { const eventType = name. toLowerCase().substring(2) dom. removeEventListener(eventType, prev[name] as EventListener) }) // Удаляем старые пропсы Object. keys(prev) .filter(isProperty) .filter(isGone(prev, next)) .forEach((name) => { if (dom instanceof HTMLElement) dom. removeAttribute(name) }) // Устанавливаем новые пропсы Object. keys(next) .filter(isProperty) .filter(isNew(prev, next)) .forEach((name) => { if (name === ’style’) { const prevStyles = prev. style || {} const nextStyles = next. style || {} Object. keys(prevStyles).forEach((key) => { if (!(key in nextStyles)) dom. style[key] = ’’ }) Object. keys(nextStyles).forEach((key) => { dom. style[key] = nextStyles[key] }) return } dom[name] = next[name] }) // Устанавливаем новые event listeners Object. keys(next) .filter(isEvent) .filter(isNew(prev, next)) .forEach((name) => { const eventType = name. toLowerCase().substring(2) dom. addEventListener(eventType, next[name]) }) }
Таким образом мы можем гарантировать что DOM всегда в точности совпадает с текущим состоянием компонента, без необходимости пересоздавать весь узел с нуля.
Хук, как мы все знаем, это чистая функция, которая может быть вызвана только в React компоненте, на верхнем уровне и до return. Но почему? Давайте разберем.
Все хуки, вызываемые в компоненте, находятся в fiber.hooks — массив хуков (объектов хуков).
Когда компонент обновляется, updateFunctionComponent делает две основные вещи:
Пишет текущий fiber в state. wipFiber
Обнуляет state.hookIdx = 0
Каждый раз, когда любой хук в компоненте вызывается, он смотрит в state.wipFiber для того, чтоб по hookIdx понять, кто он и что он должен сделать.
Почему хуки не используют Map?
Почему бы не использовать Map? Если бы хуки хранились как Ключ: Значение, правила хуков были бы не нужны, ведь результат их выполнения не зависел бы от порядка выполнения. Но откуда тогда брать ключи? Скорее всего пришлось бы передавать его первым аргументом в хуках:
const [name, setName] = useState(’nameKey’, ’name’); const [age, setAge] = useState(’ageKey’, 0);
Но это могло создать несколько основных проблем:
Есть шанс использовать какой-то ключ дважды
Лишний бойлерплейт
Проблемы с минификацией: нельзя будет использовать названия переменных как ключи, так как сборщики (Vite, Webpack и т. д.) после минификации превратят их в переменные a, b, c.
Команда React думала насчет автоматической генерации ключей (через Babel плагины), но отказалась от этого. Они хотели, чтоб React оставался библиотекой которую можно использовать без привязки к конкретному компилятору или прочему. (Подробнее об этих архитектурных решениях можно почитать в статье Дэна Абрамова https://overreacted.io/why-do-hooks-rely-on-call-order/ и комментариях к оригинальному RFC https://github.com/reactjs/rfcs/pull/68#issuecomment-439314884).
У хуков нет имен или айдишников, они полагаются только на hookIdx.
Представьте ситуацию:
if (condition) { const [state, setState] = useState(10) } useEffect(() => { ... }, [])
Давайте рассмотрим два рендера:
Рендер 1 (condition === true): — useState был вызван первым, так что ему выдается индекс 0 и он держит число (состояние) 10 — useEffect вызывается вторым и получает индекс 1
Рендер 2 (condition === false): — useState не вызывается — useEffect теперь первый и получает индекс 0
Видите проблему?
На втором рендере useEffect просит данные по индексу 0. Движок слепо отдает число 10 (состояние useState с предыдущего рендера), но useEffect ожидает получить функцию и массив зависимостей, но получает число
Порядок хуков должен оставаться одинаковым между всеми рендерами. Не ставьте их внутри условий или циклов.
Жизненный цикл любого хука укладывается в 4 шага:
Берет старый хук fiber. alternate.hooks[hookIdx]
Делает выбор: — Если у нас есть старый хук, то мы берем его состояние — Если старого хука нет (первый рендер), то мы инициализируем его с начальным состоянием
Создаем объект хука и пушим его в fiber.hooks, и затем увеличиваем счетчик state.hookIdx++
Возвращаем результат хука
function useState(initial) { // 1. Ищем старый хук по индексу const oldHook = wipFiber. alternate?.hooks?.[hookIdx]; // 2. Берем старое состояние или инициализируем новое const hook = { state: oldHook ? oldHook. state : initial, queue: [], }; // Выполняем действия из очереди oldHook?. queue. forEach(action => { hook. state = typeof action === ’function’ ? action(hook.state) : action; }); const setState = action => { hook. queue. push(action); // Запускаем ререндер с текущего корня nextUnitOfWork = { ...currentRoot, alternate: currentRoot }; }; // 3. Сохраняем хук и двигаем индекс wipFiber.hooks.push(hook); hookIdx++; // 4. Возвращаем результат return [hook.state, setState]; }
В базовом туториале уже рассказывается про функциональные компоненты, но что если мы хотим отрендерить список элементов без создания дополнительной обертки? В React для этого используются Fragment (<>{...}</>).
Главная их проблема — отсутствие своего DOM узла (fiber.dom === null).
Представьте такой код:
function App() { return ( <> <div> {...} </div> </> ) }
Вот такое дерево будет у него:
#root (имеет DOM-узел) ↓ <App> (функциональный компонент — нет DOM) ↓ <Fragment> (нет DOM) ↓ <div> (реальный HTML-тег — есть DOM)
Если мы хотим вставить наш <div> в страницу, алгоритму нужен родительский DOM-узел, чтобы сделать domParent.appendChild(). Но ни <Fragment>, ни <App> его не имеют!
Чтобы решить эту проблему, функция findDomNode рекурсивно поднимается вверх по родителям (fiber.parent), проходя элементы без DOM узла, пока не упрется в #root. И именно туда вставляется наш тег.
export function findDomNode(fiber) { // Если у файбера есть DOM и он не в процессе вставки / удаления if ( fiber. dom && fiber. effectTag !== EffectTags. delete && fiber. effectTag !== EffectTags. place ) return fiber. dom // Если нет — рекурсивно идем по его детям let child = fiber.child while (child) { const dom = findDomNode(child ?? null) if (dom) return dom child = child.sibling } return null }
А когда нам нужно правильно отсортировать элементы (через insertBefore), мы ищем ближайшего соседа с реальным DOM узлом.
export function getNextDomSibling(fiber) { let sibling = fiber.sibling while (sibling) { const domNode = findDomNode(sibling) if (domNode) return domNode sibling = sibling.sibling } return null }
Благодаря этим двум функциям мы можем бесшовно работать с фрагментами и возвращать сколько угодно элементов без обертки
C useState все понятно, он хранит состояние и триггерит рендер. С useEffect все сложнее, он выполняет callback только после того как браузер обновит экран, чтоб не блокировать отрисовку интерфейса.
У нас это разбито на две фазы:
Фаза рендера Когда компонент вызывает useEffect мы не вызываем callback сразу, мы лишь сравниваем массив зависимостей с предыдущим рендером, и если они изменились, то мы сохраняем callback в массив fiber.hooks и вешаем флаг hasChanged: true.
Фаза коммита Вспомним функцию commitRoot, в ней мы синхронно мутируем реальное DOM дерево. Чтоб эффекты не блокировали основной поток выполнения браузера, мы написали вот так:
function commitRoot() { // ... const finishedWork = state. wipRoot // Откладываем выполнение эффектов setTimeout(() => runEffects(finishedWork), 0) // ... }
Мы используем setTimeout для того, чтоб отдать браузеру время на отрисовку (так как это синхронные задачи они выполнятся первыми). Пользователь сразу увидит изменения на экране и только после этого Event Loop запустит нашу макротаску с функцией runEffects.
В этой функции мы рекурсивно проходимся по готовому дереву Файберов, находим все хуки у которых hasChanged: true и выполняем их.
export function runEffects(fiber) { const effects: EffectHook[] = [] collectEffects(fiber, effects) // Собираем изменившиеся эффекты effects. forEach((hook) => { // Выполняем очистку с предыдущего рендера if (hook. cleanup) hook. cleanup() // Запускаем новый эффект и сохраяем то что он вернул как функцию для очистки hook. cleanup = hook. effect() ?? null }) }
По-настоящему понять как работает useContext я смог только после того как смог сам написать его. Я всегда задавался вопросом: «а как он получает данные из провайдера который находится намного выше него?».
С помощью Fiber архитектуры реализация становится очень простой, так как у каждого файбера есть ссылка на своего родителя и мы без проблем можем подняться на сколь угодно уровней вверх.
Хук useContext берет текущий файбер и идет вверх пока не наткнется на свой Provider. Как только мы его нашли сразу берем его props. value, а если дошли до самого верха (#root) то отдаем значение по умолчанию.
export function useContext(context) { let currentFiber = state.wipFiber.parent // Идем вверх по родителям while (currentFiber) { if (currentFiber. type === context. Provider) { // Нашли провайдер — возвращаем результат return currentFiber.props.value } currentFiber = currentFiber.parent } // Провайдер не найден, отдаем значение по умолчанию return context._currentValue }
Что за значение по умолчанию? Давайте вспомним функцию createContext.
В ней мы задаем значение по умолчанию и получаем объект с контекстом и сам провайдер:
export function createContext(defaultValue) { const context = { _currentValue: defaultValue, Provider: null, } const Provider = (props) => { return { type: MyReact. Fragment, props: { children: props.children }, } } context. Provider = Provider return context }
Как это выглядит на практике? Чтобы проверить движок, я собрал тестовое приложение со стейтами, условным рендерингом и фрагментами.
import MyReact from ’./MyReact’ const React = MyReact // Подменяем реальный React чтоб сборщики не ругались export function App() { // Наш useState const [tab, setTab] = MyReact.useState(’counter’) return ( <div id="foo"> <h1> Hello from the <b>MyReact</b> functional component! </h1> <h2>Current Tab: {tab}</h2> {/* Фрагменты */} <> <button onClick={() => setTab(’counter’)}>Counter</button> <button onClick={() => setTab(’todos’)}>Todos</button> <button onClick={() => setTab(’keys’)}>Keys Demo</button> </> {tab === ’counter’ && <Counter />} {tab === ’todos’ && <Todos />} {tab === ’keys’ && <KeysDemo />} </div> ) }
Полноценную демку можно запустить из репозитория)
В этой статье мы разобрали лишь малую часть того, что делает React таким мощным и популярным инструментом, но эта часть — одна из самых важных и фундаментальных данной библиотеки. Написав это все мы смогли понять как работает Fiber архитектуры, принцип работы Reconciliation и почему у хуков такие строгие и порой странные правила.
Конечно, эта версия реакта не готова к проду. В ней нет пулинга синтетических событий, нет настоящего Concurrent Mode с умными приоритетами рендеринга (мы используем устаревший requestIdleCallback) и многих других вещей которые есть в библиотеке. Но цель была не переписать библиотеку, а глубоко понять ее фундамент.
Теперь, когда вы будете писать очередной useState или useEffect, вы будете знать как это работает под капотом (как хук находит свое состояние по индексу, как файбер попадает в очередь на обновление и так далее). Надеюсь, что вам это поможет не «просто писать на React», а создавать более осознанный, предсказуемый и производительный код)
Полный исходный код проекта с типизацией на TypeScript, StrictMode, поддержкой фрагментов и расширенным набором хуков (включая useMemo, useRef и memo) лежит в моем GitHub-репозитории. Буду рад вашим звездочкам и пул-реквестам!
Кстати, сейчас нахожусь в активном поиске работы, если вам в команду нужен уверенный Middle+ разработчик — буду ждать ваших сообщений)