Skip to main content

Як працює 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. Для статичних сторінок - не потрібен.

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

vue
<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 на вузлах поза списком

vue
<!-- Неправильно: при кожному count++ елемент знищується і створюється знову --> <div :key="count">Статичний текст</div> <!-- Правильно: для стабільних вузлів key не потрібен --> <div>Статичний текст</div>

Кожен count++ знищує і відтворює елемент заново, втрачаючи фокус і ламаючи CSS-анімації.

Помилка: пряма мутація props у дочірньому компоненті

vue
<script setup> const props = defineProps(['item']) props.item.done = true // Неправильно: обходить реактивність </script>

Vue перевіряє батьківський масив поверхнево. Пряма мутація не запускає реактивний сеттер, новий VNode не створюється і патч не відбувається. Правильно відправляти подію наверх: emit('update:item', ...).

Помилка: індекс масиву як key

vue
<!-- Неправильно: при вставці всі індекси зсуваються --> <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 кращий для дуже великих дерев з потребою в планувальнику.

Приклади

Базовий: лічильник з одним реактивним вузлом

vue
<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-ом

vue
<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

vue
<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 не обов'язково збігаються.

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

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

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

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