
Привет, Хабр. Меня зовут Марат Исаев. В этой статье кратко рассмотрю устройство самописного микрофреймворка и его применение для написания приложения‑планировщика, чтобы вы могли написать свой фреймворк, для своих приложений. Подчеркну: статья образовательная и не охватывает enterprise‑разработку.
Чтобы мой рассказ был более предметным, сразу покажу код фреймворка и приложения. Можете скопировать его прямо в консоль, запустить и по мере чтения статьи тестировать, разбираться и исследовать.
Код фреймворка
let reconcileApp = () => {};
function html(tag, props = {}, children = []) {
return {
tag,
props,
children,
type: "element",
domRef: undefined,
};
}
function primitive(value) {
return {
type: "primitive",
value,
};
}
class Component {
type = "component";
rendered;
constructor(props = {}) {
this.props = props;
}
setState(nextState) {
this.state = nextState;
reconcileApp();
}
render() {}
onMount() {}
onUnmount() {}
}
function mount(node, rootDOMElement) {
switch (node.type) {
case "element": {
const { props, tag } = node;
node.domRef = document.createElement(tag);
patchDOMElement(props, node.domRef);
rootDOMElement.appendChild(node.domRef);
node.children.forEach((childNode) => mount(childNode, node.domRef));
break;
}
case "component":
node.rendered = node.render();
node.onMount();
mount(node.rendered, rootDOMElement);
break;
case "primitive":
node.domRef = document.createTextNode(node.value);
rootDOMElement.appendChild(node.domRef);
break;
default:
throw Error("Неожиданный узел: ", node.type);
}
}
function unmount(node) {
switch (node.type) {
case "element": {
node.domRef.remove();
node.children.forEach((childNode) => unmount(childNode));
break;
}
case "component":
node.onUnmount();
unmount(node.rendered);
break;
case "primitive":
node.domRef.remove();
break;
default:
throw Error("Неожиданный узел: ", node.type);
}
}
function patchDOMElement(props, element) {
const attributes = Object.keys(props);
for (attr of attributes) {
element[attr] = props[attr];
}
}
function isEqualNodes(node1, node2) {
if (node1.type !== node2.type) {
return false;
}
switch (node1.type) {
case "element":
return node1.tag === node2.tag;
case "component":
return (
Object.getPrototypeOf(node1) === Object.getPrototypeOf(node2) &&
node1.props.key === node2.props.key
);
case "primitive":
return node1.value === node2.value;
default:
return false;
}
}
function reconcile(prevNode, nextNode, parentDOMElement) {
if (!prevNode && nextNode) {
mount(nextNode, parentDOMElement);
return nextNode;
} else if (prevNode && !nextNode) {
unmount(prevNode);
return;
} else if (!prevNode && !nextNode) {
return;
} else if (prevNode && nextNode) {
const isEqual = isEqualNodes(prevNode, nextNode);
if (isEqual) {
if (prevNode.type === "element") {
const { props, children: nextChildren } = nextNode;
const { domRef, children: prevChildren } = prevNode;
prevNode.props = props;
patchDOMElement(props, domRef);
const length = Math.max(prevChildren.length, nextChildren.length);
for (let i = 0; i < length; i++) {
prevChildren[i] = reconcile(
prevChildren[i],
nextChildren[i],
prevNode.domRef
);
}
return prevNode;
} else if (prevNode.type === "component") {
prevNode.props = nextNode.props;
prevNode.rendered = reconcile(
prevNode.rendered,
prevNode.render(),
parentDOMElement
);
return prevNode;
} else if (prevNode.type === "primitive") {
return prevNode;
} else {
throw Error("Упс!");
}
} else {
unmount(prevNode);
mount(nextNode, parentDOMElement);
return nextNode;
}
}
}
function createApp(App, root) {
let vdom = new App();
mount(vdom, root);
reconcileApp = () => {
vdom = reconcile(vdom, vdom, root);
};
}
Код приложения
class Input extends Component {
state = { value: "" };
constructor(props) {
super(props);
}
render() {
return html("div", {}, [
html("input", {
oninput: (event) => {
this.setState({
...this.state,
value: event.target.value,
});
},
value: this.state.value,
}),
html(
"button",
{
onclick: () => {
if (this.state.value) {
this.props.onAdd(this.state.value);
this.setState({
...this.state,
value: "",
});
}
},
disabled: !this.state.value,
},
[primitive("Добавить")]
),
]);
}
}
class Item extends Component {
constructor(props) {
super(props);
}
render() {
return html("li", {}, [
primitive(this.props.name),
html("button", { onclick: this.props.onRemove }, [primitive("Удалить")]),
]);
}
}
class List extends Component {
constructor(props) {
super(props);
}
render() {
return html(
"ul",
{},
this.props.list.map((item) => {
return new Item({
name: item,
key: item,
onRemove: () => this.props.onRemove(item),
});
})
);
}
}
class ToDoApp extends Component {
state = { list: [] };
render() {
return html("div", {}, [
new Input({
onAdd: (item) => {
this.setState({
list: [...this.state.list, item],
});
},
}),
new List({
list: this.state.list,
onRemove: (item) => {
this.setState({
list: this.state.list.filter((i) => i !== item),
});
},
}),
]);
}
}
createApp(ToDoApp, document.body);
Из чего состоит фреймворк
primitive
: текст, числа, булевые значения;element
: DOM-элементы;component
: компоненты, экземпляры классаComponent
с методами жизненного цикла и состоянием.
Для их создания будем использовать функции html
, primitive
и класс Component
. Узлы типа element
имеют поле domRef
, в котором хранится ссылка на реальный DOM-узел, массив дочерних элементов children
, имя HTML-тега tag
и свойства props
.
В классе Component
нужно объяснить свойство rendered
: здесь хранится результат предыдущего выполнения метода render
для сравнения и нахождения различий между двумя рендерами. При вызове setState
мы обновляем состояние компонента и запускаем перерисовку всего приложения (запускаем функцию reconcileApp
).
Монтирование и размонтирование виртуального DOM
После создания виртуального DOM его нужно смонтировать. Функция mount
принимает виртуальный узел (primitive
, element
, component
) и родительский DOM‑элемент, создаёт реальные DOM‑элементы из наших виртуальных узлов и монтирует к родительскому, рекурсивно проходя по children
и делая с ними то же самое.
Для узлов component
мы вызываем метод жизненного цикла onMount
и метод render
для получения следующего узла виртуального дерева, к которому рекурсивно применяем mount
.
Раз мы монтируем виртуальный DOM, то должен быть и способ его размонтировать. Функция unmount
принимает виртуальный узел и по ссылке domRef
указывающей на представление этого узла в реальном DOM‑дереве и вызывает у реального DOM‑элемента метод remove()
, тем самым удаляя его. Далее unmount
рекурсивно применяем ко всем children
.
Для компонента вызываем метод onUnmount
, после чего unmount
применяется к его поддереву, хранящемуся в свойстве rendered
.
Согласование узлов DOM-дерева
Пожалуй, самая важная часть фреймворка — алгоритм согласования. Он реализован в функции reconcile
. На вход получаем два узла, которые нужно сравнить, и родительский реальный DOM‑узел на случай, если понадобится вносить изменения в реальный DOM (что‑то смонтировать). Результат выполнения reconcile
— это и есть новое представление нашего приложения.
Алгоритм очень прост:
если раньше узла не было, а теперь появился — монтируем;
если был и пропал — размонтируем;
если ранее и сейчас в этом месте пустота — ничего не делаем;
-
если есть два узла в обеих версиях виртуального DOM — вызываем
isEqualNodes
:если типы узлов разные — размонтируем старый и монтируем новый;
-
а если они одинаковые, то сохраняем прежний узел и передаём ему обновившиеся свойства;
если это узел типа
element
, то вызываемpatchDOMElement
, чтобы обновить свойства реального DOM‑узла, и рекурсивно проходимreconcile
по дочерним узлам (их количество может измениться, поэтому для обхода выбираем наибольшую длину из обоих путей, чтобы не пропустить ни один дочерний узел);если это узел типа
component
, то вызываемreconcile
для сравнения прежнего поддерева из свойстваrendered
и нового, которое получим с помощью вызова методаrender()
.
Инициализация приложения
Для этого воспользуемся функцией createApp
, это некое подобие ReactDOM.render
. Она смонтирует приложение в рутовый DOM‑элемент и обновит функцию reconcileApp
. Напомню, что reconcileApp
мы вызываем каждый раз после вызова setState
, чтобы пробежаться по всему приложению и обновить представление.
Совершенствуем фреймворк
Чтобы наш кроха‑фреймворк стал тем самым убийцей React или Angular, ещё предстоит много работы. Эти 150 строк могут стать для вас отправной точкой, чтобы сделать что‑то своё. Начать можно с таких задач:
Доработайте
patchDOMElement
, чтобы он мог обрабатывать что‑то сложнее, чем атрибутыonclick
,disabled
и прочие похожие, которые можно задать простым присвоением.Попробуйте переработать
createApp
иreconcileApp
, ведь сейчас невозможно инициализировать несколько отдельных приложений.Что если в
isEqualNodes
для узлов типаcomponent
убрать проверку наprops.key
? Попробуйте в нашем приложении ввести два пункта и удалить первый:


Подсказка: обратите внимание, что mount
не заменяет узел, а лишь добавляет дочерний в конец.
igorzakhar
Может кому-нибудь захочется углубиться, эта тема неплохо описана в книге "Создание фронтенд-фреймворка с нуля"