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.
Швидкий приклад
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, поле починає лагати.
// Неправильно: поле вводу гальмує, бо setValue відкладено
startTransition(() => setValue(e.target.value));
// Правильно: відкладаємо тільки важке вторинне оновлення
setValue(e.target.value); // миттєво
startTransition(() => setFiltered(computeFilter(e.target.value)));Думати що concurrent-режим прибирає весь гальмівний ефект. Фаза коміту завжди синхронна. Якщо в один коміт потрапляє 20 000 DOM-вузлів, браузер блокується.
// Гальмує: 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 поступається браузеру
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 для фільтрації великого списку
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 з очищенням
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 про оновлення розмонтованого компонента.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.