Виртуальный DOM и обновления

Разбираемся, что происходит между «изменилась реактивная переменная» и «браузер перерисовал страницу».

Virtual DOM — это лёгкое JS-описание того, как должен выглядеть реальный DOM; Vue сравнивает старое описание с новым и меняет в браузере только то, что действительно изменилось.

В базовых разделах мы писали {{ count }} и v-for и видели, что интерфейс «сам обновляется». Пора понять механику. Это не магия и не «Vue перерисовывает всё подряд». Между вашим состоянием и пикселями на экране есть несколько слоёв, и понимание их устройства напрямую влияет на скорость приложения и на то, почему иногда возникают странные баги со списками и формами.

Зачем это знать на практике

Без этой модели вы будете гадать. «Почему при добавлении одного элемента в список подвисает вся таблица?» «Почему чекбоксы в списке после сортировки оказались не у тех строк?» «Почему дочерний компонент рендерится 50 раз за секунду?» Все эти вопросы решаются, когда вы понимаете три вещи: что такое VNode, как работает diffing и зачем нужен key. Это фундамент, на котором стоит следующий урок про оптимизацию.

Что такое VNode и render-функция

Каждый ваш шаблон Vue компилирует в render-функцию — обычную JS-функцию, которая возвращает дерево объектов. Эти объекты называются VNode (virtual node). VNode — это просто описание: какой тег, какие атрибуты, какие дети. Ничего «настоящего» в нём нет, это план.

// Упрощённая модель того, во что превращается шаблон <p class="msg">Привет</p>
const vnode = {
  type: "p",
  props: { class: "msg" },
  children: "Привет"
};
console.log(vnode.type, "->", vnode.children);

Вывод:

p -> Привет

Когда компонент впервые монтируется, Vue по этому дереву VNode создаёт реальные DOM-узлы — это называется mount. А вот при обновлениях начинается самое интересное.

Diffing: сравнение старого и нового дерева

Когда реактивные данные меняются, Vue заново вызывает render-функцию компонента и получает новое дерево VNode. Теперь у него два дерева: старое (что на экране сейчас) и новое (что должно быть). Vue сравнивает их узел за узлом — это и есть diffing. Результат сравнения — минимальный набор операций над реальным DOM: «здесь поменялся текст», «тут добавился атрибут», «этот узел удалить». Реальный DOM трогается только там, где есть разница.

Почему так, а не «снести и построить заново»? Потому что операции с реальным DOM дорогие: пересчёт стилей, layout, перерисовка. Сравнение же двух JS-объектов в памяти стоит копейки. Поэтому Vue платит дешёвым сравнением, чтобы сэкономить на дорогих DOM-операциях.

ЭтапЧто происходит
renderшаблон → новое дерево VNode (в памяти)
diffсравнение нового дерева со старым
patchприменение только различий к реальному DOM

Почему key в v-for так важен

Списки — самое уязвимое место diffing. Когда Vue сравнивает старый список детей с новым, ему нужно понять: какой элемент остался, какой добавился, какой удалился. Без подсказки он сравнивает по позиции: первый со первым, второй со вторым. Это и есть источник классических багов.

Представьте список из трёх задач, у каждой — поле ввода с уже набранным текстом (это состояние живёт в реальном DOM, не в ваших данных). Вы удаляете первую задачу. Без key Vue видит: было 3 узла, стало 2. Он не понимает, что удалили именно первый, — он просто «подгоняет» старые узлы под новые по порядку. В итоге текст из второй задачи остаётся в первом поле. Данные сдвинулись, а DOM-состояние полей — нет.

<!-- ПЛОХО: Vue сопоставляет по индексу -->
<li v-for="task in tasks">
  <input v-model="task.draft">
</li>

<!-- ХОРОШО: стабильный уникальный key -->
<li v-for="task in tasks" :key="task.id">
  <input v-model="task.draft">
</li>

С :key="task.id" Vue сопоставляет узлы по идентичности, а не по позиции. Удалили задачу с id: 1 — Vue точно знает, какой DOM-узел убрать, остальные остаются на месте вместе со своим состоянием.

Каким должен быть key

  • Уникальным в пределах списка — обычно это id из базы данных.
  • Стабильным — у одного и того же элемента key не должен меняться между рендерами.
  • Не индексом массива при изменяемых списках. :key="index" ничем не лучше отсутствия key, если элементы добавляются, удаляются или сортируются: индекс привязан к позиции, а не к элементу.

Что именно заставляет компонент перерисоваться

