Виртуальный 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().