Virtual DOM - Max-Starling/Notes GitHub Wiki
- Virtual DOM это любой вид представления реального DOM (any kind of representation of a real DOM).
- Когда мы что-то изменяем в нашем виртуальном DOM-дереве, мы получаем новое виртуальное дерево. Алгоритм сравнивает старое и новое деревья, находит их различия и делает лишь необходимые изменения в реальном DOM. Что и отражает слово virtual.
Итак, для начала нам нужно как-то хранить исходное дерево DOM в памяти. Для примера:
<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 узлы-элементы как объекты. Будем представлять текстовые DOM-узлы как обычные JS-строки.
Для рассмотрения общего случая нам понадобится вспомогательная функция (helper):
const h = (type, props, ...children) => ({ type, props: props || {}, children });
С её помощью мы можем переписать код так:
h(
'ul',
{ 'class' : 'list' },
h('li', {}, 'item 1'),
h('li', {}, 'item 2'),
);
Уже лучше, но можно пойти дальше. Как работает JSX? Babel транслирует код (transpile) во что-то такое:
React.createElement(
‘ul’,
{ className: ‘list’ },
React.createElement(‘li’, {}, ‘item 1’),
React.createElement(‘li’, {}, ‘item 2’),
);
Что очень похоже на нашу вспомогательную функцию. Чтобы сделать что-то подобное, мы можем использовать jsx pragma. Для этого нужно просто над исходным HTML кодом оставить комментарий вида:
/* @jsx h */
В этом случае мы укажем Babel'ю, что нужно транслировать этот JSX, но вместо React.createElement() использовать нашу вспомогательную функцию h().
Таким образом код
/* @jsx h */
const d = (<div>Hello</div>);
транслируется Babel'ем в код:
const d = h('div', {});
А после выполнения вспомогательной функции получим простые JS объекты - наше представление Virtual DOM:
const d = ({ type: 'div', props: {}, children: [] });
У нас уже есть представление дерева DOM со своей структурой в виде объектов. Теперь нам нужно создать реальный DOM по этой структуре.
Cделаем несколько предположений и установим терминологию:
- Реальные DOM узлы (текстовые и элементы) будем обозначать со знаком $.
- Представление Virtual DOM будет располагаться в переменной node.
- Будет использоваться только один корневой компонент (root), как в React.
Напишем функцию createElement(node), принимающую узел Virtual DOM и возвращающую узел реального DOM:
const createElement = node => (typeof node === ‘string’)
? document.createTextNode(node)
: document.createElement(node.type);
Подумаем теперь о дочерних элементах(children). Так как они являются узлами, то их так же можно создать с помощью createElement(node). Получается рекурсия. Затем добавляем их с помощью appendChild. Таким образом к функция примет вид:
const createElement = node => {
if (typeof node === ‘string’) {
return document.createTextNode(node);
}
const $elem = document.createElement(node.type);
node.children
.map(createElement)
.forEach($elem.appendChild.bind($elem));
return $elem;
};
Сейчас мы можем превращать наш Virtual DOM в реальный DOM. Теперь построим алгоритм сравнения старого и нового виртуальных деревьев, а затем изменим необходимое в реальном DOM.
Могут быть следующие случаи:
- appendChild() - добавление узла. В старом дереве отсутствует узел, который есть в новом дереве на том же месте.
- removeChild() - удаление узла. В новом дереве отсутствует узел, который есть в старом дереве на том же месте.
- replaceChild() - замена узла. Узлы на одном и том же месте в старом и новом деревьях не совпадают.
- Ничего не делаем. Узлы совпадают. Идём искать дальше.
Напишем функцию, которая будет сравнивать два узла:
const isChanged = (nodeA, nodeB) =>
(typeof nodeA !== typeof nodeB) || // тип не совпадает (элемент и текст)
(typeof nodeA === ‘string’ && nodeA !== nodeB) || // текстовые узлы не совпадают
(nodeA.type !== nodeB.type) // тип элементов не совпадает
};
Теперь, воспользовавшись этой функцией, напишем функцию для сравнения, учитывающую все возможные случаи:
const updateElement = ($parent, newNode, oldNode, index = 0) => {
if (!oldNode) { // в старом дереве отсутствует узел -> добавляем
$parent.appendChild(createElement(newNode));
} else if (!newNode) { // в ноном дереве отсутствует узел -> удаляем по индексу
$parent.removeChild($parent.childNodes[index]); // по-умолчанию индекс = 0
} else if (isChanged(newNode, oldNode)) { // если узлы не совпадают, то заменяем старый узел на новый
$parent.replaceChild(createElement(newNode), $parent.childNodes[index]);
}
};
Осталось рассмотреть один случай, когда дочерние узлы элементов не совпадают. Здесь мы должны сравнить дочерние узлы и изменить при необходимости. По сути, нужно вызвать updateElement() для каждого из них, что снова пораждает рекурсию.
//...
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,
);
}
}
Чтобы установить props, достаточно взять атрибуры и их значения из JSX и записать их в нашу структуру как поля объекта props.
Учтём несколько моментов:
- Слово class зарезервировано в JS, поэтому мы не можем его использовать как название свойства и заменим его на className. Нужно так же учесть, что в реальном DOM нет атрибута className.
<div className="block" />
- Для атрибутов с булевыми значениями по типу
checked={true}, disabled
удобнее было бы написать отдельную функцию:
const setBooleanProp = ($target, name, value) => {
if (value) {
$target.setAttribute(name, value);
$target[name] = true;
} else {
$target[name] = false;
}
};
- Должна быть возможность добавлять свои свойства (custom properties) и обрабатывать их. Но пока это не требуется и функция может быть:
const isCustomProp = name => false;
С учётом всего этого случая можем написать функцию, которая устанавливает атрибуты узлу реального DOM:
const setProp = ($target, name, value) => {
if (isCustomProp(name)) {
return;
} else if (name === ‘className’) {
$target.setAttribute(‘class’, value);
} else if (typeof value === ‘boolean’) {
setBooleanProp($target, name, value);
} else {
$target.setAttribute(name, value);
}
};
Теперь мы можем написать функцию, устанавливающую все props элемента:
const setProps = ($target, props) => {
Object.keys(props).forEach(name => {
setProp($target, name, props[name]);
});
};
Наконец, изменим ранее написанную функцию createElement(node), добавив в неё установку props:
// ...
const $el = document.createElement(node.type);
setProps($el, node.props);
// ...
На данный момент у нас есть функции для установки props, но так же могут понадобиться функции для их удаления:
const removeBooleanProp = ($target, name) => {
$target.removeAttribute(name);
$target[name] = false;
};
const removeProp = ($target, name, value) => {
if (isCustomProp(name)) {
return;
} else if (name === ‘className’) {
$target.removeAttribute(‘class’);
} else if (typeof value === ‘boolean’) {
removeBooleanProp($target, name);
} else {
$target.removeAttribute(name);
}
};
Теперь можно написать функцию обновнения prop, которая будет сравнивать два свойства - старое и новое, и изменять элемент в реальном DOM по необходимости.
Возможны случаи:
- Свойство с таким именем отсутствует в старом узле - добавляем.
- Свойство с таким именем отсуствует в новом узле - удаляем.
- Свойство с таким именем присутствует в обоих узлах - устанавливаем значение нового узла.
- Свойство и его значение совпадает в обоих узлах - ничего не делаем.
Тогда функция будет иметь вид:
const updateProp = ($target, name, newValue, oldValue) => {
if (!newValue) {
removeProp($target, name, oldValue);
} else if (!oldValue || newValue !== oldValue) {
setProp($target, name, newValue);
}
};
Тогда для обновления всех props можем написать:
const updateProps = ($target, newProps, oldProps = {}) => {
const props = { ...oldProps, ...newProps };
Object.keys(props).forEach(name => {
updateProp($target, name, newProps[name], oldProps[name]);
});
};
Изменим теперь ранее написанную функцию updateElement(...), добавив в неё установку props:
// ...
} else if (newNode.type) {
updateProps(
$parent.childNodes[index],
newNode.props,
oldNode.props
);
// ...
Попробуем реализовать обработку событий как в реакт:
<button onClick={() => console.log('qq')} />
Напишем функцию, которая позволяет выделять события из других props:
const isEventProp = name => /^on/.test(name);
Напишем функцию, которая удаляет префикс 'on':
const extractEventName = name => name.slice(2).toLowerCase();
Поместим в ранее написанную пустую функцию для пользовательских свойств:
const isCustomProp = name => isEventProp(name);
Теперь можем написать функцию для добавления обработчиков событий:
const addEventListeners = ($target, props) => {
Object.keys(props).forEach(name => {
if (isEventProp(name)) {
$target.addEventListener(
extractEventName(name),
props[name],
);
}
});
};
Вызовем её при создании элемента после установки props:
// ...
const $el = document.createElement(node.type);
setProps($el, node.props);
addEventListeners($el, node.props);
// ...
При такой реализации события будут установлены только один раз: при создании элемента. Чтобы обновить их, создадим новое пользовательское свйоство forceUpdate. Тогда к функции, проверяющей все изменения, добавится одна строка:
const isChanged = (nodeA, nodeB) =>
(typeof nodeA !== typeof nodeB) ||
(typeof nodeA === ‘string’ && nodeA !== nodeB) ||
(nodeA.type !== nodeB.type) ||
nodeA.props.forceUpdate // новое свойство
};
Если это свойство изменяется, то узел будет полностью пересоздан (в том числе и все его события).
Так же мы не хотим, чтобы это свойство попало в реальный DOM, поэтому добавим ещё одну проверку:
const isCustomProp = name => isEventProp(name) || name === 'forceUpdate';