Одна з найскладніших задач у фронтенд-розробці — це ефективне оновлення інтерфейсу користувача у відповідь на зміну даних. Браузерний DOM неймовірно повільний при внесенні змін, оскільки будь-яка модифікація може викликати ланцюжок дорогих операцій: перерахунок стилів (Recalculate Style), оновлення макета (Reflow/Layout) та перемальовування пікселів (Repaint). У цій лекції ми заглибимося у внутрішню архітектуру React, яка дозволяє йому “обманювати” повільність DOM за допомогою концепцій Virtual DOM та Fiber.
Існує кілька стратегій оновлення UI:
Virtual DOM — це концепція програмування, в якій ідеальне, або “віртуальне”, представлення інтерфейсу користувача зберігається в пам’яті (In-Memory) і синхронізується з “реальним” DOM.
По суті, елемент React — це просто звичайний (plain) JavaScript об’єкт. Його створення коштує дуже дешево.
Приклад: Коли ми пишемо такий JSX код:
const element = <div className="box">Hello</div>;
Babel конвертує його таким чином, щоб React створив об’єкт, який виглядає приблизно так:
// Спрощене подання React об'єкта
const virtualElement = {
type: "div",
props: {
className: "box",
children: "Hello",
},
};
React тримає в пам’яті дерево таких об’єктів. Коли стан (state) змінюється, React створює нове, повністю незалежне дерево Virtual DOM.
Як перетворити одне дерево (старе) на інше дерево (нове) з мінімальною кількістю операцій? У класичній інформатиці (алгоритм Левенштейна та алгоритми обчислення дерев) ця задача математично вирішується за час $O(n^3)$, де n — кількість елементів дерева.
Для типового додатка з 1000 компонентів це $1000^3$ (мільярд) порівнянь. Це неприпустимо довго.
Тому інженери React створили евристичний алгоритм порівняння (Reconciliation, або алгоритм “Diffing”), який зводить складність до $O(n)$. Алгоритм базується на двох фундаментальних припущеннях:
type, наприклад, <div> та <span> або <nav> та <header>) згенерують різні дерева.
<button> на <a>), React не намагається їх порівнювати. Він повністю знищує старе DOM-дерево (включно із вкладеними дітьми) та монтує нове дерево з чистого аркуша.key.
Процес (якщо типи однакові):
// Старе:
<div className="before" title="stuff" />
// Нове:
<div className="after" title="stuff" />
React порівнює props, бачить, що title не змінився, а className змінився, і відправляє в реальний DOM точкову команду: element.className = 'after'.
keyПри мапінгу списку є велика проблема продуктивності під час вставки нових елементів.
Без key:
// Старе
<ul>
<li>Яблуко</li>
<li>Банан</li>
</ul>
// Нове (додали ківі на ПОЧАТОК списку)
<ul>
<li>Ківі</li> <!-- React думає, що Яблуко перетворилось на Ківі -->
<li>Яблуко</li> <!-- React думає, що Банан перетворився на Яблуко -->
<li>Банан</li> <!-- React додає новий тег -->
</ul>
Результат: $100\%$ елементів мутували, інтерфейс страждає від перемальовок.
З key:
// Старе
<ul>
<li key="apple">Яблуко</li>
<li key="banana">Банан</li>
</ul>
// Нове
<ul>
<li key="kiwi">Ківі</li>
<li key="apple">Яблуко</li>
<li key="banana">Банан</li>
</ul>
Тепер React бачить, що ключі “apple” і “banana” існують в обох деревах. Він лише вставляє новий елемент “kiwi” поверх старих. Жодних зайвих модифікацій існуючих вузлів!
Чому не можна використовувати index масиву як key?
Якщо елементи можуть міняти порядок, сортуватися або видалятися із середини, index прив’язується не до даних, а до позиції. Це ламає логіку Reconciliation, оскільки React починає оновлювати неправильні компонети (баги зі станом інпутів у відсортованих списках).
До версії React 16 алгоритм узгодження базувався на архітектурі, яка називалася Stack. Вона працювала синхронно (рекурсивно крокувала деревом). Це означало, що після початку потоку рендерингу (наприклад, через складний setState), браузер блокувався (main thread freeze) до тих пір, поки React не завершить обробку всього дерева.
Привіт, React Fiber (React 16+) Fiber — це повний перепис внутрішнього ядра (двигуна) React.
Об’єкт Fiber (одиниця роботи) містить в собі інформацію про компонент, його стан, посилання на батьків, дітей та “братів” (Siblings).
З новими версіями React (починаючи з версії 18) механізми Virtual DOM стали ще розумнішими завдяки Concurrent Mode:
setState) з кількох подій (або setTimeout, fetch) у єдиний цикл повторного рендеру. Раніше це працювало лише всередині React-подій (onClick).useTransition, useDeferredValue): Дозволяють маркувати певні оновлення як нетермінові (другорядні), даючи пріоритет плавному UI (наприклад, затримка при важкому пошуку, але інпут працює плавно).Розуміючи як працює Reconciliation, розробник повинен писати ефективніший код:
React.memo (High-Order Component) порівнює пропси (props) поверхнево. Якщо пропси не змінилися, дочірній компонент не рендериться навіть тоді, коли рендериться батьківський.Уникнення інлайн-функцій у пропсах:
// Погано: створює ВАЖКУ(нову) функцію при кожному рендері, ламаючи React.memo дочірніх компонентів.
<button onClick={() => fetchData()}>Sync</button>
// Добре: використання useCallback хука або винос функції назовні.
useMemo для важких обчислень або об’єктів пропсів:
// Зміна стилів або масиву генерує новий референс (посилання в пам'яті).
// Погано: <List items={[]} />
// Погано: <Component style= />
key — це міст зв’язку між Virtual DOM елементами, який критичний для продуктивності списків. key повинен бути стабільним та унікальним для даних.key може призвести до збоїв у стані інтерфейсу (наприклад, стан полів вводу зсувається)?