Skip to content

Latest commit

 

History

History

how-to-write-your-own-virtual-dom

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 

Как написать ваш собственный виртуальный DOM

Перевод статьи deathmood: How to write your own Virtual DOM

Есть две вещи, которые вам необходимо знать для написания вашего собственного виртуального DOM. Вам даже не нужно погружаться в исходный код React или в исходный код любой другой реализации виртуального DOM. Они такие большие и комплексные, но в действительности основная часть виртуального DOM может быть написана меньше чем за 50 строк кода. 50. Строк. Кода. !!!

Здесь заложено две концепции:

  • Виртуальный DOM - любое представление настоящего 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

Хорошо, теперь мы имеем представление нашего 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(…)

appendChild

  • Нет новой ноды в определенном месте: эта нода была удалена и нам нужно использовать removeChild(…)

removeChild

  • Другая нода в этом месте: нода изменилась и нам нужно использовать replaceChild(…)

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. reload

Заключение

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

Однако, есть вещи, которые мы не осветили здесь (я постараюсь раскрыть их в будущих статьях):

  • Установка атрибутов элементов и их сравнение и обновление
  • Добавление обработчиков событий для наших элементов
  • Возможность нашего виртуального DOM работать с компонентами, как React
  • Получение ссылок на реальные DOM ноды
  • Использование виртуального DOM с библиотеками, которые непосредственно мутируют настоящий DOM: jQuery и её плагины
  • И многое другое…

Вторая статья о работе с атрибутами и событиями в виртуальном DOM здесь.


Читайте нас на Медиуме, контрибьютьте на Гитхабе, общайтесь в группе Телеграма, следите в Твиттере и канале Телеграма, рекомендуйте в VK и Facebook. Скоро подъедет подкаст, не теряйтесь.

Ссылка на medium