Перевод статьи deathmood: How to write your own Virtual DOM
Есть две вещи, которые вам необходимо знать для написания вашего собственного виртуального DOM. Вам даже не нужно погружаться в исходный код React или в исходный код любой другой реализации виртуального DOM. Они такие большие и комплексные, но в действительности основная часть виртуального DOM может быть написана меньше чем за 50 строк кода. 50. Строк. Кода. !!!
Здесь заложено две концепции:
- Виртуальный DOM - любое представление настоящего DOM
- Когда мы что-то меняем в нашем виртуальном DOM дереве, мы создаём новое виртуальное дерево. Алгоритм сравнивает эти два дерева (старое и новое), находит разницу и вносит только необходимые минимальные изменения в настоящий DOM, чтобы он соответствовал виртуальному.
Это все! Давайте углубимся в каждую из этих концепций.
Хорошо, в первую очередь нам нужно как-то хранить наше DOM дерево в памяти. И мы можем сделать это с помощью простых JS объектов. Предположим, у нас есть это дерево:
<ul class="list">
<li>item 1</li>
<li>item 2</li>
</ul>
Выглядит просто, не так ли? Как мы можем это представить с помощью простых JS объектов:
{
type: 'ul', props: { 'class': 'list' }, children: [
{ type: 'li', props: {}, children: ['item 1'] },
{ type: 'li', props: {}, children: ['item 2'] }
]
}
Здесь вы можете заметить две вещи:
- Мы представляем DOM элементы в объектом виде
{ type: '…', props: { … }, children: [ … ] }
- Мы описываем текстовые ноды простыми JS строками
Но писать большие деревья таким способом довольно сложно. Так давайте напишем вспомогательную функцию, чтобы нам стало проще понимать структуру:
function h(type, props, …children) {
return { type, props, children };
}
Теперь мы можем описывать наше DOM дерево в таком виде:
h('ul', { class: 'list' },
h('li', {}, 'item 1'),
h('li', {}, 'item 2'),
);
Выглядит намного чище, не правда ли? Но мы даже можем пойти дальше. Вы ведь слышали про JSX, не так ли? Да, я хотел бы применить его идеи тут. Так как же он работает?
Если вы читали официальную документацию Babel к JSX здесь, вы знаете, что Babel транспилирует этот код:
<ul className=”list”>
<li>item 1</li>
<li>item 2</li>
</ul>
во что-то вроде этого:
React.createElement('ul', { className: 'list' },
React.createElement('li', {}, 'item 1'),
React.createElement('li', {}, 'item 2'),
);
Заметили сходство? Да, да... Если бы мы только могли просто заменить вызов React.createElement(...)
на наш h(...)
... Оказывается, мы можем, используя jsx pragma. Нам только требуется вставить похожую на комментарий строчку в начале нашего файла с исходным кодом.
/** @jsx helper */
<ul className=”list”>
<li>item 1</li>
<li>item 2</li>
</ul>
Хорошо, на самом деле она сообщает Babel: «Эй, скомпилируй этот jsx, но вместо React.createElement
, подставь h
». Здесь вы можете подставить что угодно вместо h
. И это будет скомпилировано.
Итак, мы будем писать наш DOM таким образом:
/** @jsx h */
const a = (
<ul className=”list”>
<li>item 1</li>
<li>item 2</li>
</ul>
);
И это будет скомпилировано Babel в такой код:
const a = (
h(‘ul’, { className: ‘list’ },
h(‘li’, {}, ‘item 1’),
h(‘li’, {}, ‘item 2’),
);
);
Выполнение функции h
вернет простой JS объект - наше представление виртуального DOM.
const a = (
{ type: ‘ul’, props: { className: ‘list’ }, children: [
{ type: ‘li’, props: {}, children: [‘item 1’] },
{ type: ‘li’, props: {}, children: [‘item 2’] }
] }
);
Попробуйте сами на JSFiddle (не забудьте указать Babel в качестве языка).
Хорошо, теперь мы имеем представление нашего DOM дерева в JS объекте, с нашей собственной структурой. Это круто, но нам нужно как-то создать настоящий DOM из него. Конечно, мы не можем просто применить наше представление к DOM.
Прежде всего давайте установим некоторые допущения и определимся с терминологией:
- Я буду писать все переменные, хранящие настоящие DOM ноды (элементы, текстовые ноды), начиная с
$
, таким образом $parent будет настоящим DOM элементом - Виртуальное DOM представление будет храниться в переменной с именем node
- Как и в React, у вас может быть только одна корневая нода, все остальные ноды будут внутри
Теперь, когда мы со всем этим разобрались, давайте напишем функцию createElement(…), которая возьмет виртуальную DOM ноду и вернет настоящую DOM ноду. Забудьте пока о props
и children
, мы займемся ими позже:
function createElement(node) {
if (typeof node === ‘string’) {
return document.createTextNode(node);
}
return document.createElement(node.type);
}
Содержание функции такое, потому что у нас могут быть и текстовые ноды, являющиеся простыми JS строками, и элементы, представляющие собой JS объекты такого вида:
{ type: ‘…’, props: { … }, children: [ … ] }
Таким образом, мы можем передавать в неё как виртуальные текстовые ноды, так и ноды виртуальных элементов, и это будет работать.
Теперь давайте подумаем о детях: каждый из них также является текстовой нодой или элементом. Поэтому они также могут быть созданы с помощью нашей функции createElement(...). Вы чувствуете это? Попахивает рекурсией :)) Итак, мы можем вызвать createElement(...) для каждого из дочерних элементов, а затем appendChild() их в наш элемент следующим образом:
function createElement(node) {
if (typeof node === ‘string’) {
return document.createTextNode(node);
}
const $el = document.createElement(node.type);
node.children
.map(createElement)
.forEach($el.appendChild.bind($el));
return $el;
}
Вау, выглядит отлично. Давайте пока оставим в стороне props ноды. Мы поговорим о них позже. Они не нужны нам для базового понимания концепций виртуального DOM и только добавят сложности.
Давайте пойдем дальше и попробуем это в JSFiddle.
Хорошо, теперь мы можем превратить наш виртуальный DOM в настоящий DOM, пора подумать про сравнение наших виртуальных деревьев. Нам нужно написать алгоритм, который будет сравнивать два виртуальных дерева — старое и новое — и делать только необходимые изменения в настоящем DOM.
Как сравнить деревья? Нам необходимо обработать следующие ситуации:
- Если в определенном месте нет старой ноды: нода была добавлена и нам необходимо использовать appendChild(…)
- Нет новой ноды в определенном месте: эта нода была удалена и нам нужно использовать removeChild(…)
- Другая нода в этом месте: нода изменилась и нам нужно использовать replaceChild(…)
- Ноды те же: нам нужно идти глубже и сравнивать дочерние ноды
Хорошо, давайте напишем функцию updateElement(...), принимающую три параметра: $parent, newNode и oldNode. $parent - родительский элемент реального DOM нашей виртуальной ноды. Сейчас мы увидим, как обработать все ситуации, описанные выше.
Хорошо, это довольно просто, я даже не буду комментировать:
function updateElement($parent, newNode, oldNode) {
if (!oldNode) {
$parent.appendChild(
createElement(newNode)
);
}
}
Тут у нас появляется проблема: если нет ноды в определенном месте в новом виртуальном дереве, мы должны удалить её из реального DOM. Но как это сделать? Мы знаем родительский элемент (он передаётся в функцию) и поэтому мы должны вызывать $parent.removeChild(...) и передавать ссылку на реальный элемент DOM. Но у нас его нет. Если бы мы знали положение нашей ноды в родителе, мы могли бы получить его ссылку с помощью $parent.childNodes[index], где index - позиция нашей ноды в родительском элементе.
Хорошо, давайте предположим, что этот index будет передаваться нашей функции (и это действительно произойдет, как вы увидите позже), тогда наш код будет таким:
function updateElement($parent, newNode, oldNode, index = 0) {
if (!oldNode) {
$parent.appendChild(
createElement(newNode)
);
} else if (!newNode) {
$parent.removeChild(
$parent.childNodes[index]
);
}
}
Прежде всего нам необходимо написать функцию, сравнивающую две ноды и сообщающую нам, если узел действительно изменился. Мы должны учитывать, что это могут быть как элементы, так и текстовые ноды:
function changed(node1, node2) {
return typeof node1 !== typeof node2 ||
typeof node1 === ‘string’ && node1 !== node2 ||
node1.type !== node2.type
}
И теперь, имея index текущей ноды родителя, мы можем легко заменить её на вновь созданную ноду:
function updateElement($parent, newNode, oldNode, index = 0) {
if (!oldNode) {
$parent.appendChild(
createElement(newNode)
);
} else if (!newNode) {
$parent.removeChild(
$parent.childNodes[index]
);
} else if (changed(newNode, oldNode)) {
$parent.replaceChild(
createElement(newNode),
$parent.childNodes[index]
);
}
}
И последнее, но не менее важное: мы должны пройти каждый дочерний элемент на обоих нодах, сравнивать их и вызвать updateElement(…) для каждого из них. Да, снова рекурсия.
Но есть несколько вещей, требующие рассмотрения перед написанием кода:
- Мы должны сравнивать детей, если нода является элементом (текстовые ноды не могут иметь детей)
- Сейчас мы передаём ссылку на текущую ноду как на родителя
- Мы должны сравнить всех детей один за одним, даже если в какой-то момент мы получим
undefined
(это нормально, наша функция умеет обрабатывать это) - И, наконец, index - это просто указатель на дочернюю ноду в массиве
children
function updateElement($parent, newNode, oldNode, index = 0) {
if (!oldNode) {
$parent.appendChild(
createElement(newNode)
);
} else if (!newNode) {
$parent.removeChild(
$parent.childNodes[index]
);
} else if (changed(newNode, oldNode)) {
$parent.replaceChild(
createElement(newNode),
$parent.childNodes[index]
);
} else if (newNode.type) {
const newLength = newNode.children.length;
const oldLength = oldNode.children.length;
for (let i = 0; i < newLength || i < oldLength; i++) {
updateElement(
$parent.childNodes[index],
newNode.children[i],
oldNode.children[i],
i
);
}
}
}
Да, это оно. Мы добрались. Я поместил весь код в JSFiddle и реализация действительно составляет 50 LOC, как я вам и обещал. Можете пойти и поиграть с ним.
Откройте инструменты разработчика и посмотрите, как происходят изменения при нажатии кнопки Reload
.
Поздравляю! Мы сделали это. Мы написали свою реализацию виртуального DOM. И она работает. Я надеюсь, что, прочитав эту статью, вы поняли основные концепции и то, как виртуальный DOM должен работать и как React работает под капотом.
Однако, есть вещи, которые мы не осветили здесь (я постараюсь раскрыть их в будущих статьях):
- Установка атрибутов элементов и их сравнение и обновление
- Добавление обработчиков событий для наших элементов
- Возможность нашего виртуального DOM работать с компонентами, как React
- Получение ссылок на реальные DOM ноды
- Использование виртуального DOM с библиотеками, которые непосредственно мутируют настоящий DOM: jQuery и её плагины
- И многое другое…
Вторая статья о работе с атрибутами и событиями в виртуальном DOM здесь.
Читайте нас на Медиуме, контрибьютьте на Гитхабе, общайтесь в группе Телеграма, следите в Твиттере и канале Телеграма, рекомендуйте в VK и Facebook. Скоро подъедет подкаст, не теряйтесь.