Пишем свой VirtualDOM

VirtualDOM

VirtualDOM

Не стоит воспринимать этот материал как руководство к действию, это только базовые принципы работы с VDOM, которые используют большинство. Реализация будет максимальной простой и не учитывающей многих факторов и проблем кросбразерности. Главная цель, показать, что никакой магии нет и сам принцип очень прост.

VirtualDOM / React / JSX

			function Button({name, text, onTap}) {
				return (
					<button className="button" name={name} onClick={onTap}>
						{text}
					</button>
				);
			}
		

VirtualDOM / React / JSX

			function Button({name, text, onTap}) {
				return React.createElement(
					"button", // Element
					{className: "button", name, onTap}, // attributes
					text // children
				);
			}
		

VirtualDOM / Убираем React

			function Button({name, text, onTap}) {
				return {
					tag: "button",
					attrs: {class: "button", name, onTap},
					children: [text]
				};
			}
		

VirtualDOM / Пример

			<div class="app">
				<h1>Hi, %username%!</h1>
				<button name="enter" class="button">Enter</button>
			</div>
			                ↓(click + prompt)↓
			<div class="app">
				<h1>Hi, VD!</h1>
				<button name="exit" class="button">Exit</button>
			</div>
		
			import {Component} from "./vdom";
			import Button from "./Button";
			class App extends Component {
				handleEvent(evt) {
					#! const name = evt.currentTarget.name === "enter" ? prompt("Name?") : null;
					#! this.setState({name});
				}
				render() {
					#! const {name} = this.state;
					#!+ return {
						tag: "div",
						attrs: {className: "app"},
						children: [
							#! {tag: "h1", children: [`Hi, ${name || '%username%'}!`]},
							#!+ {tag: Button, attrs: {
								#!+ name: name ? "enter" : "exit",
								#!- text: name ? "Enter" : "Exit",
								#! onClick: this,
							#!- }},
						]
					#!- };
				}
			}
		

VirtualDOM / Пример

			import {render} from "./vdom";
			import App from "./App";

			const container = document.getElementById("root");
			render(container, App, {});
		

Приступим

Необходимый набор методов

			function create(/* VNode */node, /* HTMLElement */parent) {
				node = normalize(node); // "str" -> {tag: "#", children: "..."}
				let el;

				if (TEXT_NODE === node.tag) {
					el = document.createTextNode(node.children);
				} else {
					el = document.createElement(node.tag);
					updateAttrs(el, node.attrs); // добавляем аттрибуты
					createChildren(node, el); // создаеём детишек
				}

				node.el = el; // запоминаем DOM
				(parent !== null) && parent.appendChild(el);

				return node;
			}
		
			function updateAttrs(/* HTMLElement */el, attrs, oldAttrs) {
				let name;
				let value;
				for (name in attrs) {
					value = attrs[name];
					#!+ if (oldAttrs == null || oldAttrs[name] !== value) {
						#!+ if (CLASS_ATTR === name) { // className
							el.className = value;
						#!- }
						#!+ else if (VALUE_ATTR === name) { // value
							(el[name] !== value)  && (el[name] = value);
						#!- }
						#!+ else if (/^on[A-Z]/.test(name)) { // события
							el.addEventListener(name.substr(2).toLowerCase(), value, false);
						#!- }
						#!+ else { // все остальные
							el.setAttribute(name, value);
						#!- }
					#!- }
				}

				// продолжение следует
		
			.
				// продолжение

				if (oldAttrs != null) {
					for (name in oldAttrs) {
						value = oldAttrs[name];

						if (attrs[name] == null && attrs[name] !== value) {
							if (/^on[A-Z]/.test(name)) {
								el.removeEventListener(name.substr(2).toLowerCase(), value, false);
							} else {
								el.removeAttribute(name);
							}
						}
					}
				}
			}
		
			function createChildren(/* VNode */node, /* HTMLElement */parent) {
				const children = node.children;
				const length = children.length;

				#!+ if (length == null || /string|number|boolean/.test(typeof children)) {
					#! node.children = [create(children, parent)];
				} else {
					#!+ for (let i = 0; i < length; i++) {
						children[i] = create(children[i], parent);
					#!- }
				#!- }
			}
		

v0.1.0 (jsfiddle)

			<div id="root"></div>

			<script>
				const vnode = create({
					#!+ tag: "div",
					attrs: {class: "app"},
					children: [
						#! {tag: "h1", children: "Hi, %username%!"},
						#! {tag: "button", attrs: {class: "button"}, children: "Enter"},
					#!- ],
				}, document.getElementById("root"));
			</script>
		

Обновление

			function update(/* VNode */oldNode, /* VNode */newNode, /* HTMLElement */parent) {
				const el = oldNode.el;
				newNode = normalize(newNode);

				#!+ if (newNode.tag === oldNode.tag) {
					#! newNode.el = el;
					#!+ if (TEXT_NODE === oldNode.tag) { // Текст
						#!+ if (oldNode.children !== newNode.children) {
							el.textContent = newNode.children;
						#!- }
					} else { // Внутренности
						#!+ updateAttrs(el, newNode.attrs, oldNode.attrs);
						#!- updateChildren(oldNode, newNode);
					#!- }
				} else {
					#! newNode = create(newNode, null);
					#! parent.replaceChild(newNode.el, el);
				#!- }

				return newNode;
			}
		
			function updateChildren(/* VNode */oldNode, /* VNode */newNode) {
				const el = newNode.el;
				const oldChildren = oldNode.children;
				const oldLength = oldChildren.length;
				let newChildren = newNode.children;
				let newLength = newChildren.length;

				#!+ if (newLength == null || /string|number|boolean/.test(typeof newChildren)) {
					newLength = 1;
					newNode.children = newChildren = [newChildren];
				#!- }

				#!+ for (let idx = 0; idx < newLength; idx++) {
					const newChild = newChildren[idx];
					if (idx < oldLength) { // обновляем узел
						#! newChildren[idx] = update(oldChildren[idx], newChild, el);
					} else { // создаём новый в конце
						#! newChildren[idx] = create(newChild , el);
					}
				#!- }

				#!+ for (let i = newLength; i < oldLength; i++) { // удаляем хвост
					el.removeChild(oldChildren[i].el);
				#!- }
			}
		

И финальный штрих

			function render(container, node) {
				#! let vnode = container.vnode;

				#!+ if (vnode) {
					vnode = update(vnode, node, container);
				} else {
					vnode = create(node, container);
				#!- }

				#!+ container.vnode = vnode;
				#!- return vnode;
			}
		

v0.2.0 (jsfiddle)

			let name;
			const container = document.getElementById("root");
			const redraw = () => render(container, createFragment());
			#!+ function createFragment() {
				return {
					tag: "div",
					attrs: {class: "app"},
					children: [
						#! {tag: "h1", children: `Hi, ${name || '%username%'}!`},
						#!+ {tag: "button", attrs: {
							class: "button",
							name: **name ? "exit" : "enter"**,
							onClick: handleClick,
						#!- }, children: **name ? "Exit" : "Enter"**},
					]
				};
			#!- }
			#!+ function handleClick(evt) {
				name = evt.currentTarget.name === "enter" && prompt("Name?");
				#! redraw();
			#!- }
			#! redraw();
		

Продолжение
следует