Virtual DOM - Max-Starling/Notes GitHub Wiki

Как написать свой Virtual DOM

Основные концепции

  • Virtual DOM это любой вид представления реального DOM (any kind of representation of a real DOM).
  • Когда мы что-то изменяем в нашем виртуальном DOM-дереве, мы получаем новое виртуальное дерево. Алгоритм сравнивает старое и новое деревья, находит их различия и делает лишь необходимые изменения в реальном DOM. Что и отражает слово virtual.

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

Итак, для начала нам нужно как-то хранить исходное дерево 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 со своей структурой в виде объектов. Теперь нам нужно создать реальный 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

Чтобы установить 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);
 // ...

Сравнение (diffing) 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';
⚠️ **GitHub.com Fallback** ⚠️