Антон Ларичев
Как работает Virtual DOM в React
Что такое Virtual DOM
В браузере DOM (Document Object Model) — это дерево объектов, которое представляет HTML-страницу. Любое обновление DOM обходится дорого: браузер пересчитывает стили, выполняет layout и перерисовывает пиксели. Прямые манипуляции с DOM через document.querySelector или innerHTML могут вызывать сотни таких операций при каждом изменении.
Virtual DOM (VDOM) — это лёгкое JavaScript-представление реального DOM-дерева. React хранит его в памяти и при каждом изменении состояния сравнивает новое дерево со старым, находит минимальный набор изменений и применяет только их к реальному DOM.
По сути VDOM — это обычный JavaScript-объект:
// Реальный DOM-элемент
// <div class="card">
// <h2>Привет</h2>
// </div>
// Его представление в Virtual DOM
{
type: 'div',
props: { className: 'card' },
children: [
{
type: 'h2',
props: {},
children: ['Привет']
}
]
}
Операции с таким объектом выполняются в тысячи раз быстрее, чем с реальным DOM, потому что не запускают механизмы браузера.
Закрепите тему на практике: Пройти курс React и Redux Toolkit с поддержкой наставников.
Курс по теме
Пройти курс React и Redux Toolkit
40 000+ студентов · рейтинг 4.8 · гарантия возврата 30 дней
Как React создаёт Virtual DOM
Каждый JSX-элемент компилируется в вызов React.createElement (или _jsx в современных сборках с новым JSX-трансформом):
// JSX
const element = (
<div className="card">
<h2>Привет</h2>
</div>
);
// Что получается после компиляции
const element = React.createElement(
'div',
{ className: 'card' },
React.createElement('h2', null, 'Привет')
);
Результат — «React-элемент», простой иммутабельный объект:
{
$$typeof: Symbol(react.element),
type: 'div',
props: {
className: 'card',
children: { type: 'h2', props: { children: 'Привет' } }
},
key: null,
ref: null
}
Etот объект описывает то, что должно быть на экране. React хранит дерево таких объектов и сравнивает его с предыдущей версией при каждом рендере.
Алгоритм сравнения (Reconciliation и Diffing)
Процесс применения изменений из Virtual DOM в реальный называется reconciliation (сверка). Внутри него работает алгоритм diffing — поиск различий между двумя деревьями.
Наивное сравнение двух деревьев требует O(n³) операций, что недопустимо для реальных приложений. React использует два эвристических допущения, которые снижают сложность до O(n):
- Два элемента разного типа порождают разные деревья. Если корневой элемент изменил тип — React уничтожает старое поддерево и строит новое с нуля.
- Ключи (key) позволяют сопоставлять дочерние элементы между рендерами. С их помощью React понимает, какой элемент остался на месте, а какой переместился.
Правило 1: смена типа элемента
// До обновления
<div>
<Counter />
</div>
// После обновления — тип изменился с div на span
<span>
<Counter />
</span>
React видит, что корневой тип изменился с div на span, уничтожает весь компонент Counter (вызывает componentWillUnmount / эффект очистки), и создаёт новый экземпляр.
Если тип остался прежним — React сравнивает атрибуты и обновляет только изменившиеся:
// До
<div className="old" title="подсказка" />
// После
<div className="new" title="подсказка" />
// React обновит только className, title трогать не будет
Правило 2: список дочерних элементов и ключи
Рассмотрим список без ключей:
// До
<ul>
<li>Яблоко</li>
<li>Банан</li>
</ul>
// После — добавили элемент в конец
<ul>
<li>Яблоко</li>
<li>Банан</li>
<li>Вишня</li>
</ul>
React сравнивает позицию за позицией. Первые два совпали — добавлен только третий. Это эффективно.
Но если добавить элемент в начало без ключей:
// До
<ul>
<li>Яблоко</li>
<li>Банан</li>
</ul>
// После — добавили элемент в начало
<ul>
<li>Вишня</li>
<li>Яблоко</li>
<li>Банан</li>
</ul>
React сравнит позицию 0: Яблоко vs Вишня — разные, обновить. Позицию 1: Банан vs Яблоко — разные, обновить. Позиция 2 — новый элемент. В итоге обновлены все три узла, хотя можно было добавить только один.
Ключи решают эту проблему:
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
Теперь React сопоставляет элементы по key, а не по позиции. Он видит, что Яблоко и Банан никуда не делись — просто сместились вниз. В DOM вставляется только один новый узел.
Важно: никогда не используйте индекс массива как ключ в списке, который может изменяться или переупорядочиваться:
// Плохо — индекс меняется при сортировке или удалении
{items.map((item, index) => (
<li key={index}>{item.name}</li>
))}
// Хорошо — стабильный идентификатор
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
React Fiber: современная реализация Virtual DOM
До React 16 алгоритм reconciliation работал синхронно: начав обход дерева, он не мог остановиться до завершения. При большом дереве это блокировало главный поток и вызывало пропуск кадров.
React 16 представил Fiber — переписанный движок reconciliation. Fiber разбивает работу на небольшие единицы (fiber-узлы) и умеет прерывать, откладывать и возобновлять их выполнение.
Каждый компонент получает соответствующий fiber-узел — объект, который хранит:
{
type: MyComponent, // тип компонента
stateNode: instance, // ссылка на DOM-узел или экземпляр класса
child: fiberNode, // первый дочерний узел
sibling: fiberNode, // следующий соседний узел
return: fiberNode, // родительский узел
pendingProps: {}, // новые пропсы
memoizedProps: {}, // применённые пропсы
memoizedState: {}, // текущее состояние
effectTag: 'UPDATE', // что нужно сделать с DOM
}
Fiber работает в две фазы:
Фаза Render (Reconciliation)
React строит новое fiber-дерево («work-in-progress tree»), сравнивая его с текущим. Эта фаза прерываемая: браузер может взять управление для обработки ввода или отрисовки кадра, а React потом продолжит.
Фаза Commit
Когда сравнение завершено, React применяет все накопленные изменения к реальному DOM за один синхронный проход. Эта фаза не прерывается — иначе пользователь увидел бы частично обновлённый интерфейс.
function App() {
const [count, setCount] = useState(0);
return (
<div>
<p>Счётчик: {count}</p>
{/* React обновит только текстовый узел внутри <p> */}
<button onClick={() => setCount(c => c + 1)}>+1</button>
</div>
);
}
При клике React:
- Создаёт новое fiber-дерево (render-фаза)
- Определяет, что изменился только текст
{count}внутри<p> - В commit-фазе обновляет один текстовый узел в реальном DOM
Батчинг обновлений
React группирует несколько вызовов setState в одно обновление — это называется batching. Так Virtual DOM перестраивается один раз, а не несколько:
function Counter() {
const [count, setCount] = useState(0);
const [doubled, setDoubled] = useState(0);
function handleClick() {
// React 18: оба setState объединятся в один рендер
setCount(c => c + 1);
setDoubled(c => c + 2);
}
return <button onClick={handleClick}>{count} / {doubled}</button>;
}
До React 18 батчинг работал только в обработчиках событий React. Начиная с React 18 — везде, включая setTimeout, fetch-коллбэки и нативные события.
Практический пример: почему важна структура компонентов
Virtual DOM эффективен только тогда, когда React умеет переиспользовать существующие узлы. Неправильная структура лишает его такой возможности:
// Плохо: компонент создаётся внутри другого компонента
// При каждом рендере Parent тип функции меняется,
// React думает, что это другой компонент, и монтирует заново
function Parent() {
const Child = () => <div>Дочерний</div>; // новая функция каждый рендер!
return <Child />;
}
// Хорошо: вынести компонент наружу
const Child = () => <div>Дочерний</div>;
function Parent() {
return <Child />;
}
В первом варианте при каждом рендере Parent создаётся новая функция Child — React видит другой type и перемонтирует компонент, уничтожая его состояние.
Как Virtual DOM влияет на оптимизацию
Понимание Virtual DOM помогает правильно применять инструменты оптимизации:
import { memo, useCallback } from 'react';
// memo сравнивает пропсы между рендерами
// Если пропсы не изменились — компонент не перерендерится
const ExpensiveList = memo(function ExpensiveList({ items, onSelect }) {
return (
<ul>
{items.map(item => (
<li key={item.id} onClick={() => onSelect(item.id)}>
{item.name}
</li>
))}
</ul>
);
});
function App() {
const [selected, setSelected] = useState(null);
const [filter, setFilter] = useState('');
// useCallback сохраняет ссылку на функцию
// Без него каждый рендер App создаёт новую функцию,
// и memo на ExpensiveList не сработает
const handleSelect = useCallback((id) => {
setSelected(id);
}, []);
const filtered = items.filter(i => i.name.includes(filter));
return (
<>
<input value={filter} onChange={e => setFilter(e.target.value)} />
<ExpensiveList items={filtered} onSelect={handleSelect} />
</>
);
}
memo работает на уровне Virtual DOM: React пропускает фазу render для компонента, если пропсы не изменились, и не строит новое fiber-поддерево.
Ограничения Virtual DOM
Virtual DOM не является панацеей:
- Накладные расходы на сравнение. Для очень маленьких приложений прямые манипуляции с DOM могут быть быстрее.
- Память. React хранит два дерева одновременно (current и work-in-progress).
- Не все операции ускоряются. Анимации, canvas, WebGL и прямой доступ к DOM (через
ref) обходят Virtual DOM полностью.
Для случаев, когда Virtual DOM мешает, React предоставляет useRef и flushSync:
import { useRef } from 'react';
function VideoPlayer() {
const videoRef = useRef(null);
// Прямой доступ к DOM-элементу, минуя Virtual DOM
function handlePlay() {
videoRef.current.play();
}
return <video ref={videoRef} src="/video.mp4" />;
}
Итог
Virtual DOM — это слой абстракции между логикой приложения и реальным DOM браузера. Он позволяет React:
- Декларативно описывать интерфейс через JSX
- Батчить обновления и применять только минимальный набор изменений
- Прерывать и приоритизировать работу через Fiber
Главное, что нужно помнить на практике: ключи в списках, стабильные типы компонентов и неизменяемость пропсов — три вещи, которые напрямую влияют на эффективность алгоритма сравнения.
Чтобы глубоко разобраться в работе React, жизненном цикле компонентов и оптимизации рендеров, пройдите курс React на PurpleSchool.
Постройте личный план изучения React до уровня Middle — бесплатно!
React — часть карты развития Frontend
100+ шагов развития
30 бесплатных лекций
300 бонусных рублей на счет
Бесплатные лекции
Все гайды по React
Лучшие курсы по теме

React и Redux Toolkit
Антон Ларичев
TypeScript с нуля
Антон Ларичев