Настоящий или реальный (real) DOM
DOM
расшифровывается как Document Object Model
(объектная модель документа). Проще говоря, DOM — это представление пользовательского интерфейса (user interface, UI) в приложении. При каждом изменении UI, DOM также обновляется для отображения этих изменений. Частые манипуляции с DOM негативно влияют на производительность.
Что делает манипуляции с DOM медленными?
DOM представляет собой древовидной структуру данных. Поэтому изменения и обновления самого DOM являются достаточно быстрыми. Но после изменения обновленный элемент и все его потомки (дочерние элементы) должны быть повторно отрисованы (отрендерены) для обновления UI приложения. Повторный рендеринг — очень медленный процесс. Таким образом, чем больше у нас компонентов UI, тем более дорогими с точки зрения производительности являются обновления DOM.
Манипуляции с DOM являются сердцем современного интерактивного веба. К сожалению, они намного медленнее большинства JavaScript-операций. Ситуация усугубляется тем, что многие JavaScript-фреймворки обновляют DOM чаще, чем необходимо.
Допустим, у нас имеется список из 10 элементов. Мы изменяем первый элемент. Большинство фреймворков перестроят весь список. Это в 10 раз больше работы, чем требуется! Только 1 элемент изменился, остальные 9 остались прежними.
Перестроение списка — это легкая задача для браузера, но современные веб-сайты могут осуществлять огромное количество манипуляций с DOM. Поэтому неэффективное обновление часто становится серьезной проблемой. Для решения данной проблемы команда React популяризовала нечто под названием виртуальный (virtual) DOM (VDOM).
Виртуальный DOM
В React для каждого объекта настоящего DOM (далее — RDOM) существует соответствующий объект VDOM. VDOM — это объектное представление RDOM, его легковесная копия. VDOM содержит те же свойства, что и RDOM, но не может напрямую влиять на то, что отображается на экране.
Виртуальный DOM (VDOM) — это концепция программирования, где идеальное или «виртуальное» представление UI хранится в памяти и синхронизируется с «реальным» DOM, используемая такими библиотеками, как ReactDOM. Данный процесс называется согласованием (reconcilation).
Манипуляции с RDOM являются медленными. Манипуляции с VDOM намного быстрее, поскольку они не отображаются (отрисовываются) на экране. Манипуляции с VDOM похожи на работу с проектом (или планом) здания перед началом его возведения.
Почему VDOM является более быстрым?
Когда в UI добавляются новые элементы, создается VDOM в виде дерева. Каждый элемент является узлом этого дерева. При изменении состояния любого элемента, создается новое дерево. Затем это новое дерево сравнивается (diffed) со старым.
После этого вычисляется наиболее эффективный метод внесения изменений в RDOM. Цель данных вычислений состоит в минимизации количества операций, совершаемых с RDOM. Тем самым, уменьшаются накладные расходы, связанные с обновлением RDOM.
На изображениях ниже представлено виртуальное DOM-дерево и процесс согласования.
Красным цветом обозначены узлы, которые были обновлены. Эти узлы представляют элементы UI, состояние которых изменилось. После этого вычисляется разница между предыдущей и текущей версиями виртуального DOM-дерева. Затем все родительское поддерево подвергается повторному рендерингу для представления обновленного UI. Наконец, это обновленное дерево используется для обновления RDOM.
Как React использует VDOM?
После того, как мы рассмотрели, что такое VDOM, настало время поговорить о том, как он используется в React.
1. React использует паттерн проектирования «Наблюдатель» (observer) и реагирует на изменения состояния
В React каждая часть UI является компонентом и почти каждый компонент имеет состояние (state). При изменении состояния компонента, React обновляет VDOM. После обновления VDOM, React сравнивает его текущую версию с предыдущей. Этот процесс называется «поиском различий» (diffing).
После обнаружения объектов, изменившихся в VDOM, React обновляет соответствующие объекты в RDOM. Это существенно повышает производительность по сравнению с прямыми манипуляциями DOM. Именно это делает React высокопроизводительной библиотекой JavaScript.
2. React использует механизм пакетного (batch) обновления RDOM
Это также положительно влияет на производительность. Названный механизм предполагает отправку обновлений в виде пакетов (набора, серии) вместо отправки отдельного обновления при каждом изменении состояния.
Повторная отрисовка UI — самая затратная часть, React обеспечивает точечную и групповую перерисовку RDOM.
3. React использует эффективный алгоритм поиска различий
React использует эвристический O(n) (линейный) алгоритм, основываясь на двух предположениях:
-
Два элемента разных типов приводят к построению разных деревьев
-
Разработчик может обеспечить стабильность элементов между рендерингами посредством пропа key
(ключ)
На практике эти предположения являются верными почти во всех случаях.
При сравнении двух деревьев, React начинает с корневых элементов. Дальнейшие операции зависят от типов этих элементов.
Элементы разных типов
-
Если корневые элементы имеют разные типы, React уничтожает старое дерево и строит новое с нуля
-
Вместе со старым деревом уничтожаются все старые узлы DOM. Экземпляры компонента получают componentWillUnmount()
. При построении нового дерева, новые узлы DOM встраиваются в DOM. Экземпляры компонента получают сначала UNSAFE_componentWillMount()
, затем componentDidMount()
. Любое состояние, связанное со старым деревом, утрачивается
-
Любые компоненты, являющиеся дочерними по отношению к корневому, размонтируются, их состояние уничтожается. Например, при сравнении:
<div>
<Counter />
</div>
<span>
<Counter />
</span>
Старый Counter
будет уничтожен и создан заново.
Элементы одинакового типа
При сравнении двух элементов одинакового типа, React «смотрит» на атрибуты этих элементов. Узлы DOM сохраняются, изменяются только их атрибуты. Например:
<div className="before" title="stuff" />
<div className="after" title="stuff" />
После сравнения этих элементов будет обновлен только атрибут className
.
После обработки узла DOM, React рекурсивно перебирает всех его потомков.
Рекурсивный перебор дочерних элементов
По умолчанию React перебирает два списка дочерних элементов DOM-узла и генерирует мутацию при обнаружении различий.
Например, при добавлении элемента в конец списка дочерних элементов, преобразование одного дерева в другое работает хорошо:
<ul>
<li>первый</li>
<li>второй</li>
</ul>
<ul>
<li>первый</li>
<li>второй</li>
<li>третий</li>
</ul>
React "видит", что в обоих деревьях имеются <li>первый</li>
и <li>второй</li>
, пропускает их и вставляет в конец <li>третий</li>
.
Обычно, вставка элемента в начало списка плохо влияет на производительность. Например, преобразование одного дерева в другое в данном случае будет работать плохо:
<ul>
<li>первый</li>
<li>второй</li>
</ul>
<ul>
<li>нулевой</li>
<li>первый</li>
<li>второй</li>
</ul>
React не сможет понять, что <li>первый</li>
и <li>второй</li>
остались прежними и мутирует каждый элемент.
Использование ключей
Для решения данной проблемы React предоставляет атрибут (проп) key
. Когда дочерние элементы имеют ключи, React использует их для сравнения потомков текущего и предыдущего узлов. Например, добавление ключей к элементам из последнего примера сделает преобразование деревьев намного более эффективным:
<ul>
<li key="1">первый</li>
<li key="2">второй</li>
</ul>
<ul>
<li key="0">нулевой</li>
<li key="1">первый</li>
<li key="2">второй</li>
</ul>
Теперь React знает, что элемент с ключом 0
является новым, а элементы с ключами 1
и 2
старыми.
На практике в качестве ключей, как правило, используются идентификаторы:
<li key={item.id}>{item.name}</li>
При отсутствии идентификаторов, их всегда можно добавить в модель данных или создать хэш на основе какой-либо части данных. Ключи должны быть уникальными среди соседних элементов, а не глобально.
В крайней случае, в качестве ключей можно использовать индексы массива. Это работает хорошо только в том случае, если порядок элементов остается неизменным. Изменение порядка элементов будет медленным.
Изменение порядка элементов при использовании индексов в качестве ключей также может привести к проблемам с состоянием элементов. Экземпляры компонента обновляются и повторно используются на основе ключей. Если ключом является индекс, перемещение элемента приведет к изменению ключа. Как результат, состояние компонента для таких вещей, как неуправляемое поле для ввода данных, может смешаться и обновиться неожиданным образом.
Простыми словами: «Вы говорите React, в каком состоянии должен находиться UI, и он обеспечивает соответствие DOM этому состоянию. Преимущество такого подхода состоит в том, что вам, как разработчику, не нужно знать, как именно происходит изменение атрибутов, обработка событий и обновление DOM».
Все эти вещи абстрагируются React. Все, что вам нужно делать — это обновлять состояние компонента, об остальном позаботится React. Это обеспечивает очень хороший опыт разработки.
Поскольку «виртуальный DOM» — это в большей степени паттерн, нежели конкретная технология, данное понятие может означать разные вещи. В мире React «виртуальный DOM», обычно, ассоциируется с React-элементами, которые являются объектами, представляющими пользовательский интерфейс. Тем не менее, React также использует внутренние объекты, которые называются «волокнами» (fibers). В этих объектах хранится дополнительная информация о дереве компонентов. Fiber
— это новый движок согласования, появившийся в React 16. Его основная цель заключается в обеспечении инкрементального рендеринга VDOM.
Как выглядит VDOM?
Название «виртуальный DOM» делает концепцию немного магической (мистической). На самом деле, VDOM — это обычный JavaScript-объект.
Представим, что у нас имеется такое DOM-дерево:
Это дерево может быть представлено в виде такого объекта:
const vdom = {
tagName: 'html',
children: [
{ tagName: 'head' },
{
tagName: 'body',
children: [
{
tagName: 'ul',
attributes: { class: 'list' },
children: [
{
tagName: 'li',
attributes: { class: 'list_item' },
textContent: 'Элемент списка',
}, // конец li
],
}, // конец ul
],
}, // конец body
],
} // конец html
Это наш VDOM. Как и RDOM, он является объектным представлением HTML-документа (разметки). Однако, поскольку он представляет собой всего лишь объект, мы можем свободно и часто им манипулировать, не прикасаясь к RDOM без крайней необходимости.
Вместо использования одного объекта для всего документа, удобнее разделить его на небольшие секции. Например, мы можем выделить из нашего объекта компонент list
, соответствующий неупорядоченному списку:
const list = {
tagName: 'ul',
attributes: { class: 'list' },
children: [
{
tagName: 'li',
attributes: { class: 'list_item' },
textContent: 'Элемент списка',
},
],
}
VDOM под капотом
Теперь давайте поговорим о том, как VDOM решает проблему производительности и повторного использования.
Как мы выяснили ранее, VDOM может использоваться для обнаружения конкретных изменений, которые необходимо произвести в DOM. Вернемся к примеру с неупорядоченным списком и внесем в него те же изменения, которые мы делали с помощью DOM API.
Первым делом, нам нужна копия VDOM с изменениями, которые мы хотим осуществить. Поскольку нам не нужно использовать DOM API, мы можем просто создать новый объект.
const copy = {
tagName: 'ul',
attributes: { class: 'list' },
children: [
{
tagName: 'li',
attributes: { class: 'list_item' },
textContent: 'Первый элемент списка',
},
{
tagName: 'li',
attributes: { class: 'list_item' },
textContent: 'Второй элемент списка',
},
],
}
Данная копия используется для создания «различия» (diff) между оригинальным VDOM (list
) и его обновленной версией. Diff
может выглядеть следующим образом:
const diffs = [
{
newNode: {
/* новая версия первого элемента списка */
},
oldNode: {
/* оригинальная версия первого элемента списка */
},
index: {
/* индекс элемента в родительском списке */
},
},
{
newNode: {
/* второй элемент списка */
},
index: {
/* ... */
},
},
]
Данный diff
содержит инструкции по обновлению RDOM. После определения всех различий мы можем отправить их в DOM для выполнения необходимых обновлений.
Например, мы можем перебрать все различия и либо добавить нового потомка, либо обновить существующего в зависимости от различия:
const domElement = document.quesrySelector('list')
diffs.forEach((diff) => {
const newElement = document.createElement(diff.newNode.tagName)
/* Добавляем атрибуты ... */
if (diff.oldNode) {
// Если имеется старая версия, заменяем ее новой
domElement.replaceChild(diff.newNode, diff.oldNode)
} else {
// Если старая версия отсутствует, создаем новый узел
domElement.append(diff.newNode)
}
})
Обратите внимание, что это очень упрощенная версия того, как может работать VDOM.
VDOM и фреймворки
Обычно, мы имеем дело с VDOM при использовании фреймворков.
Копцепция VDOM используется такими фреймворками, как React и Vue для повышения производительности обновления DOM. Например, с помощью React наш компонент list
может быть реализован следующим образом:
import React from 'react'
import ReactDOM from 'react-dom'
const list = React.createElement(
'ul',
{ className: 'list' },
React.createElement('li', { className: 'list_item' }, 'Элемент списка')
)
// для создания элементов в React обычно используется специальный синтаксис под названием «JSX»
// const list = <ul className="list"><li className="list_item">Элемент списка</li></ul>
ReactDOM.render(list, document.body)
Для обновления списка достаточно создать новый шаблон и снова передать его ReactDOM.render()
:
const newList = React.createElement(
'ul',
{ className: 'list' },
React.createElement(
'li',
{ className: 'list_item' },
'Первый элемент списка'
),
React.createElement('li', { className: 'list_item' }, 'Второй элемент списка')
)
const timerId = setTimeout(() => {
ReactDOM.render(newList, document.body)
clearTimeout(timerId)
}, 5000)
Поскольку React использует VDOM, даже несмотря на то, что мы повторно рендерим весь список, обновляются только фактически изменившиеся части.
Заключение
VDOM, определенно, заслуживает нашего внимания. Он предоставляет отличный способ отделения логики приложения от DOM-элементов, уменьшая вероятность непреднамеренного создания узких мест, связанных с манипуляцией DOM. Другие библиотеки так или иначе используют такой же подход, мы наблюдаем становление данной концепции в качестве предпочтительной стратегии разработки веб-приложений.
Подход, используемый Angular
, который является фреймворком, благодаря которому одностраничные приложения (single page applications, SPA) обрели столь широкую известность, называется Dirty Model Checking
(грязной проверкой моделей). Следует отметить, что DMC и VDOM не исключают друг друга. MVC-фреймворк вполне может использовать оба подхода. В случае с React это не имеет особого смысла, поскольку React — это, в конце концов, всего лишь библиотека для слоя представления (view).
Облачные VDS от Маклауд быстрые и безопасные.
Зарегистрируйтесь по ссылке выше или кликнув на баннер и получите 10% скидку на первый месяц аренды сервера любой конфигурации!