Реконсиляція в React
Реконсиляція (reconciliation) - це процес, за яким React порівнює нове дерево virtual DOM зі старим і знаходить мінімальний набір змін для реального DOM.
Теорія
TL;DR
- Аналогія: відеоредактор, який перерендерює тільки пікселі що змінились, а не весь фільм.
- React тримає складність O(n) замість O(n³) двома припущеннями: різні типи елементів дають різні піддерева, а
keyвказує на стабільні елементи списку. - Різний тип (
<div>на<span>) - повне розмонтування і перебудова. Однаковий тип - оновлення атрибутів, рекурсія в дітей. - Список без стабільних
keyпорівнюється за позицією (індексом), що ламається при видаленні або перестановці. - Спочатку профайл через React DevTools Profiler, потім вже думай про
React.memoабо зміну ключів.
Швидкий приклад
// Без стабільних ключів: React перемонтовує ВСІ елементи при будь-якій зміні списку
function BadList({ items }) {
return (
<ul>
{items.map(item => (
<li key={Math.random()}>{item.name}</li> // новий ключ кожен рендер = повне перемонтування
))}
</ul>
);
}
// Зі стабільними ключами: оновлюється тільки змінений елемент
function GoodList({ items }) {
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li> // React відстежує за id, перевикористовує DOM-вузли
))}
</ul>
);
}Видали один елемент з BadList - кожен <li> перемонтується, фокус і локальний стан зникають. Видали з GoodList - зникає тільки той вузол. Ось і вся різниця.
Як працює евристика диффінгу
Точне порівняння двох довільних дерев - задача O(n³). React зводить це до O(n) двома ставками на твій код.
Перша ставка: елементи різних типів дають повністю різні піддерева. Тому React зносить старе і будує нове з нуля. <div> перетворився на <span> - кожен дочірній елемент розмонтовується, componentWillUnmount спрацьовує, весь стан компонента стирається.
Друга ставка: розробник сам вказує на ідентичність елементів списку через key. Без нього React порівнює за позицією. Додав елемент на початок - React вважає, що позиція 0 змінилась, позиція 1 змінилась, і перерендерює все.
Елементи одного типу йдуть іншим шляхом. React залишає DOM-вузол, оновлює тільки атрибути що змінились, і рекурсивно обробляє дітей. Для компонентів екземпляр залишається живим, стан зберігається, оновлюються тільки пропси.
// Старий
<div className="card" title="old title" />
// Новий
<div className="card" title="new title" />
// React чіпає тільки атрибут title. className не змінюється.Коли потрібні ключі
Перебудова або фільтрація списку: кожен елемент у .map() потребує стабільного key зі своїх даних, не з поточної позиції.
Перемикання між двома різними типами компонентів: прийми повне перемонтування. Минути його неможливо, і зазвичай це нормально.
Перемикання між двома компонентами одного типу: React перевикористає екземпляр і збереже стан. Якщо тобі потрібен свіжий mount - дай кожному різний key.
Динамічний список з видаленням або додаванням: key={item.id} - єдиний безпечний варіант. Індекс зсувається при видаленні з середини.
Як це працює всередині
Коли спрацьовує setState, пакет react-reconciler стартує від зміненого fiber-вузла і запускає reconcileChildren. Fiber-архітектура, що зʼявилась у React 16 і розвинулась у React 18, зробила реконсиляцію переривчастою. Замість одного синхронного проходу React розбиває роботу на частини через Scheduler з пріоритетними lanes.
Concurrent Mode в React 18 додає useTransition, який позначає деякі оновлення як низькопріоритетні. Scheduler відступає перед терміновим відмальовуванням і виконує відкладений диф пізніше. Сам алгоритм не змінюється. Змінюється тільки момент запуску.
Після диффінгу React збирає всі мутації в список ефектів і скидає їх у реальний DOM одним викликом commitRoot. Саме цей батч тримає кількість reflow мінімальною.
Типові помилки
1. Індекс масиву як ключ
// Видалення з середини зсуває всі індекси.
// React вважає, що елементи змінились, хоча вони просто перемістились.
{todos.map((todo, index) => (
<TodoItem key={index} todo={todo} />
))}
// Правильно: id зі своїх даних
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}2. Випадкове значення як ключ
// Math.random() при кожному рендері = кожен елемент перемонтується щоразу
{items.map(item => <li key={Math.random()}>{item.name}</li>)}
// Правильно: стабільний id, згенерований один раз при створенні елемента
{items.map(item => <li key={item.id}>{item.name}</li>)}3. Зайвий key на одному умовному дочірньому елементі
// Зміна key на умовному елементі - розмонтування і монтування при кожному перемиканні
function Switcher({ active }) {
return (
<div>
{active ? <ExpensiveChart key="chart" /> : <Summary key="sum" />}
</div>
);
}
// ExpensiveChart втрачає WebGL-контекст при кожному перемиканні.
// Правильно: без key для одиночних умовних елементів
function Switcher({ active }) {
return (
<div>
{active ? <ExpensiveChart /> : <Summary />}
</div>
);
}4. Зміна типу елемента за умовою
// Зміна isLoading - повне розмонтування, бо div !== span
function Status({ isLoading }) {
if (isLoading) return <div>Завантаження...</div>;
return <span>Готово</span>;
}
// Правильно: залишай однаковий тип елемента
function Status({ isLoading }) {
return <div>{isLoading ? 'Завантаження...' : 'Готово'}</div>;
}5. Припущення, що реконсиляція завжди зберігає стан
Щойно key змінюється або тип елемента змінюється, React розмонтовує все і втрачає стан. Бачив, як команди міняли key щоб "скинути" форму, не помічаючи, що це вбиває анімації і WebSocket-підписки в польоті. Якщо потрібно скинути тільки поля форми - роби це явно всередині компонента.
Де зустрічається в реальних проектах
- React core:
react-reconcilerу репозиторії facebook/react обробляє всі рендери. Його також можна використовувати окремо для не-DOM середовищ - canvas, native. - Next.js app router: клієнтська навігація дифить дерево компонентів через реконсиляцію. React Server Components надсилають серіалізоване дерево, яке клієнтський reconciler зливає з вже змонтованим.
- Remix: після сабміту форми відбувається стандартний прохід реконсиляції.
- React DevTools Profiler: показує які компоненти перерендерились, скільки часу зайняли і чому. Запускай перед тим, як думати про
React.memo.
Follow-up питання
Q: Чому O(n) замість O(n³)?
A: Точний алгоритм диффінгу двох дерев - O(n³). React зводить це до O(n) двома евристиками: порівнює тільки елементи одного рівня і використовує keys для ідентифікації елементів списку за даними, а не за позицією.
Q: Що відбувається, якщо не додати keys у маппованому списку?
A: React порівнює за позицією. Видаляєш перший елемент - всі наступні "змінились" за позицією, і всі перерендеряться або перемонтуються залежно від того, чи змінився тип чи тільки контент.
Q: Чим stack reconciler відрізняється від Fiber?
A: Старий stack reconciler обробляв все дерево синхронно і не міг перерватись. Fiber, що зʼявився в React 16, - це work loop на зв'язних списках: його можна поставити на паузу, продовжити або скасувати. Саме це дає Concurrent Mode в React 18.
Q: Як useTransition впливає на реконсиляцію в React 18?
A: Позначає оновлення як низькопріоритетне. Scheduler через модель lanes відкладає цей диф до того, як браузер відмалює термінові кадри. Сам алгоритм реконсиляції однаковий - змінюється тільки час запуску.
Q: Як змусити React перемонтувати компонент без зміни його типу або позиції?
A: Змінити key. React сприймає інший key як сигнал про новий елемент, розмонтовує старий екземпляр і монтує свіжий. Корисно для скидання компонента, але стеж за побічними ефектами - підписки і анімації теж зникнуть.
Приклади
Базовий: key і збереження стану в списку задач
function TodoList({ todos }) {
return (
<ul>
{todos.map(todo => (
// Стабільний id: React зіставляє цей todo між рендерами.
// Фільтрація, перестановка, додавання - локальний стан у TodoItem виживає.
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
);
}
function TodoItem({ todo }) {
const [editing, setEditing] = useState(false);
return (
<li>
{editing ? <input defaultValue={todo.text} /> : todo.text}
<button onClick={() => setEditing(!editing)}>Редагувати</button>
</li>
);
}
// Без key={todo.id}: відкрий редагування одного todo, додай інший.
// Всі TodoItem перемонтуються і відкритий input втрачає курсор.
// З key={todo.id}: рендериться тільки новий todo, решта зберігає стан.Стан editing живе всередині кожного TodoItem. Зі стабільним key React знає, який екземпляр відповідає якому todo, і зберігає його. Без стабільного key - перемонтовує все.
Середній: фільтрований список і фокус на input
function UserList({ users, filter }) {
const visible = users.filter(u => u.name.includes(filter));
return (
<ul>
{visible.map(user => (
<li key={user.id}>
{user.name}
<input placeholder="Нотатка про користувача" />
</li>
))}
</ul>
);
}
// З key={user.id}: написав нотатку для Аліси, змінив фільтр,
// повернув Алісу - її нотатка на місці.
//
// З key={index}: зміна фільтра зсуває індекси.
// Аліса зникає з позиції 0, Боб займає позицію 0.
// React перевикористовує DOM-вузол від Аліси для Боба - нотатка Аліси тепер у Боба.Саме через цю помилку інпути в списках поводяться непередбачувано. Один пропс це виправляє. Причина - React порівнює за позицією, коли нема ключа для порівняння за ідентичністю.
Сеньйорський: навмисне перемонтування через key для скидання форми
// Навмисна зміна key щоб отримати чистий компонент при зміні userId
function ProfilePage({ userId }) {
return <ProfileForm key={userId} userId={userId} />;
}
function ProfileForm({ userId }) {
const [name, setName] = useState('');
useEffect(() => {
fetchUser(userId).then(user => setName(user.name));
}, [userId]);
return (
<form>
<input value={name} onChange={e => setName(e.target.value)} />
<button type="submit">Зберегти</button>
</form>
);
}
// При зміні userId змінюється key.
// React розмонтовує старий ProfileForm і монтує свіжий з порожнім станом.
// Не потрібно вручну скидати кожне поле в useEffect.
//
// Компроміс: також розмонтовуються анімації і підписки всередині.
// Використовуй цей патерн, коли чистий аркуш - саме те що потрібно.Цей патерн часто зустрічається в адмін-панелях, де клік між записами має давати повністю свіжу форму. Простіше ніж скидати десяток полів вручну, але обовʼязково задокументуй навіщо тут key - інакше наступний розробник його прибере.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.