Skip to main content

React fiber та процес оновлення віртуального DOM

React Fiber - це переосмислення рушія узгодження (reconciliation engine) у React 16, реалізоване як зв'язний список, що дозволяє рендерити віртуальний DOM інкрементально, з паузами і пріоритетами.

Теорія

TL;DR

  • Fiber - це конвеєр з кнопками паузи: старий React обходив усе дерево компонентів за один синхронний прохід (блокував браузер на 100мс+ при великих деревах); Fiber призупиняє другорядну роботу, щоб не гальмувати дії користувача.
  • Головна різниця: стековий рекурсивний обхід (до 16 версії) проти зв'язного списку fiber-вузлів. Зв'язний список можна зупинити посередині і продовжити з тієї самої точки.
  • Кожен fiber-вузол має три вказівники: child (перший дочірній), sibling (наступний сусід), return (батько).
  • createRoot у React 18 вмикає повний concurrent-режим; ReactDOM.render залишається на старому синхронному шляху.
  • Практичне правило: великі списки (500+ елементів) або анімації поряд із завантаженням даних - використовуй startTransition або useDeferredValue.

Швидкий приклад

jsx
import { useState, useTransition } from 'react'; function App() { const [input, setInput] = useState(''); const [isPending, startTransition] = useTransition(); const handleChange = (e) => { setInput(e.target.value); // високий пріоритет: виконується одразу startTransition(() => { filterLargeList(e.target.value); // низький пріоритет: Fiber ставить у чергу }); }; return <input value={input} onChange={handleChange} placeholder="Пиши без затримок" />; }

startTransition повідомляє Fiber, що це оновлення може зачекати. Поле вводу залишається миттєвим, бо Fiber передає важку роботу браузеру між 16мс-кадрами і відновлює її тільки коли головний потік вільний.

Стековий рекурсор vs Fiber

До React 16 рекурсор обходив дерево компонентів через стек викликів - один великий синхронний прохід. Дерево з 1000 вузлів по 0.1мс кожен блокувало браузер на 100мс. Жодних пауз, жодної поступки.

Fiber замінює стек зв'язним списком. React обходить його через nextUnitOfWork, обробляє 5-10мс роботи за один шматок, потім перевіряє чи браузеру потрібно щось намалювати або обробити подію. Якщо так - shouldYield() повертає true, і React передає керування. Потім підхоплює з nextUnitOfWork рівно звідти, де зупинився. Дерево в процесі роботи (work-in-progress) - це current.alternate, клон поточного дерева, тому інтерфейс залишається стабільним поки React будує наступну версію.

Ось і весь архітектурний зсув.

Фаза рендерингу: побудова work-in-progress дерева

Кожна зміна стану через useState або setState потрапляє у чергу оновлень прикріплену до fiber-вузла. React читає цю чергу і починає будувати WIP-дерево: клон поточного fiber-дерева з новим станом.

Під час цієї фази React викликає твої render-функції і запускає reconcileChildFibers, щоб порівняти нові та старі пропси і стан. Якщо типи елементів збігаються - патчить існуючий вузол. Якщо тип змінився (наприклад, div на span) - знищує гілку і будує нову з нуля.

Тут важливі ключі. Для списків key дозволяє React зіставляти старі і нові вузли за ідентичністю, а не позицією. Без ключів перестановка двох елементів виглядає як два окремі зміни.

Фаза рендерингу чиста і переривається. Жодних змін у реальному DOM тут не відбувається.

Фаза коміту: атомарна і невід'ємна

Коли WIP-дерево готове, React переходить до фази коміту. Цю частину не можна перервати.

Три підфази виконуються по черзі:

  • getSnapshotBeforeUpdate - зчитує DOM до будь-яких мутацій.
  • commitMutationEffects - додає, видаляє і змінює реальні DOM-вузли.
  • commitLayoutEffects - синхронно запускає componentDidMount, componentDidUpdate та useLayoutEffect.

Після того як браузер намалює кадр, асинхронно виконується useEffect.

Фаза коміту завжди синхронна. На практиці саме це найчастіше дивує розробників: навіть у concurrent-режимі, якщо в один коміт потрапляє 10 000 реальних DOM-вузлів, браузер блокується. Fiber розбиває побудову WIP-дерева, але не саме записування в DOM. Тому віртуалізація через react-window важлива навіть у React 18.

Як Fiber планує роботу

Fiber не використовує requestIdleCallback напряму - підтримка у браузерах непослідовна, а мінімальна затримка у 50мс надто велика. Натомість пакет scheduler полімає його через цикли MessageChannel postMessage, нарізаючи роботу на шматки по ~5мс. Функція shouldYield() перевіряє дедлайн; коли час вийшов - поточна одиниця роботи ставиться на паузу і керування повертається браузеру.

Пріоритети кодуються через 32-бітні lane-маски. SyncLane (біт 0) - для введення користувача. Вищі біти відповідають роботі з нижчим пріоритетом: переходи (transitions), відкладені значення, фонові задачі. Коли startTransition обгортає оновлення стану, React додає TransitionLane у поле lanes fiber-вузла через побітове АБО і розповсюджує його вгору деревом. Менший номер біту означає вищий пріоритет.

Коли використовувати concurrent-функції

Великі списки або таблиці (500+ елементів): обгортай важке оновлення стану у startTransition, щоб уникнути затримки введення.

Анімації поряд із дорогим перерендером: useDeferredValue тримає lane анімації окремо від lane повільних даних.

Прості застосунки з невеликим деревом компонентів: стандартний синхронний режим підходить. Concurrent-режим додає накладні витрати планувальника без видимої користі при дереві менше 100 вузлів.

Легасі-код на ReactDOM.render: такі проекти використовують старий синхронний шлях. Перехід на createRoot вмикає concurrent-функції, але деякі патерни з методами життєвого циклу і сторонніми бібліотеками поводяться інакше.

Порівняльна таблиця

ФункціяСтековий рекурсор (до 16)Fiber (16+)
Внутрішня структураСтек викликівЗв'язний список (child / sibling / return)
ПерериванняНі - один атомарний прохідТак - пауза і відновлення по вузлах
Рівні пріоритетуНемає32-бітна lane-маска (5+ рівнів)
ПлануванняХаки через setTimeoutПакет scheduler через MessageChannel
Concurrent-режимНедоступнийЧерез createRoot
Коли використовуватиМаленькі застосунки без анімаційПродакшен з великими деревами

Типові помилки

Загортати все у startTransition. Переходи відкладають перерендер, але оновлення стану самого поля вводу все одно має відбуватись синхронно. Якщо загорнути його у transition, поле починає лагати.

jsx
// Неправильно: поле вводу гальмує, бо setValue відкладено startTransition(() => setValue(e.target.value)); // Правильно: відкладаємо тільки важке вторинне оновлення setValue(e.target.value); // миттєво startTransition(() => setFiltered(computeFilter(e.target.value)));

Думати що concurrent-режим прибирає весь гальмівний ефект. Фаза коміту завжди синхронна. Якщо в один коміт потрапляє 20 000 DOM-вузлів, браузер блокується.

jsx
// Гальмує: 20 000 реальних вузлів за один коміт {items.map(item => <div key={item.id}>{item.text}</div>)} // Рішення: віртуалізація <FixedSizeList height={500} itemCount={20000} itemSize={35}> {Row} </FixedSizeList>

Використовувати flushSync скрізь для "миттєвих" оновлень. flushSync форсує синхронний рендер і повністю обходить планувальник Fiber. При частому використанні це викликає каскади гальм. Залишай його для випадків де треба прочитати layout одразу після зміни стану: фокус, прокрутка.

Ігнорувати подвійний виклик ефектів у StrictMode з async-переходами. У режимі розробки StrictMode запускає ефекти двічі. fetch всередині useEffect, загорнутий у startTransition, викликається двічі. Без AbortController або прапора cancelled перша відповідь може перезаписати другу.

Де це використовується

  • React 18 застосунки: createRoot + startTransition для миттєвого введення у великих списках задач.
  • Next.js 13+ app router: автоматичний concurrent-рендеринг; серверні компоненти стримують контент через Fiber-лейни.
  • Redux Toolkit: useSelector разом із useDeferredValue для адмін-панелей з великими оновленнями стору.
  • TanStack Query: useSuspenseQuery інтегрується з механізмом bailout у Fiber, скасовуючи застарілі запити при перериванні вищим пріоритетом.

Питання на співбесіді

Q: Опиши структуру зв'язного списку у Fiber. Які три вказівники є на кожному вузлі?
A: Кожен fiber має child (перший дочірній компонент), sibling (наступний на тому ж рівні) і return (батько). Обхід: спочатку workInProgress.child; якщо немає - workInProgress.sibling; якщо немає - підіймаємось через workInProgress.return доки не знайдемо sibling. WIP-дерево - це current.alternate.

Q: Як Fiber планує роботу без requestIdleCallback?
A: Пакет scheduler використовує MessageChannel postMessage щоб отримати колбек після відмальовування браузера. Всередині shouldYield() перевіряє дедлайн ~5мс і повертає true коли час вийшов. Це дає тонший контроль ніж requestIdleCallback з його мінімумом 50мс.

Q: Яка різниця між фазою рендерингу і фазою коміту?
A: Фаза рендерингу чиста і переривається - будує WIP-дерево, порівнює fiber-вузли, збирає список ефектів. DOM не чіпається. Фаза коміту синхронна і атомарна - застосовує DOM-мутації, потім запускає методи життєвого циклу та ефекти у фіксованому порядку.

Q: Поясни lane-маски. Покажи приклад із двома пріоритетами.
A: Лейни - це 32-бітне ціле число. Біт 0 (значення 1) - SyncLane для введення користувача. Біт 4 (значення 16) - TransitionLane для startTransition. React додає новий лейн через побітове АБО у поле lanes fiber-вузла. Менший номер біту означає вищий пріоритет, тому введення завжди перериває перехід.

Q (senior): Чому useDeferredValue не викликає нескінченний цикл перерендерів навіть у StrictMode?
A: useDeferredValue планує перерендер із відкладеним значенням на низькому пріоритеті. Під час цього перерендеру відкладене значення вже збігається з поточним - React робить bailout, бо стан не змінився. StrictMode викликає render-функцію двічі, але обидва виклики поділяють той самий current fiber і однаково виходять через bailout. Нових оновлень не планується.

Q (senior): Чому SSR не використовує concurrent-планування Fiber?
A: SSR стримує HTML синхронно до клієнта. Там немає браузерного планувальника, якому можна поступитися - немає кадрів відмальовування, немає подій користувача. React використовує простий синхронний шлях на сервері. ReactDOMServer.renderToString обробляє все дерево за один прохід.

Приклади

Базовий: як Fiber поступається браузеру

jsx
import { useState, useTransition } from 'react'; function SlowList({ count }) { return ( <> {Array.from({ length: count }, (_, i) => ( <div key={i} style={{ padding: '4px', borderBottom: '1px solid #eee' }}> Елемент {i + 1} </div> ))} </> ); } function App() { const [count, setCount] = useState(100); const [isPending, startTransition] = useTransition(); return ( <div> <button onClick={() => startTransition(() => setCount(c => c + 500))} > Додати 500 елементів {isPending && '(рендер...)'} </button> {/* Fiber будує WIP-дерево у фоні, поступаючись кліками на кнопку */} <SlowList count={count} /> </div> ); }

isPending стає true поки Fiber будує WIP-дерево для нового count. Кнопка залишається клікабельною весь цей час. Без startTransition клік на кнопку блокував би UI до завершення коміту.

Середній: useDeferredValue для фільтрації великого списку

jsx
import { useState, useDeferredValue, memo, useMemo } from 'react'; const ItemList = memo(({ filter, items }) => { const filtered = items.filter(item => item.label.toLowerCase().includes(filter.toLowerCase()) ); return ( <ul> {filtered.map(item => <li key={item.id}>{item.label}</li>)} </ul> ); }); function App() { const [query, setQuery] = useState(''); const deferredQuery = useDeferredValue(query); // копія з низьким пріоритетом const items = useMemo(() => generateItems(1000), []); return ( <> <input value={query} onChange={e => setQuery(e.target.value)} placeholder="Фільтр..." /> {/* Перерендериться з deferredQuery - завжди на крок позаду query */} <ItemList filter={deferredQuery} items={items} /> </> ); }

deferredQuery відстає від query на один або більше рендерів. Fiber планує перерендер ItemList на низькому пріоритеті. Поле вводу оновлюється на кожне натискання клавіші без очікування фільтрації. memo не дає ItemList перерендеритись коли змінився тільки query, але ще не deferredQuery.

Просунутий: concurrent fetch у StrictMode з очищенням

jsx
import { useState, useEffect, useTransition } from 'react'; function DataFetcher() { const [data, setData] = useState(null); const [isPending, startTransition] = useTransition(); useEffect(() => { let cancelled = false; // StrictMode викликає цей ефект двічі у режимі розробки: // монтування -> очищення (cancelled = true) -> монтування знову // Без прапора перша відповідь може перезаписати другу startTransition(() => { fetch('/api/heavy-data') .then(res => res.json()) .then(json => { if (!cancelled) setData(json); // ігноруємо якщо ефект очищено }); }); return () => { cancelled = true; }; }, []); if (isPending) return <p>Завантаження...</p>; return <pre>{JSON.stringify(data, null, 2)}</pre>; }

shouldYield у Fiber також може перервати низькопріоритетний перехід якщо між стартом fetch і його завершенням прийде більш пріоритетне оновлення. Прапор cancelled обробляє обидва випадки: подвійний виклик від StrictMode і переривання від Fiber. Без нього отримаєш або застарілий стан, або попередження React про оновлення розмонтованого компонента.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?