Virtual DOM
Зачем нужно
Virtual DOM — это лёгкая копия реального DOM, хранящаяся в памяти как JavaScript-объект. Вместо прямой работы с DOM (медленно), фреймворк сначала вносит изменения в виртуальное дерево, затем сравнивает его со старым (diffing) и применяет к реальному DOM только минимальный набор изменений (patching).
Это ключевая концепция React и аналогичных фреймворков.
Где используется
- React — основной механизм обновления UI
- Vue 2/3 — использует Virtual DOM для рендеринга
- Preact, Inferno — лёгкие аналоги React с VDOM
- Любое SPA, где нужно эффективно обновлять интерфейс
Почему DOM медленный
// Каждая операция с DOM — дорогая:
// 1. Пересчёт стилей (Style recalculation)
// 2. Перерасчёт layout (Reflow)
// 3. Перерисовка пикселей (Repaint)
// ❌ ПЛОХО — 1000 обращений к DOM
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = `Item ${i}`;
ul.appendChild(li); // Каждый раз → reflow
}
// ✅ ЛУЧШЕ — 1 обращение к DOM
const fragment = document.createDocumentFragment;
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = `Item ${i}`;
fragment.appendChild(li); // Без reflow
}
ul.appendChild(fragment); // Один reflow
// ✅ ЕЩЁ ЛУЧШЕ — Virtual DOM делает это автоматически
Как работает Virtual DOM
1. State изменился (setState)
↓
2. Создаётся НОВОЕ виртуальное дерево (render)
↓
3. Diffing: сравнение нового VDOM со старым
↓
4. Найдены минимальные различия (patches)
↓
5. Patches применяются к реальному DOM
↓
6. Старое виртуальное дерево заменяется новым
Реализация Virtual DOM
Шаг 1: Виртуальный узел (VNode)
// VNode — JavaScript-объект, описывающий элемент
// Вместо настоящего DOM-элемента — простой объект
function createElement(type, props, ...children) {
return {
type, // 'div', 'span', 'h1'
props: props || {},
children: children
.flat()
.map(child =>
typeof child === 'object' ? child : createTextNode(child)
),
};
}
function createTextNode(text) {
return {
type: 'TEXT',
props: {},
children: ,
value: String(text),
};
}
// Использование (аналог JSX)
const vdom = createElement('div', { class: 'app' },
createElement('h1', null, 'Заголовок'),
createElement('ul', null,
createElement('li', null, 'Элемент 1'),
createElement('li', null, 'Элемент 2'),
),
);
// Результат — JS-объект:
// {
// type: 'div',
// props: { class: 'app' },
// children: [
// { type: 'h1', props: {}, children: [{ type: 'TEXT', value: 'Заголовок' }] },
// { type: 'ul', props: {}, children: [
// { type: 'li', props: {}, children: [{ type: 'TEXT', value: 'Элемент 1' }] },
// { type: 'li', props: {}, children: [{ type: 'TEXT', value: 'Элемент 2' }] },
// ]},
// ],
// }
Шаг 2: Рендеринг VDOM в реальный DOM
// Превращаем VNode → настоящий DOM-элемент
function renderToDOM(vnode) {
// Текстовый узел
if (vnode.type === 'TEXT') {
return document.createTextNode(vnode.value);
}
// Элемент
const element = document.createElement(vnode.type);
// Устанавливаем атрибуты/свойства
for (const [key, value] of Object.entries(vnode.props)) {
if (key.startsWith('on')) {
// События: onClick → click
const eventName = key.slice(2).toLowerCase();
element.addEventListener(eventName, value);
} else if (key === 'class') {
element.className = value;
} else {
element.setAttribute(key, value);
}
}
// Рекурсивно рендерим детей
for (const child of vnode.children) {
element.appendChild(renderToDOM(child));
}
return element;
}
Шаг 3: Diffing — сравнение деревьев
// Сравниваем старое и новое VDOM-дерево
// Возвращаем патч-функцию, которая обновит реальный DOM
function diff(oldVNode, newVNode) {
// Узел удалён
if (!newVNode) {
return (domNode) => {
domNode.remove();
return null;
};
}
// Узел добавлен
if (!oldVNode) {
return (domNode) => {
const newDom = renderToDOM(newVNode);
domNode.parentNode.insertBefore(newDom, domNode);
return newDom;
};
}
// Разные типы → полная замена
if (oldVNode.type !== newVNode.type) {
return (domNode) => {
const newDom = renderToDOM(newVNode);
domNode.replaceWith(newDom);
return newDom;
};
}
// Текстовый узел — сравниваем значение
if (newVNode.type === 'TEXT') {
if (oldVNode.value !== newVNode.value) {
return (domNode) => {
domNode.textContent = newVNode.value;
return domNode;
};
}
return (domNode) => domNode; // Без изменений
}
// Одинаковый тип — сравниваем props и children
const propPatches = diffProps(oldVNode.props, newVNode.props);
const childPatches = diffChildren(oldVNode.children, newVNode.children);
return (domNode) => {
propPatches(domNode);
childPatches(domNode);
return domNode;
};
}
function diffProps(oldProps, newProps) {
const patches = ;
// Обновлённые/новые свойства
for (const [key, value] of Object.entries(newProps)) {
if (oldProps[key] !== value) {
patches.push((node) => node.setAttribute(key, value));
}
}
// Удалённые свойства
for (const key of Object.keys(oldProps)) {
if (!(key in newProps)) {
patches.push((node) => node.removeAttribute(key));
}
}
return (node) => patches.forEach(patch => patch(node));
}
function diffChildren(oldChildren, newChildren) {
const patches = ;
const maxLen = Math.max(oldChildren.length, newChildren.length);
for (let i = 0; i < maxLen; i++) {
patches.push(diff(oldChildren[i], newChildren[i]));
}
return (parentNode) => {
const childNodes = [...parentNode.childNodes];
patches.forEach((patch, i) => {
if (childNodes[i]) {
patch(childNodes[i]);
} else if (newChildren[i]) {
parentNode.appendChild(renderToDOM(newChildren[i]));
}
});
};
}
Шаг 4: Применение (собираем всё вместе)
class MiniReact {
constructor(container) {
this.container = container;
this.currentVDOM = null;
}
render(vnodeFactory) {
const newVDOM = vnodeFactory;
if (!this.currentVDOM) {
// Первый рендер
const domTree = renderToDOM(newVDOM);
this.container.innerHTML = '';
this.container.appendChild(domTree);
} else {
// Обновление — diff + patch
const patch = diff(this.currentVDOM, newVDOM);
patch(this.container.firstChild);
}
this.currentVDOM = newVDOM;
}
}
// Использование
const app = new MiniReact(document.getElementById('app'));
let count = 0;
function renderApp() {
app.render( =>
createElement('div', { class: 'counter' },
createElement('h1', null, `Счётчик: ${count}`),
createElement('button', { onClick: => { count++; renderApp; } },
'Увеличить'
),
)
);
}
renderApp;
Reconciliation (согласование)
Алгоритм reconciliation в React использует эвристики для O(n) сравнения:
Правило 1: Разные типы → полная замена поддерева
<div> → <span> → удалить div, создать span
Правило 2: Одинаковый тип → обновить атрибуты
<div class="a"> → <div class="b"> → изменить class
Правило 3: Списки → используй key
Без key: [A, B, C] → [B, C] — React думает A→B, B→C, удалить C
С key: [A, B, C] → [B, C] — React знает: удалить A
Зачем нужен key
// ❌ Без key — неэффективное обновление
const items = ['Б', 'В']; // было ['А', 'Б', 'В']
items.map(item => createElement('li', null, item));
// React: li[0] 'А'→'Б', li[1] 'Б'→'В', удалить li[2]
// 2 обновления + 1 удаление
// ✅ С key — оптимальное обновление
items.map(item => createElement('li', { key: item }, item));
// React: удалить li с key='А', остальные не трогать
// 1 удаление
Альтернативы Virtual DOM
Svelte — компиляция вместо VDOM
// Svelte компилирует код в точечные DOM-обновления
// Нет runtime diff — компилятор знает, что именно обновлять
// Svelte-компонент
// <script>
// let count = 0;
// </script>
// <button on:click={ => count++}>
// Счётчик: {count}
// </button>
// Скомпилированный код (упрощённо):
function update() {
// Точечное обновление — только textContent кнопки
button.textContent = `Счётчик: ${count}`;
}
Solid.js — Fine-grained reactivity
// Solid использует сигналы — каждое значение отслеживается отдельно
// Нет перерисовки компонента целиком
// const [count, setCount] = createSignal(0);
// <button onClick={ => setCount(count + 1)}>
// Счётчик: {count}
// </button>
// DOM обновляется напрямую, без VDOM diffing
Частые ошибки
- Нет key в списках — React не может эффективно обновлять списки, перерисовывает всё
- Index как key —
key={index}не работает при переупорядочивании/удалении элементов - Создание объектов в render —
style={{ color: 'red' }}создаёт новый объект каждый рендер - Тяжёлые вычисления в render — render вызывается часто, вычисления нужно мемоизировать
- Путаница VDOM с реальным DOM — Virtual DOM не быстрее ручных точечных обновлений, он быстрее наивной полной перерисовки
Практика
- Реализовать функцию
createElement(type, props, ...children)→ VNode-объект - Реализовать
renderToDOM(vnode)— конвертация VNode в реальный DOM-элемент - Реализовать простой
diffдля текстовых узлов и атрибутов - Собрать мини-фреймворк: state → VDOM → diff → patch → DOM
- Сравнить производительность: innerHTML vs Virtual DOM vs точечные обновления
Связанные темы
- Компонентный подход — компоненты как единица VDOM
- Управление состоянием — изменение state запускает VDOM diffing
- Что такое SPA — Virtual DOM решает проблему перерисовки в SPA
- Webpack — JSX компилируется в createElement через Babel