Віртуальний DOM у React
Віртуальний DOM - це JavaScript-представлення реального DOM у пам'яті, яке React використовує для обчислення мінімальних змін інтерфейсу через diffing і точкові мутації реального DOM.
Теорія
TL;DR
- Аналогія: спочатку ескіз на папері (Virtual DOM), потім фарба на стіну (реальний DOM) - наносиш рівно ті мазки, що потрібні
- Головна різниця: прямі виклики DOM (
document.getElementById) запускають синхронний reflow браузера; Virtual DOM спочатку збирає зміни і виконує diff - Правило вибору: часті оновлення стану → Virtual DOM; статичні сторінки без інтерактивності → звичайний HTML швидший
- Ключі (
key) у списках дозволяють React переставляти DOM-вузли замість перемонтування всіх підряд - Fiber у React 18 робить рендеринг переривчастим: взаємодія з користувачем має пріоритет над фоновими оновленнями
Швидкий приклад
// Лічильник: React оновлює лише текстовий вузол, не весь div
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<h1>Count: {count}</h1> {/* Змінюється тільки цей текстовий вузол */}
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
// При натисканні: React будує новий Virtual DOM,
// порівнює зі старим, знаходить різницю лише в тексті <h1>,
// і оновлює тільки цей один вузол у реальному DOM.При кожному кліку React будує новий JavaScript-об'єкт, що описує UI, порівнює його з попереднім і передає браузеру тільки дельту. <button> і <div> у реальному DOM навіть не торкаються.
Як працює алгоритм diffing
Алгоритм diff у React виконується за O(n) часу з двома допущеннями. Перше: якщо типи елементів різні (наприклад, <div> став <span>), React знищує старе піддерево і будує нове з нуля. Жодних спроб узгодити. Друге: в списках пропс key дозволяє відстежити, які елементи перемістились, які додались, а які зникли. Без key React порівнює за індексом і перемонтовує все при переупорядкуванні.
Reconciler (пакет react-reconciler) будує fiber-дерево: зв'язний список, де кожен вузол - це компонент. У React 18 ця робота переривчаста. Пріоритетне оновлення (наприклад, натискання клавіші) може перервати повільний рендер таблиці даних прямо посередині.
Процес оновлення
- Тригер -
setStateабоuseStateспрацьовує, React ставить рендер у чергу - Фаза рендеру - React викликає компоненти і будує новий Virtual DOM у пам'яті (прості JS-об'єкти з
React.createElement) - Фаза commit - React порівнює нове дерево з попереднім fiber-деревом і застосовує мінімальний набір мутацій через
commitRoot
Батчинг тут важливий. React групує кілька оновлень стану в один commit замість того, щоб flush після кожного виклику. У React 18 батчинг відбувається автоматично навіть усередині setTimeout або нативних обробників подій.
Коли використовувати
- Часті оновлення стану (введення даних користувача, дані в реальному часі) - Virtual DOM автоматично збирає їх у пакети
- Великі динамічні списки - додай
keyпропси, а при понад 500 елементів підключиreact-window - Налаштування продуктивності - поєднуй з
React.memoабоuseMemoповерх Virtual DOM diffing - Статичні HTML-сторінки без інтерактивності - React тут надлишковий, прямий DOM або звичайний HTML швидші
Типові помилки
Індекс масиву як key
// Неправильно: при вставці на початок усі індекси зсуваються, React перемонтовує все
items.map((item, i) => <li key={i}>{item.text}</li>)
// Правильно: стабільний унікальний ідентифікатор
items.map(item => <li key={item.id}>{item.text}</li>)Індексні ключі нормально працюють для статичних списків, які ніколи не переупорядковуються. Але щойно з'являється сортування, фільтрація або вставка на початок, всі ключі зсуваються і React перемонтовує кожен вузол, втрачаючи локальний стан, фокус і поточні анімації.
Припущення, що Virtual DOM завжди швидший за прямий DOM
Для простого перемикання або заміни тексту document.getElementById буде швидшим. Накладні витрати React - це сам diff. За бенчмарками SyntheticJS, React приблизно у 4 рази повільніший за прямий DOM для тривіального оновлення одного елемента і у 10 разів швидший для складних списків, де батчинг запобігає сотням reflow.
Мутація стану напряму
// Неправильно: diffing Virtual DOM спирається на порівняння посилань
state.items.push(newItem);
setState(state); // React бачить те саме посилання, може пропустити рендер
// Правильно: нове посилання
setState({ ...state, items: [...state.items, newItem] });Глибокі дерева без віртуалізації
10 000 рядків таблиці одночасно навантажать diff незалежно від Virtual DOM. Для таких випадків є react-window або react-virtual - вони рендерять лише видимі рядки.
Рендери від батька до дочірніх компонентів
Якщо батьківський компонент рендериться, всі його дочірні компоненти рендеряться теж, якщо не обгорнути їх у React.memo. Virtual DOM робить це дешевим, але не безкоштовним. React DevTools Profiler покаже, де реальні витрати.
Де використовується в реальних проєктах
- React core - кожен компонент рендериться у Virtual DOM-об'єкти через
jsx-runtimeперед будь-яким відображенням у браузері - Next.js - сервер рендерить Virtual DOM, потім гідратує його на клієнті, зіставляючи з реальним DOM
- Preact - 3kb-клон Virtual DOM, використовується в дашборді Hulu і на 100 тис.+ сайтах, де важливий розмір бандлу
- Redux DevTools - серіалізує снепшоти Virtual DOM для налагодження з відмоткою часу (time-travel debugging)
- React Native - той самий механізм diffing Virtual DOM, але commit застосовується до нативних компонентів, а не до браузерного DOM
Питання зі співбесід
Q: Як React вирішує, замінювати піддерево чи оновлювати?
A: Порівнює тип елемента. Якщо <Input> став <Select>, React демонтує Input і монтує Select з нуля. Якщо тип не змінився, патчить тільки змінені пропси.
Q: Що відбувається з дублікатами або відсутніми ключами?
A: Відсутні ключі замінюються індексом з попередженням у консолі. Дублікати призводять до некоректного diffing - React вибирає один елемент довільно і може показати неправильний UI без жодної помилки.
Q: Як concurrent mode у React 18 змінює цикл Virtual DOM?
A: startTransition позначає оновлення як низькопріоритетні. Fiber може перервати роботу над новим деревом посередині, дати пріоритетному оновленню (наприклад, натисканню клавіші) завершитись першим, а потім продовжити або відкинути перерване. Структура Virtual DOM та сама, але планувальник вирішує, коли відбудеться commit.
Q: Навіщо useMemo, якщо вже є Virtual DOM?
A: Коли компонент рендериться часто і в ньому є дочірні компоненти з важкими обчисленнями або великим піддеревом. useMemo повертає збережений результат, diff отримує те саме посилання і пропускає це піддерево повністю.
Q: Чому не писати ручний diff замість React?
A: Можна. Але React покриває 99% типових випадків без додаткового коду. Ручний diffing швидко перетворюється на технічний борг, особливо коли до проєкту додаються списки, анімації і конкурентні оновлення.
Приклади
Базовий: лічильник і мінімальні оновлення DOM
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<h1>Count: {count}</h1>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}Клікни три рази. React будує новий Virtual DOM при кожному кліку, порівнює з попереднім і патчить тільки текстовий вузол усередині <h1>. <div> і <button> у реальному DOM не змінюються. Відкрий React DevTools Profiler і побачиш точно, які компоненти перерендерились і скільки це зайняло.
Середній: список задач із ключами
import { useState } from 'react';
const initial = [
{ id: 1, text: 'Вивчити React', done: false },
{ id: 2, text: 'Зібрати додаток', done: true },
];
function TodoList() {
const [todos] = useState(initial);
const [filter, setFilter] = useState('all');
const visible = todos.filter(t =>
filter === 'all' ? true : t.done === (filter === 'done')
);
return (
<>
<button onClick={() => setFilter('all')}>Всі</button>
<button onClick={() => setFilter('done')}>Виконані</button>
<ul>
{visible.map(todo => (
<li key={todo.id}>{todo.text}</li> // стабільний key = повторне використання DOM-вузла
))}
</ul>
</>
);
}При перемиканні фільтра React порівнює список, повторно використовує незмінені вузли <li> і додає або видаляє тільки те, що дійсно змінилось. Заміни key={todo.id} на key={index} і перевір у Profiler - побачиш, що кожен <li> перемонтовується при кожному перемиканні фільтра.
Просунутий: індексні ключі проти стабільних при перетягуванні
// Погано: індексні ключі призводять до повного перемонтування при переупорядкуванні
function BadList({ items }) {
return (
<ul>
{items.map((item, index) => (
<Item key={index} data={item} /> // перетягнути елемент → 100 розмонтувань + 100 монтувань
))}
</ul>
);
}
// Добре: стабільні ключі-ID дозволяють React дешево міняти позиції
function GoodList({ items }) {
return (
<ul>
{items.map(item => (
<Item key={item.id} data={item} /> // перетягнути → React міняє fiber-вузли місцями, стан зберігається
))}
</ul>
);
}При 100 елементах з можливістю перетягування BadList запускає 100 розмонтувань і 100 монтувань при кожному переупорядкуванні, бо зсуваються всі індекси. GoodList лише міняє fiber-вузли місцями. У React Native FlatList ця різниця виявляється як видиме мерехтіння і втрата позиції прокрутки на Android. Я сам бачив цю помилку в PR від досвідчених розробників.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.