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

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

  1. Нет key в списках — React не может эффективно обновлять списки, перерисовывает всё
  2. Index как keykey={index} не работает при переупорядочивании/удалении элементов
  3. Создание объектов в renderstyle={{ color: 'red' }} создаёт новый объект каждый рендер
  4. Тяжёлые вычисления в render — render вызывается часто, вычисления нужно мемоизировать
  5. Путаница VDOM с реальным DOM — Virtual DOM не быстрее ручных точечных обновлений, он быстрее наивной полной перерисовки

Практика

  1. Реализовать функцию createElement(type, props, ...children) → VNode-объект
  2. Реализовать renderToDOM(vnode) — конвертация VNode в реальный DOM-элемент
  3. Реализовать простой diff для текстовых узлов и атрибутов
  4. Собрать мини-фреймворк: state → VDOM → diff → patch → DOM
  5. Сравнить производительность: innerHTML vs Virtual DOM vs точечные обновления

Связанные темы

Ресурсы