Render-функция вызывается заново только тогда, когда меняются реактивные данные, которые этот компонент реально использует в шаблоне. Это ключевая мысль. Vue в момент рендера «запоминает», к каким реактивным полям обращался компонент (это называется отслеживанием зависимостей). Изменилось одно из них — компонент помечается на повторный рендер. Изменилось поле, которое компонент не читает, — ничего не происходит.

// Моделируем подсчёт рендеров при изменении только нужных данных
let renders = 0;
let used = { a: 1, b: 2 };      // b в шаблоне не используется
function render() { renders++; return used.a; }

render();                        // первый монтаж
used = { a: 1, b: 99 };          // изменили только b
// b не читается в render -> перерисовки нет (в реальном Vue)
used = { a: 5, b: 99 };          // изменили a
render();                        // a используется -> рендер
console.log("Рендеров:", renders);

Вывод:

Рендеров: 2

Как это работает под капотом

Реактивность Vue 3 построена на Proxy. Когда вы оборачиваете объект в reactive() или используете ref(), Vue подменяет доступ к свойствам. Во время рендера каждое чтение свойства регистрирует зависимость: «этот эффект (рендер компонента) зависит от этого свойства». Запись в свойство уведомляет все зависимые эффекты, что пора пересчитаться.

Важная деталь: Vue не перерисовывает компонент синхронно на каждое изменение. Он складывает «грязные» компоненты в очередь и обрабатывает её один раз перед следующей отрисовкой кадра браузера. Поэтому если вы в одном обработчике три раза измените одну и ту же переменную, компонент перерисуется один раз, а не три. Доступ к уже обновлённому DOM получают через await nextTick().

Ещё компилятор шаблонов делает статический анализ. Части шаблона, которые никогда не меняются (статический текст, неизменные атрибуты), он помечает и пропускает при diffing — это называется hoisting и patch flags. Поэтому ручной virtual DOM в React-стиле почти никогда не нужен: компилятор Vue уже знает, что в вашем шаблоне динамическое, а что нет.

Частые ошибки

  • :key="index" в изменяемом списке. Работает, пока список только растёт с конца. Сломается на удалении, вставке в середину и сортировке. Берите устойчивый id.
  • Использование индекса массива как ключа объекта вместо id. После удаления элемента индексы «съезжают», и Vue переиспользует не те DOM-узлы — частая причина «прилипшего» состояния в полях ввода и чекбоксах.
  • Ожидание, что DOM обновится сразу после изменения данных. Обновление асинхронное; читать новый DOM нужно после await nextTick(), иначе получите старые размеры/значения.
  • Вера в то, что Vue перерисовывает «всё». Нет: перерисовывается только компонент, чьи использованные данные изменились. Если что-то ререндерится «само» — значит, оно действительно читает изменившуюся реактивную переменную.

Итоги

  • Шаблон компилируется в render-функцию, возвращающую дерево VNode — лёгкое описание DOM.
  • При изменении данных Vue строит новое дерево, сравнивает со старым (diffing) и патчит в реальном DOM только различия.
  • key в v-for даёт Vue идентичность элементов, чтобы он сопоставлял узлы по сути, а не по позиции; ключ должен быть уникальным и стабильным, не индексом.
  • Компонент перерисовывается только когда меняются реактивные данные, которые он реально читает в шаблоне.
  • Обновления группируются в очередь и применяются асинхронно; новый DOM доступен после nextTick().
Проверьте себя
1. Зачем Vue сравнивает два дерева VNode вместо того, чтобы пересоздавать реальный DOM целиком?
AОперации с реальным DOM (layout, перерисовка) дороги, а сравнение JS-объектов в памяти дёшево, поэтому правят только различия
BБраузер запрещает удалять более одного DOM-узла за раз
CVNode занимают больше памяти, и их выгодно держать на экране
DТак требует стандарт HTML5
2. Почему :key="index" — плохой выбор для списка, в котором элементы можно удалять и сортировать?
AИндекс — это число, а key обязан быть строкой
BИндекс привязан к позиции, а не к элементу: при удалении/сортировке индексы съезжают и Vue переиспользует не те DOM-узлы вместе с их состоянием
CVue вообще не поддерживает числовые ключи
DИндекс делает рендер синхронным и замедляет страницу
3. Что заставляет конкретный компонент Vue выполнить повторный рендер?
AЛюбое изменение любых данных во всём приложении
BИзменение реактивных данных, которые этот компонент реально читает в своём шаблоне
CКаждый кадр анимации браузера
DТолько ручной вызов forceUpdate()