Как написать свой Virtual DOM
Всем привет! Сегодня нас ждет удивительное приключение реализации виртуального DOM-a с нуля.
Материал получился большим, чтение у вас может занять до получаса, имейте это ввиду. А если у вас нет столько времени, то вы можете сразу посмотреть результат в песочнице codesandbox или на гитхабе.
Быстрые переходы по частям:
- Что такое Virtual DOM?
- Реализуем метод createVNode
- Создаем реальный DOM-узлы
- Монтируем DOM-узел в DOM-дерево
- Сравнение виртуальных нод
- Сравнение атрибутов
- Сравнение дочерних нод
- Собираем все вместе
- Обработчики событий
- Бонус: интегрируем JSX
Что такое Virtual DOM? #
Если речь заходит про виртуальный DOM, то почти всегда дело касается React-а. Но на самом деле это общая концепция, используемая и за пределами мира React-а.
Виртуальный DOM (VDOM) - это представление пользовательского интерфейса в памяти (например, в JS переменной), по которому можно сформировать "настоящий" DOM. Виртуальный DOM отвечает не только представление в памяти, но и за синхронизацию с реальным DOM.
Например, у нас есть следующий код:
<div id="app">
Hello world
</div>
Виртуальное представление может выглядеть следующим образом:
const vNode = {
tagName: "div",
props: {
id: "app"
},
children: [
"Hello world"
],
}
Такой паттерн позволяет описывать интерфейсы в декларативном подходе, абстрагирует нас от прямой работы с DOM, обработчиками событий и атрибутов, и самое важное: оптимизирует работу обновления только нужных частей DOM-дерева (отвечает за синхронизацию с реальным DOM).
На медиуме есть классный пост, как работает VDOM в React-е, а мы реализуем его сами (конечно очень упрощенную, но рабочую версию). Приступим!
createVNode(tagName, props, children) #
В первом разделе реализуем метод, который будет создавать виртуальный элемент (узел или ноду, все это синонимы, далее по тексту они будут использоваться в одном контексте). В большинстве реализаций виртуального DOM-а функцию создания виртуальных элементов называют либо createElement
, либо сокращенно h
. Но мы назовем ее с ключевым словом vNode, чтобы явно указать на работу с виртуальным деревом.
Реализация этого метода довольна простая, поэтому приступим:
export const createVNode = (tagName, props = {}, children = []) => {
return {
tagName,
props,
children,
};
};
Метод принимает три параметра:
tagName
- имя тега (например,div
илиspan
);props
- свойства узла (например, атрибуты -class
илиid
);children
- дочерние элементы.
Формат этой функции и названия полей мы задаем сами, как захотим. Свойство props
может называться attrs
, а tagName
- type
; Смотрите на виртуальный DOM как на концепцию без четкого интерфейса реализации.
Для свойств и детей используем значения по умолчанию, чтобы при вызове createVNode('h1')
быть уверенным, что у созданной виртуальной ноды будут проставлены как свойства, так и дети, и избегать этих проверок в дальнейшем.
Напишем наш первый виртуальный элемент:
import { createVNode } from './vdom';
const vNode = createVNode('div', {
class: 'container'
});
console.log(vNode);
В результате мы получим следующий объект:
Object
tagName: "div"
props: Object
class: "container"
children: Array[0]
Придумаем что-нибудь посложнее:
import { createVNode } from './vdom';
const vNode = createVNode('div', { class: 'container' }, [
createVNode('h1', {}, ['Hello, Virtual DOM']),
createVNode('img', { src: 'https://i.ibb.co/M6LdN5m/2.png', width: 200 }),
]);
console.log(vNode);
И посмотрим на вывод в терминале:
Object
tagName: "div"
props: Object
class: "container"
children: Array[2]
0: Object
tagName: "h1"
props: Object
children: Array[1]
0: "Hello, Virtual DOM"
1: Object
tagName: "img"
props: Object
src: "https://i.ibb.co/M6LdN5m/2.png"
width: 200
children: Array[0]
Мы только что научились строить виртуальное дерево или виртуальный DOM, поздравляю! Но это только самое начало. Далее мы будет из этого дерева получать реальные DOM элементы.
createNode(vNode) #
Теперь из виртуального дерева необходимо сформировать DOM узел. Рассмотрим реализацию ниже:
export const createDOMNode = vNode => {
const { tagName, props, children } = vNode;
// создаем DOM-узел
const node = document.createElement(tagName);
// Добавляем атрибуты к DOM-узлу
Object.entries(props).forEach(([key, value]) => {
node.setAttribute(key, value);
});
// Рекурсивно обрабатываем дочерные узлы
children.forEach(child => {
node.appendChild(createDOMNode(child));
});
return node;
};
Вы можете заметить, что рекурсия не лучшее решение, особенно для обхода DOM-узлов, вложенность которых может быть очень глубокой, и лучше использовать стек (чтобы не словить ошибку при достижении лимитов рекурсии). И вы будете правы, но для простоты реализации оставим именно этот вариант.
В DOM существуют целых семь типов элементов (выше мы обработали самый используемый тип - ElementNode). Обработаем еще один тип - TextNode (простой текст). В нашем виртуальном дереве это будет выглядеть следующим образом:
const vNode = createVNode('div', { class: 'container' }, [
createVNode('h1', {}, ['Hello, Virtual DOM']),
'Text node without tags', // <- TextNode
createVNode('img', { src: 'https://i.ibb.co/M6LdN5m/2.png', width: 200 }),
]);
У текстовых нод нет ни аттрибутов, ни детей, поэтому в виртуальном DOM-е их можно хранить в виде обычных строк. Добавим в метод createDOMNode условие на обработку текстовых узлов - они будут создаваться с помощью метода document.createTextNode, если виртуальная нода представлена строкой.
export const createDOMNode = vNode => {
if (typeof vNode === "string") {
return document.createTextNode(vNode);
}
const { tagName, props, children } = vNode;
// ...
}
Попробуем метод в действии:
import { createVNode, createDOMNode } from './vdom';
const vNode = createVNode('div', { class: 'container' }, [
createVNode('h1', {}, ['Hello, Virtual DOM']),
'Text node without tags',
createVNode('img', { src: 'https://i.ibb.co/M6LdN5m/2.png', width: 200 }),
]);
const node = createDOMNode(vNode);
console.log(node);
После выполнения кода в терминале будет отображено полученное DOM-дерево:
<div class="container">
<h1>Hello, Virtual DOM</h1>
Text node without tags
<img src="https://i.ibb.co/M6LdN5m/2.png" width="200"></img>
</div>
Очередной шаг завершен. Мы научились строить виртуальное DOM-дерево и формировать из него реальное. Теперь нужно отобразить результат в DOM страницы.
mount(node, target) #
Нам понадобится index.html страница, на которую и будем монтировать полученное DOM-дерево:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Virtual DOM</title>
</head>
<body>
<div id="app"></div>
<script src="./app.js"></script>
</body>
</html>
В элемент с id=app
необходимо вставить полученное DOM-дерево. Способов сделать это не мало, например с помощью innerHTML
отчистить содержимое родительской ноды и добавить в нее нужный элемент, используя appendChild
. Мы же сделаем еще проще и заменим текущую DOM-ноду на переданную:
export const mount = (node, target) => {
target.replaceWith(node);
return node;
};
У выбранного способа есть проблема - если с сервера к нам приходит уже готовый HTML (сервер сайд рендеринг), то лишний раз полностью перерисовывается DOM. Ближе к концу материала мы исправим это.
Используем метод mount
в нашем приложении и посмотрим на результат в браузере:
import { mount, createVNode, createDOMNode } from "./vdom";
const vNode = createVNode("div", { class: "container" }, [
createVNode("h1", {}, ["Hello, Virtual DOM"]),
"Text node without tags",
createVNode("img", { src: "https://i.ibb.co/M6LdN5m/2.png", width: 200 })
]);
const app = document.getElementById("app");
mount(createDOMNode(vNode), app);
Состояние #
Усложним пример и добавим на страницу состояние. Конечно же, что может быть лучше старого доброго счетчика, который мы и добавим. Будем обновлять каждую секунду счетчик (с помощью setInterval
) и перерисовывать DOM-дерево.
Виртуальное дерево перенесем в функцию createVApp
, которая будет принимать наше состояние (обычный объект state = { count }
) и использовать его в двух местах (просто выводить на странице и записываться в data-атрибут корневого элемента):
import { mount, createVNode, createDOMNode } from "./vdom";
const createVApp = state => {
const { count } = state;
return createVNode("div", { class: "container", "data-count": count }, [ // <- тут
createVNode("h1", {}, ["Hello, Virtual DOM"]),
createVNode("div", {}, [`Count: ${count}`]), // <- и тут
"Text node without tags",
createVNode("img", { src: "https://i.ibb.co/M6LdN5m/2.png", width: 200 })
]);
};
// ...
И добавим код с интервалом и перерендером:
// ...
const state = { count: 0 };
const app = document.getElementById("app");
mount(createDOMNode(createVApp(state)), app);
setInterval(() => {
state.count++;
mount(createDOMNode(createVApp(state)), app);
}, 1000);
Вернувшись в браузер, мы увидим, что счетчик обновляется, как мы и планировали! На данном этапе мы можем писать приложения в декларативном стиле, что интуитивно понятно и просто.
Но есть и несколько серьезных проблем:
- Сбрасываются состояния (например, текст или фокус в инпуте) и обработчики событий после каждого ререндера (которых у нас сейчас нет, но это так);
- На каждое обновление счетчика постоянно перерисовывается DOM-дерево. А его перерисовка - достаточно тяжелая операция (одна из основных причин, зачем нужен VDOM);
Чтобы увидеть реальные перерисовки браузера, можно включить в devtools браузера вкладку Rendering и поставить галку у пункта "Paint flashing" (области, которые браузер перерисовывает, будут подсвечиваться зеленым цветом):
И посмотрим на результат:
Браузер перерисовывает все элементы, несмотря на то, что часть из них не меняется.
Для решения проблемы нам нужно не полностью заменять ноду, а обновлять (патчить) ее атрибуты и детей. Сделать это можно как раз с помощью виртуальных элементов - сравнив два элемента, можно найти отличия и точечно сделать изменения в реальном DOM.
patchNode(node, vNode, nextVNode) #
Мы подошли к самому интересному - сравнение виртуальных нод и применение изменений к реальной DOM-ноде. Идея в следующем:
- Строится виртуальное дерево, по которому в свою очередь строится реальное и монтируется в DOM;
- Как только меняется состояние (например, счетчик увеличивается), то строится новое виртуальное дерево и сравнивается с предыдущим;
- Все найденные отличия патчатся в реальный DOM-узел;
- Текущий виртуальный DOM заменяем новым.
Далее в коде переменные, содержащие новое виртуальное дерево, его дочерние ноды или атрибуты, будут начинаться со слова
next
, помогая нам понять, что это именно следующее значение, которое нужно сравнивать с текущим.
Выглядеть это будет следующим образом:
// ...
const state = { count: 0 };
let vApp = createVApp(state);
let rootNode = mount(createDOMNode(vApp), document.getElementById("app"));
setInterval(() => {
state.count++;
// Формируем новое виртуальное дерево
const nextVApp = createVApp(state);
// Применяем изменения к DOM-ноде
rootNode = patchNode(rootNode, vApp, nextVApp);
// Текущее виртуальное дерево заменяем новым
vApp = nextVApp;
}, 1000);
Приступим к реализации метода patchNode
. Начнем с описания кейсов, которые нужно обработать при сравнении элементов. Первое, что приходит в голову, это изменение атрибутов и дочерних узлов. Все верно, но эти пункты мы рассмотрим далее, а пока опишем возможные случаи с самим элементом:
- Если
nextVNode
равенundefined
, то реальную ноду нужно удалить; - Если хотя бы одно из значений
vNode
иnextVNode
равно строке (не виртуальный элемент) и они не равны друг другу (например,vNode
это строка, аnextVNode
- нет, наоборот или два значения - это строки, которые не равны), то можно просто создать новую DOM-ноду с помощьюcreateNode
и заменить ей текущую с помощьюnode.replaceWith
; - Если
vNode.tagName
, не равенnextVNode.tagName
, то предположим, что это новый элемент и просто создадим новую DOM-ноду, заменив текущую; - Если у элементов одинаковый тег, то нужно сравнить дочерние узлы и атрибуты.
export const patchNode = (node, vNode, nextVNode) => {
// Удаляем ноду, если значение nextVNode не задано
if (nextVNode === undefined) {
node.remove();
return;
}
if (typeof vNode === "string" || typeof nextVNode === "string") {
// Заменяем ноду на новую, если как минимум одно из значений равно строке
// и эти значения не равны друг другу
if (vNode !== nextVNode) {
const nextNode = createDOMNode(nextVNode);
node.replaceWith(nextNode);
return nextNode;
}
// Если два значения - это строки и они равны,
// просто возвращаем текущую ноду
return node;
}
// Заменяем ноду на новую, если теги не равны
if (vNode.tagName !== nextVNode.tagName) {
const nextNode = createDOMNode(nextVNode);
node.replaceWith(nextNode);
return nextNode;
}
// Патчим свойства (реализация будет далее)
patchProps(node, vNode.props, nextVNode.props);
// Патчим детей (реализация будет далее)
patchChildren(node, vNode.children, nextVNode.children);
// Возвращаем обновленный DOM-элемент
return node;
};
patchProps(node, props, nextProps) #
Начнем с метода patchProps
, который довольно простой в реализации:
- С помощью деструктизации объектов мержим
props
иnextProps
, получая единый объект с атрибутами элемента; - Далее перебираем ключи это объекта:
- Если атрибут имеет одинаковые значения, ничего не делаем;
- Если
nextProp
равенundefined
,null
илиfalse
, то удаляем атрибут у ноды (с помощьюremoveAttribute
); - В остальных случаях вызываем
setAttribute
, который перезапишет старое значение новым.
Вполне логичным замечанием будет то, почему мы мержим старые и новые свойства и перебираем их, когда старые достаточно было бы удалить. В разделе с обработчиками событий нам это пригодится.
Обновление самого свойства вынесем в дополнительный метод patchProp
:
const patchProp = (node, key, value, nextValue) => {
// Если новое значение не задано, то удаляем атрибут
if (nextValue == null || nextValue === false) {
node.removeAttribute(key);
return;
}
// Устанавливаем новое значение атрибута
node.setAttribute(key, nextValue);
};
const patchProps = (node, props, nextProps) => {
// Объект с общими свойствами
const mergedProps = { ...props, ...nextProps };
Object.keys(mergedProps).forEach(key => {
// Если значение не изменилось, то ничего не обновляем
if (props[key] !== nextProps[key]) {
patchProp(node, key, props[key], nextProps[key]);
}
});
};
В методе createDOMNode
теперь тоже можно использовать метод patchProps
, просто передавая в качестве текущих свойств пустой объект:
- Object.entries(props).forEach(([key, value]) => {
- node.setAttribute(key, value);
- });
+ patchProps(node, {}, props);
Стоит отметить, что setAttribute
преобразует значение в строчку, поэтому если нужно хранить объекты, массивы или методы, то их необходимо сеттить в node
, например:
const key = 'customArray';
const value = [1, 5];
node[key] = value;
В нашей реализации мы этого делать не будем.
patchChildren(...) #
patchChildren(parentNode, vChildren, nextVChildren)
Обновление дочерних нод - процесс без какой либо магии и всего в несколько строк кода. В метод нужно передать родительскую ноду, так как именно ее детей необходимо править (удалять, добавлять и обновлять). Рассмотрим возможные кейсы:
- Если количество детей каждого виртуального элемента одинаково, то просто сравниваем их в методе
patchNode
; - Если у текущего виртуального дерева детей больше, то необходимо удалить эти ноды. но нам не придется ничего писать дополнительно, так как метод
patchNode
умеет удалять DOM-ноды; - Если у следующего виртуального элемента детей больше, то их просто нужно добавить в родительский DOM-элемент с помощью
appendChild
.
Метод выглядит следующим образом:
const patchChildren = (parent, vChildren, nextVChildren) => {
parent.childNodes.forEach((childNode, i) => {
patchNode(childNode, vChildren[i], nextVChildren[i]);
});
nextVChildren.slice(vChildren.length).forEach(vChild => {
parent.appendChild(createDOMNode(vChild));
});
};
С помощью slice
мы получаем массив из дочерних нод, которые необходимо просто вставить в родительскую DOM-ноду.
Так же можно для виртуальных нод добавить поле key, чтобы использовать его в списках, как это сделано в React, и при добавлении элемента в начало списка не обновлять все следующие ноды. Но этот пункт мы так же пропустим.
Собираем все вместе #
Файл vdom.js
содержит 120 строк кода и реализует простейший вариант Virtual DOM-a. Попробуем его в действии: будем на каждое обновление стейта формировать новый виртуальный DOM и патчить ноду.
import { patchNode, mount, createVNode, createDOMNode } from "./vdom";
const createVApp = state => {
const { count } = state;
return createVNode("div", { class: "container", "data-count": count }, [
createVNode("h1", {}, ["Hello, Virtual DOM"]),
createVNode("div", {}, [`Count: ${count}`]),
"Text node without tags",
createVNode("img", { src: "https://i.ibb.co/M6LdN5m/2.png", width: 200 })
]);
};
let vApp = createVApp(state);
let app = mount(createDOMNode(vApp), document.getElementById("app"));
setInterval(() => {
state.count++;
const nextVApp = createVApp(state);
app = patchNode(app, vApp, nextVApp);
vApp = nextVApp;
}, 1000);
Посмотрим на результат в браузере:
А так же в DevTools на изменения в DOM-дереве:
Что же, мы решили проблемы и сейчас обновляются только измененные элементы! Но если мы посмотрим на код в app.js
, то он не сильно удобный для использования (постоянно хранить ссылку как на DOM-элемент, так и на виртуальный DOM, который еще нужно и заменять). Инкапсулируем всю эту логику в методе patch
.
patch(vNode, node) #
В метод patch
будем передавать виртуальный элемент и реальный, содержимое которого нужно обновить. Текущее виртуальное дерево будем хранить в самой DOM-ноде в свойстве v
.
export const patch = (nextVNode, node) => {
// Получаем текущее виртуальное дерево из DOM-ноды
const vNode = node.v;
// Патчим DOM-ноду
node = patchNode(node, vNode, nextVNode);
// Сохраняем виртуальное дерево в DOM-ноду
node.v = nextVNode;
return node;
};
Это будет работать и теперь появилась возможность использовать patch
вместо метода mount
. Но у нас еще осталась проблема с гидрацией, когда с сервера уже приходит HTML (SSR). Так как изначально node.v
равно undefined
, то текущий элемент будет просто заменен новым (в методе patchNode
).
Однако мы можем решить это, восстановив виртуальное дерево из реального DOM-узла. Полностью восстанавливать мы его не будем (например, атрибуты), но восстановим текстовые элементы (понять, что элемент текстовый, можно по свойству nodeType
, которое будет равно 3) и структуру элементов с правильными тегами:
const TEXT_NODE_TYPE = 3;
const recycleNode = node => {
// Если текстовая нода - то возвращаем текст
if (node.nodeType === TEXT_NODE_TYPE) {
return node.nodeValue;
}
// Получаем имя тега
const tagName = node.nodeName.toLowerCase();
// Рекурсивно обрабатываем дочерние ноды
const children = [].map.call(node.childNodes, recycleNode);
// Создаем виртуальную ноду
return createVNode(tagName, {}, children);
};
Используем этот метод в patch
:
export const patch = (nextVNode, node) => {
+ const vNode = node.v || recycleNode(node);
// ...
};
И перепишем app.js
(теперь можно не использовать метод mount
и обойтись patch
):
// ...
let vApp = createVApp(state);
- let app = mount(createDOMNode(vApp), document.getElementById("app"));
+ let app = patch(vApp, document.getElementById("app"));
setInterval(() => {
state.count++;
- const nextVApp = createVApp(state);
-
- app = patchNode(app, vApp, nextVApp);
- vApp = nextVApp;
+ app = patch(createVApp(state), app);
}, 1000);
Конечно же вручную запускать patch
на каждое обновление стейта не хочется, поэтому усложним несколько наше состояние, добавив метод setState
и слушателя onStateChanged
:
const store = {
state: { count: 0 },
onStateChanged: () => {},
setState(nextState) {
this.state = nextState;
this.onStateChanged();
}
};
let app = patch(createVApp(store), document.getElementById("app"));
// На каждое изменение состояния патчим DOM-элемент
store.onStateChanged = () => {
app = patch(createVApp(store), app);
};
Создавали VDOM, а по ходу реализовали очень легкую версию flux-стора. Теперь достаточно только обновлять state
и DOM автоматически будет обновлен:
setInterval(() => {
store.setState({ count: store.state.count + 1 });
}, 1000);
Финальный вариант файла app.js
будет выглядеть следующим образом:
import { patch, createVNode } from "./vdom";
const createVApp = store => {
const { count } = store.state;
return createVNode("div", { class: "container", "data-count": count }, [
createVNode("h1", {}, ["Hello, Virtual DOM"]),
createVNode("div", {}, [`Count: ${count}`]),
"Text node without tags",
createVNode("img", { src: "https://i.ibb.co/M6LdN5m/2.png", width: 200 })
]);
};
const store = {
state: { count: 0 },
onStateChanged: () => {},
setState(nextState) {
this.state = nextState;
this.onStateChanged();
}
};
let app = patch(createVApp(store), document.getElementById("app"));
store.onStateChanged = () => {
app = patch(createVApp(store), app);
};
setInterval(() => {
store.setState({ count: store.state.count + 1 });
}, 1000);
Обработчики событий #
Даже для простой версии виртуального DOM-а обработчики событий обязательны. Сначала добавим кнопки "+1" и "-1" для изменения значения счетчика и удалим setInterval
. Для кнопок напишем свою фабрику или компонент (если говорить на языке React-а) с двумя свойствами onclick
и text
:
const createVButton = props => {
const { text, onclick } = props;
return createVNode("button", { onclick }, [text]);
};
И добавим две кнопки в createVApp
:
const createVApp = store => {
const { count } = store.state;
return createVNode("div", { class: "container", "data-count": count }, [
createVNode("h1", {}, ["Hello, Virtual DOM"]),
createVNode("div", {}, [`Count: ${count}`]),
"Text node without tags",
createVNode("img", { src: "https://i.ibb.co/M6LdN5m/2.png", width: 200 }),
+ createVNode("div", {}, [
+ createVButton({
+ text: "-1",
+ onclick: () => store.setState({ count: store.state.count - 1 })
+ }),
+ " ",
+ createVButton({
+ text: "+1",
+ onclick: () => store.setState({ count: store.state.count + 1 })
+ })
+ ])
]);
};
Запустив этот код, кнопки появятся, но при нажатии на них ничего не будет происходить. Почему так? Если посмотреть в исходный код, то увидим, что код обработчиков (функции) находятся в атрибуте onclick
в виде строки. И даже если бы он вызывался, то произошла бы ошибка, так как переменная store
не определена.
Чтобы сохранить контекст вызова, нужно сохранить обработчик прямо в DOM-ноду. Сперва создадим функцию listerner
:
function listener(event) {
return this[event.type](event);
}
Зачем она нам нужна и что делает? Эта функция будет вызываться при вызове события (например click
), this
указывает на DOM-элемент, this[event.type]
на метод, который мы указываем в виртуальном элементе.
Теперь добавим обработку событий в метод patchNode
. Чтобы отличать события от не событий можно называть их с префикса on (например, onclick, onkeydown), тем самым отделяя их от остальных атрибутов:
const patchProp = (...) => {
if (key.startsWith("on")) {
// ...
}
Когда мы поняли, что нужно обработать событие, нужно вытащить его имя (удалив on
), добавить функцию в DOM-ноду, и навесить обработчик с помощью addEventListener
, если значение в текущем виртуальном узле не задано (или удалить, если значение не задано в следующем виртуальном узле):
const patchProp = (...) => {
if (key.startsWith("on")) {
const eventName = key.slice(2);
node[eventName] = nextValue;
if (!nextValue) {
node.removeEventListener(eventName, listener);
} else if (!value) {
node.addEventListener(eventName, listener);
}
return;
}
Проверяем работу событий в действии:
Интегрируем JSX #
Для компиляции JSX кода в JS можно воспользоваться babel-плагинами @babel/plugin-transform-react-jsx
и @babel/plugin-syntax-jsx
. Для этого их нужно установить и создать .babelrc
в директории проекта:
{
"plugins": [
"@babel/plugin-syntax-jsx",
["@babel/plugin-transform-react-jsx", { "pragma": "createVNode" }]
]
}
Также нужно передать опцию pragma
, в которой указать название метода (в который JSX-код будет преобразован). Например, следующиий JSX:
<div>Count</div>
будет скомпилирован в следующий JS-код:
createVNode("div", {}, "Count")
Для интеграции JSX нужно немного изменить метод createVNode
, а именно добавить возможность передачи функции и обработки дочерних элементов:
export const createVNode = (tagName, props = {}, ...children) => {
if (typeof tagName === "function") {
return tagName(props, children);
}
return {
tagName,
props,
children: children.flat(),
};
};
Нужно для приведения возвращаемого значения к формату hyperscript.
Теперь изменим наш пример:
const createVApp = store => {
const { count } = store.state;
const decrement = () => store.setState({ count: store.state.count - 1 });
const increment = () => store.setState({ count: store.state.count + 1 });
return (
<div {...{ class: "container", "data-count": String(count) }}>
<h1>Hello, Virtual DOM</h1>
<div>Count: {String(count)}</div>
Text node without tags
<img src="https://i.ibb.co/M6LdN5m/2.png" width="200" />
<button onclick={decrement}>-1</button>
<button onclick={increment}>+1</button>
</div>
);
};
Выглядит компактнее, а для любителей реакта и привычнее. Как вы можете заметить, мы используем ключ class
, а не className
(так как обработка className
в атрибут class
происходит на стороне Virtual DOM-а, не JSX-а, и у нас такой обработки нет, в отличие от React-а).
Итоги #
Сегодня мы проделали большую работу, реализовав свою версию Virtual DOM с блекджеком, обработчиками событий, методом патчинга реальной ноды и даже интегрировали JSX.
Итоговый вариант залит на гитхаб и в codesandbox (версия без jsx).
В процессе написания поста я наткнулся на библиотеку superfine, которая предоставляет свою реализацию Virtual DOM в 300 строк кода. Если хотите погрузиться в тему поглубже, то рекомендую начать с изучения именно данного решения (в ней есть работа с key в списках, обработка svg, выполнение на стороне сервера и многое другое).
Чтобы не пропускать посты на блоге, подписывайтесь на телеграм канал https://t.me/amorgunov. До связи!