Как написать Virtual Dom

Есть 2 вещи, которые необходимо знать. Вам не нужно погружаться в исходный код React’а или других библиотек, они довольно большие и сложные, на самом деле Virtual DOM может быть написан в меньше чем 50 строк кода.

  1. Virtual DOM это аналог настоящего DOM
  2. Когда мы меняем что-то в дереве Virtual DOM , мы создаем новый Virtual DOM. Алгоритм сравнения двух деревьев вычисляет разницу и вносит только необходимые, минимальные правки в  настоящий DOM.

Представление нашего DOM дерева

Ну, во-первых, нам нужно как-то хранить наше DOM дерево в памяти. И мы можем сделать это с помощью обычных JS объектов. Предположим, у нас есть это дерево:

  • item 1
  • item 2


Как это будет выглядеть в JS:

{ type: ‘ul’, props: { ‘class’: ‘list’ }, children: [
{ type: ‘li’, props: {}, children: [‘item 1’] },
{ type: ‘li’, props: {}, children: [‘item 2’] }
] }

Здесь вы можете заметить две вещи:

  • Мы описываем DOM элементы как JS объекты

  • { type: ‘…’, props: { … }, children: [ … ] }

  • Мы описываем текстовые узлы как JS строки

Но писать большие деревья таким способом довольно сложно. Так давайте напишем вспомогательную функцию, которая упростит понимание структуры:

function helper(type, props, …children) {
return { type, props, children };
}

Теперь деревья можно создавать так:

helper('ul', { class: 'list' },
helper('li', {}, 'item 1'),
helper('li', {}, 'item 2'),
);

Стало лучше? Мы можем пойти дальше. Вы ведь слышали про JSX, не так ли?
Если вы читали документацию babel тут, вы должны знать, что данный код:

  • item 1
  • item 2


будет скомпилирован в

React.createElement(‘ul’, { className: ‘list’ },
React.createElement(‘li’, {}, ‘item 1’),
React.createElement(‘li’, {}, ‘item 2’),
);

Заметили сходство с нашей функцией? Мы можем заменить React.createElement на helper, используя комментарий из документации:


/** @jsx helper */

  • item 1
  • item 2


Так мы заставим babel заменить React.createElement на наш helper.

Применение нашего DOM дерева

Теперь, когда мы имеем DOM представление в обычном JS объекте, имеющем свою собственную структуру, нам нужно создать механизм, который сможет строить из нее настоящий DOM.
Сначала давайте сделаем некоторые предположения и введем терминологию:

  • Названия переменных с реальными DOM элементами будут начинаться с символа — $.
  • Virtual DOM будет храниться в переменной с именем node.
  • Аналогично React все узлы будут хранится в корневом элементе

Теперь напишем функцию, которая вернет DOM элемент, пока без «props» и «children»:

function createElement(node) {
const isString = typeof node === 'string';
return isString && document.createTextNode(node)
|| document.createElement(node.type);
}

Так мы можем создавать текстовые узлы и элементы из JS объектов. Теперь используем нашу функцию для создания дочерних элементов, при помощи рекурсии 🙂


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» пока что опустим, чтобы не усложнять.

Обработка изменений

Ок, теперь мы можем превратить наш виртуальный дом в настоящий дом, пора подумать про сравние наших виртуальных деревьев. Нам нужно написать алгоритм, который будет сравнивать два виртуальных дерева — старое и новое и делать только необходимые изменения в настоящем доме.

Для получения нового дерева нам нужно написать класс, предоставляющий слудующие методы:

  • appendChild(…)
  • removeChild(…)
  • replaceChild(…)
  • Метод который будет «заглядывать внутрь», в случае если узлы одинаковые

Ок, давайте напишем функцию updateElement(), которая принимает 3 параметра: $parent, oldNode, newNode. Где $parent элемент реального DOM узла родительского блока. Ниже рассмотрим все случаи обработки узлов.

Если нет oldNode

Все довольно просто:

function updateElement($parent, newNode, oldNode) {
if (!oldNode) {
$parent.appendChild(
createElement(newNode)
);
}
}

Если нет нового узла (newNode)

Тут проблема — если нет узла для текущего места, мы должны удалить это из реального DOM. Мы также можем передавать позицию узла для удаления

function updateElement($parent, newNode, oldNode, index = 0) {
if (!oldNode) {
$parent.appendChild(
createElement(newNode)
);
} else if (!newNode) {
$parent.removeChild(
$parent.childNodes[index]
);
}
}

Измененный узел

Напишем функцию, которая сравнивает 2 узла. Мы должны учитывать, что это могут быть как элементы так и текстовые узлы:


function changed(node1, node2) {
return typeof node1 !== typeof node2 ||
typeof node1 === ‘string’ && node1 !== node2 ||
node1.type !== node2.type
}

И теперь, имея индекс текущего узла родителя, мы можем легко заменить его на вновь созданный узел:

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(…) для каждого из них. Да, рекурсия снова.


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 ); } } }

Собираем все вместе

Как я и обещал < 50 строк кода
function helper(type, props, ...children) {
return { type, props, children };
}

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;
}

function changed(node1, node2) {
return typeof node1 !== typeof node2 ||
typeof node1 === 'string' && node1 !== node2 ||
node1.type !== node2.type
}

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 ); } } } // --------------------------------------------------------------------- const a = (

  • item 1
  • item 2

);

const b = (

  • item 1
  • hello!

);

const $root = document.getElementById('root');
const $reload = document.getElementById('reload');

updateElement($root, a);
$reload.addEventListener('click', () => {
updateElement($root, b, a);
});

Вывод

Поздравляю! Мы сделали это. Мы написали реализацию Virtual DOM. И это работает. Я надеюсь, что прочитав эту статью, вы поняли основные понятия и то, как Virtual DOM должен работать под капотом.

Однако, есть вещи, которые мы пропустили:

  • Установка атрибутов, сравнение и замена
  • Обработчики событий для наших элементов
  • Создание компонент аналогичных React
  • Получение ссылок на реальный дом узлы
  • Использование Virtual DOM с библиотеками, которые непосредственно мутируют настоящий дом — такие, как jQuery и ее плагины.
  • И много другое…

Если есть какие-либо ошибки в коде или статье, если возможны какие-нибудь оптимизации кода — не стесняйтесь выражать их в комментариях :)) и извините за мой Русский.

Оригинальный текст на Англйском

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *