Як працює virtual DOM у Vue.js?
Virtual DOM у Vue.js - це дерево JS-об'єктів, яке відображає структуру реального DOM. Vue використовує його, щоб обрахувати мінімальний набір змін у DOM після кожної зміни даних.
Теорія
TL;DR
- Уявляй ескіз на папері перед тим як фарбувати стіни: перефарбовуєш тільки те, що змінилось.
- Vue компілює
<template>у функціюrender(), яка повертає VNode-и: прості JS-об'єкти з полямиtype,props,children,key. - При реактивній зміні Vue запускає
patch(oldVNode, newVNode)і викликає браузерні API тільки там, де є різниця. - Virtual DOM займає більше пам'яті (~2x від розміру DOM) і дає повільніший початковий рендер, але робить часті оновлення дешевшими.
- Правило вибору: для інтерактивних UI з частими змінами стану - virtual DOM. Для статичних сторінок - не потрібен.
Швидкий приклад
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<p>Лічильник: {{ count }}</p> <!-- тут створюється VNode -->
<button @click="count++">+1</button>
</template>Натисни кнопку: Vue створює нове дерево VNode-ів, порівнює його з попереднім і патчить тільки текстовий вузол всередині <p>. VNode кнопки ідентичний попередньому і пропускається повністю.
Virtual DOM vs прямий DOM
Прямий запис у DOM (element.innerHTML = ...) запускає повний reflow та repaint при кожному присвоєнні. Vue групує всі реактивні зміни в один прохід diff-у, обчислює точні мутації (наприклад, заміна textContent) і застосовує їх разом через patch(). Для сторінки з десятьма оновленнями на секунду ця різниця добре відчутна.
Як Vue будує віртуальне дерево
Компілятор Vue перетворює <template> на функцію render(). Вона повертає дерево VNode-ів - звичайні JS-об'єкти з полями type, props, children і key. Коли реактивний стан змінюється (спрацьовує сеттер ref()), планувальник Vue ставить ефект рендеру в чергу. На наступному тіку render() запускається знову, повертає нове дерево, і обидва дерева передаються у patch().
V8 оптимізує обхід цих об'єктів через приховані класи (hidden classes), тому сам diff виконується швидко. Браузерні createElement() і setAttribute() викликаються тільки там, де дерева відрізняються.
Алгоритм патчингу
patch(oldVNode, newVNode) рекурсивно обходить обидва дерева з кількома евристиками. Однаковий тег і однаковий key? Оновлюємо props і заходимо у дочірні вузли. Різний тег? Відмонтовуємо старий, монтуємо новий. Для списків сусідніх елементів Vue використовує алгоритм найдовшої зростаючої підпослідовності (longest increasing subsequence), щоб знайти мінімальну кількість переміщень. Саме тому :key такий важливий у v-for: без нього Vue порівнює за індексом, що дає хибні результати при вставках і видаленнях.
Бітові маски shapeFlag (наприклад, 1=Element, 4=Text, 6=Fragment) дозволяють Vue розгалужуватись за O(1) замість перевірок через instanceof. patchFlags йдуть ще далі: компілятор вставляє прапорці типу TEXT=1 або PROPS=16, щоб рантайм пропускав повний рекурсивний обхід, коли змінився тільки текст. У продакшн-білдах це дає приблизно 90% прискорення порівняно з наївним повним diff-ом.
Батчинг і nextTick
Vue не патчить DOM після кожного окремого реактивного запису. Він ставить задачі в чергу і скидає їх у мікротаску через Promise.resolve().then(). До цього моменту і чіпляється nextTick(). Зроби десять мутацій стану поспіль - отримаєш один прохід патчингу, не десять. Бачив код, де nextTick викликали всередині for-циклу очікуючи послідовних DOM-читань - так не працює. Один flush на тік.
Типові помилки
Помилка: :key на вузлах поза списком
<!-- Неправильно: при кожному count++ елемент знищується і створюється знову -->
<div :key="count">Статичний текст</div>
<!-- Правильно: для стабільних вузлів key не потрібен -->
<div>Статичний текст</div>Кожен count++ знищує і відтворює елемент заново, втрачаючи фокус і ламаючи CSS-анімації.
Помилка: пряма мутація props у дочірньому компоненті
<script setup>
const props = defineProps(['item'])
props.item.done = true // Неправильно: обходить реактивність
</script>Vue перевіряє батьківський масив поверхнево. Пряма мутація не запускає реактивний сеттер, новий VNode не створюється і патч не відбувається. Правильно відправляти подію наверх: emit('update:item', ...).
Помилка: індекс масиву як key
<!-- Неправильно: при вставці всі індекси зсуваються -->
<li v-for="(todo, i) in todos" :key="i">
<!-- Правильно: стабільний унікальний id -->
<li v-for="todo in todos" :key="todo.id">Вставиш елемент на початок списку - кожен індекс зсунеться на одиницю. Vue побачить що всі ключі змінились і перерендерує весь список, а не тільки новий елемент.
Помилка: неправильні очікування від Teleport
<Teleport to="body"> переміщує VNode-и в іншу частину реального DOM, але diff все одно відбувається в дереві компонента. Якщо цільовий елемент не існує під час монтування компонента, Vue 3.2+ виводить попередження і Teleport не спрацьовує. Завжди переконуйся що target змонтований раніше.
Де зустрічається
- Vue/Nuxt TodoMVC:
v-for :keyдифає 1000+ елементів на 60fps без видимих затримок. - Quasar (динамічні форми): умовні VNode-и через
v-ifне рендерять невикористані поля взагалі. - Element Plus (таблиці): ключовані рядки дозволяють переупорядковувати колонки за O(n) замість перебудови всієї таблиці.
- Pinia-керовані списки: пакетні мутації стору скидаються як один патч, не по одному.
Питання на співбесіді
Q: Як Vue дифає сусідні VNode-и без ключів?
A: Порівнює за індексом і порядком. Вставка на позицію 0 змушує патчити всі наступні вузли без потреби. Саме тому існує ключований diffing.
Q: Що таке patchFlags і навіщо вони потрібні?
A: Компілятор вставляє цілочисельні прапорці в VNode-и під час збірки. Рантайм перевіряє прапорець перед тим як вирішити що дифати. TEXT=1 означає що змінився тільки текст, тому Vue пропускає порівняння props. У реальних застосунках це скорочує час diff-у приблизно на 90% порівняно з повним рекурсивним обходом.
Q: Чому nextTick() повертає Promise?
A: Vue скидає задачі патчингу в мікротаску. nextTick() повертає Promise, який розв'язується після цього скидання, тому можна await nextTick() і зчитувати оновлені DOM-значення без race conditions.
Q: Як SSR обробляє virtual DOM?
A: renderToString() серіалізує VNode-и в HTML-рядок. На сервері diff не запускається. Клієнт отримує цей HTML і гідратує його, патчачи вузли з сервера замість того щоб їх замінювати.
Q: Чим Vue diff відрізняється від React Fiber?
A: Vue запускає синхронний однопрохідний diff. React Fiber можна призупинити - він зупиняється посеред reconciliation і передає виконання більш пріоритетній роботі. Підхід Vue простіший і швидший для більшості випадків; підхід React кращий для дуже великих дерев з потребою в планувальнику.
Приклади
Базовий: лічильник з одним реактивним вузлом
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<p>Лічильник: {{ count }}</p>
<button @click="count++">+1</button>
</template>Після кожного кліку Vue створює новий VNode для <p>. Diff знаходить що змінився тільки текстовий вузол і викликає node.textContent = newCount. VNode кнопки ідентичний попередньому і пропускається повністю.
Середній: список задач з ключовим diffing-ом
<script setup>
import { ref, computed } from 'vue'
const todos = ref([
{ id: 1, text: 'Купити молоко', done: false },
{ id: 2, text: 'Написати код', done: true }
])
const activeCount = computed(() => todos.value.filter(t => !t.done).length)
const toggle = (id) => {
const todo = todos.value.find(t => t.id === id)
todo.done = !todo.done // Запускає diff на списку
}
</script>
<template>
<ul>
<li v-for="todo in todos" :key="todo.id" @click="toggle(todo.id)">
{{ todo.text }} <span v-if="todo.done">(виконано)</span>
</li>
</ul>
<p>{{ activeCount }} активних</p>
</template>Клік на задачу: Vue дифає список за key, патчить тільки натиснутий <li> (додає або прибирає <span>) і перераховує VNode activeCount. Решта елементів списку не зачіпається. Без :key="todo.id" Vue перерендерував би всі <li> при будь-якій зміні масиву.
Просунутий: Teleport і патчинг через межі DOM
<script setup>
import { ref } from 'vue'
const showModal = ref(false)
</script>
<template>
<button @click="showModal = true">Відкрити</button>
<Teleport to="body">
<div v-if="showModal" class="modal" @click="showModal = false">
Натисни щоб закрити
</div>
</Teleport>
</template>Коли showModal стає true, Vue патчить вузол <body> напряму через прапорець Teleport VNode, а не корінь компонента. Diff все одно відбувається в порядку дерева компонентів, але DOM-мутація потрапляє в зовсім інший вузол. Якщо body недоступний під час монтування компонента (наприклад, при SSR), Vue 3.2+ виводить попередження і Teleport не спрацьовує. Це найчіткіший приклад того, що позиції у virtual DOM і реальному DOM не обов'язково збігаються.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.