Skip to main content

Віртуальний 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 робить рендеринг переривчастим: взаємодія з користувачем має пріоритет над фоновими оновленнями

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

jsx
// Лічильник: 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 ця робота переривчаста. Пріоритетне оновлення (наприклад, натискання клавіші) може перервати повільний рендер таблиці даних прямо посередині.

Процес оновлення

  1. Тригер - setState або useState спрацьовує, React ставить рендер у чергу
  2. Фаза рендеру - React викликає компоненти і будує новий Virtual DOM у пам'яті (прості JS-об'єкти з React.createElement)
  3. Фаза 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

jsx
// Неправильно: при вставці на початок усі індекси зсуваються, 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.

Мутація стану напряму

jsx
// Неправильно: 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

jsx
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 і побачиш точно, які компоненти перерендерились і скільки це зайняло.

Середній: список задач із ключами

jsx
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> перемонтовується при кожному перемиканні фільтра.

Просунутий: індексні ключі проти стабільних при перетягуванні

jsx
// Погано: індексні ключі призводять до повного перемонтування при переупорядкуванні 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 від досвідчених розробників.

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

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

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

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