在这篇文章中,我将详细介绍一个完整的虚拟DOM实现,代码量略超过200行JavaScript。
结果是一个功能完备且性能足够的虚拟DOM库(演示)。它作为smvc包在NPM上可用。
主要目标是阐述像React这样的工具背后的基本技术。
React、Vue和Elm语言都通过允许你描述你希望页面看起来如何,而不必担心添加/移除元素来简化交互式网页的创建。它们通过虚拟DOM来实现这一点。
虚拟DOM的目标
这不仅仅是关于性能。
虚拟DOM是一个抽象,旨在简化UI的修改行为。
你描述你希望页面看起来如何,库会负责将DOM从当前状态更改为你希望的状态。
关键思想
库将接管一个单一的DOM元素并在其中操作。
这个元素最初应该是空的,我们假设除了我们的库之外,不会有任何东西修改它。这将是用户应用程序的根。
如果我们能够修改它,那么我们就可以确切地知道这个元素里面有什么,而无需检查它。怎么做呢?通过跟踪我们到目前为止对它所做的所有修改。
我们将通过保持一个包含每个HTML元素简化表示的结构来跟踪我们的根节点内部是什么。或者更准确地说,每个DOM节点。
因为这种表示是DOM节点的反映,但它不在真正的DOM中,让我们称它为虚拟节点,它将构成我们的虚拟DOM。
用户永远不会创建真正的DOM节点,只有那些虚拟的。他们将通过使用虚拟节点告诉我们整个页面应该如何看起来。然后我们的库将负责修改真正的DOM,使其符合我们的表示。
为了知道要修改什么,我们的库将获取用户创建的虚拟DOM,并将其与代表页面当前外观的虚拟DOM进行比较。这个过程称为diffing。它将记录差异,例如应该添加或删除哪些元素以及应该添加或删除哪些属性。diffing的输出是一个虚拟DOMdiff。
然后我们将应用diff中的更改到真正的DOM。一旦我们完成修改,用户创建的虚拟DOM现在已经成为真正的DOM的真实表示。
所以,对于UI部分,我们需要:
- 创建DOM的虚拟表示
- diff虚拟DOM节点
- 将虚拟DOMdiff应用到HTML元素
在构建这些之后,我们将看到如何通过仅添加几行代码的状态处理,将这样的虚拟DOM作为一个强大的库来使用。
表示DOM
我们希望这个结构包含尽可能少的信息,以真实地表示页面上的内容。
一个DOM节点有一个标签(div
、p
、span
等),属性和子节点。让我们使用具有这些属性的对象来表示它们。
const exampleButton = {
tag : "button",
properties: { class: "primary", disabled: true, onClick: doSomething },
children : [] // 一个虚拟节点数组
};
我们还需要一种方式来表示一个文本节点。文本节点没有标签,属性或子节点。我们可以使用一个具有单个属性的对象,其中包含文本内容。
const exampleText = {
text: "Hello World"
};
我们可以通过检查tag
或text
属性是否存在来区分文本虚拟节点和元素节点。
就是这样!这是我们完整的虚拟DOM已经指定了。
我们可以为用户创建这些类型的节点创建一些便利函数。
function h(tag, properties, children) {
return { tag, properties, children);
}
function text(content) {
return { text : content };
};
现在可以轻松创建复杂的嵌套结构。
const pausedScreen = h("div", {}, [
h("h2", {}, text("Game Paused")),
h("button", { onClick: resumeGame }, [ text("Resume") ]),
h("button", { onClick: quitGame }, [ text("Quit") ])
])
Diffing
在开始diffing之前,让我们思考一下我们希望diffing操作的输出是什么样的。
一个diff应该描述如何修改一个元素。我能想到几种类型的修改:
- Create - 在DOM中添加一个新节点。应该包含要添加的虚拟DOM节点。
- Remove - 不需要包含任何信息。
- Replace - 移除一个节点,但在其位置放一个新的。应该包含要添加的节点。
- Modify an existing node - 应该包含要添加的属性,要移除的属性,以及对子节点的修改数组。
- Don’t modify - 元素保持不变,没有什么要做的。
你可能想知道为什么我们除了create
和remove
之外还有一个replace
修改。这是因为除非用户为每个虚拟DOM节点提供唯一标识符,否则我们没有办法知道元素子节点的顺序是否发生了变化。
考虑这个情况,最初的DOM描述看起来像这样:
{ tag: "div",
properties: {},
chidlren: [
{ text: "One" },
{ text: "Two" },
{ text: "Three" }
]
}
然后一个后续的描述是这样的
{ tag: "div",
properties: {},
chidlren: [
{ text: "Three" }
{ text: "Two" },
{ text: "One" },
]
}
要注意到一和三交换了位置,我们必须将第一个对象的每个子节点与第二个对象的每个子节点进行比较。这不能有效地完成。所以相反,我们通过它们在children
数组中的索引来标识元素。这意味着我们将replace
数组中的第一个和最后一个文本节点。
这也意味着我们只能在作为最后一个子节点插入元素时使用create
。所以除非我们正在添加子节点,否则我们将使用replace
。
现在让我们深入实现这个diff
函数。
// 它需要比较两个节点,一个旧的和一个新的。
function diffOne(l, r) {
// 首先我们处理文本节点。如果它们的文本内容不
// 完全相同,那么让我们替换旧的为新的。
// 否则它是一个`noop`,这意味着我们什么都不做。
const isText = l.text !== undefined;
if (isText) {
return l.text !== r.text
? { replace: r }
: { noop : true };
}
// 接下来我们开始处理元素节点。
// 如果标签更改了,我们应该替换整个东西。
if (l.tag !== r.tag) {
return { replace: r };
}
// 现在替换已经解决,我们只能修改元素。所以让我们先记录一下
// 应该删除的属性。
// 任何在新节点中不存在的属性都应该被删除。
const remove = [];
for (const prop in l.properties) {
if (r.properties[prop] === undefined) {
remove.push(prop);
}
}
// 现在让我们检查哪些应该被设置。
// 这包括新的和修改过的属性。
// 所以除非属性的值在旧的和新的节点中是相同的,否则我们将记录它。
const set = {};
for (const prop in r.properties) {
if (r.properties[prop] !== l.properties[prop]) {
set[prop] = r.properties[prop];
}
}
// 最后我们diff子节点列表。
const children = diffList(l.children, r.children);
return { modify: { remove, set, children } };
}
作为一个优化,我们可以注意到当没有任何属性更改并且所有子节点修改都是noops时,我们可以将元素的diff也设为noop
。(像这样)
列表的Diffing过程很简单。我们创建一个diff列表,其长度为正在比较的两个列表中较长的那个。如果旧列表更长,则应移除多余的元素。如果新列表更长,则应创建多余的元素。所有共同的元素都应进行diff操作。
function diffList(ls, rs) {
const length = Math.max(ls.length, rs.length);
return Array.from({ length })
.map((_, i) =>
(ls[i] === undefined)
? { create: rs[i] }
: (rs[i] == undefined)
? { remove: true }
: diffOne(ls[i], rs[i])
);
}
Diffing操作完成!
应用Diff
我们已经可以创建一个虚拟DOM并对其进行diff操作。现在,是时候将这个diff应用到真正的DOM上了。
apply
函数将接收一个其子节点应该受到影响的真实DOM节点,以及在前一步创建的diff数组。这个节点的子节点的diffs。
apply
将没有实际意义的返回值,因为它的主要目的是执行修改DOM的副作用。
它的实现相当简单,只需为每个子节点分派适当的操作即可。创建和修改DOM节点的过程已经被移动到它们自己的函数中。
function apply(el, childrenDiff) {
const children = Array.from(el.childNodes);
childrenDiff.forEach((diff, i) => {
const action = Object.keys(diff)[0];
switch (action) {
case "remove":
children[i].remove();
break;
case "modify":
modify(children[i], diff.modify);
break;
case "create": {
const child = create(diff.create);
el.appendChild(child);
break;
}
case "replace": {
const child = create(diff.replace);
children[i].replaceWith(child);
break;
}
case "noop":
break;
}
});
}
事件监听器
在处理创建和修改之前,让我们思考一下我们如何处理事件监听器。
我们希望添加和移除事件监听器非常便宜且容易,我们希望确保我们永远不会留下任何悬挂的监听器。
我们还要强制执行一个不变的规则,即对于任何给定的节点,每个事件只能有一个监听器。这将在我们的API中已经是这种情况,因为事件监听器是使用属性对象中的键指定的,而JavaScript对象不能有重复的键。
这里有一个想法。我们在DOM对象节点中添加一个由我们的库创建的特殊属性,其中包含一个对象,其中可以找到该DOM节点的所有用户定义的事件监听器。
// 创建一个属性`_ui`,我们可以在其中直接在DOM节点本身存储与
// 我们的库相关的数据。
// 我们在这个空间中存储该节点的事件监听器。
element["_ui"] = { listeners : { click: doSomething } };
现在我们可以使用一个单一的函数listener
,作为所有节点中所有事件的事件监听器。
一旦触发事件,我们的listener
函数就会获取它,并使用监听器对象将其分派给适当的用户定义的函数来处理事件。
function listener(event) {
const el = event.currentTarget;
const handler = el._ui.listeners[event.type];
handler(event);
}
到目前为止,这给我们带来的好处是不需要每次用户监听器函数更改时都调用addEventListener
和removeEventListener
。更改事件监听器只需要更改listeners
对象中的值。稍后我们将看到这种方法的一个更有说服力的好处。
有了这些知识,我们可以创建一个专用函数来向DOM节点添加事件监听器。
function setListener(el, event, handle) {
if (el._ui.listeners[event] === undefined) {
el.addEventListener(event, listener);
}
el._ui.listeners[event] = handle;
}
我们还没有做的一件事是找出properties
对象中的任何给定条目是否是事件监听器。
让我们编写一个函数,它将告诉我们要监听的事件名称或如果属性不是事件监听器,则返回null
。
function eventName(str) {
if (str.indexOf("on") == 0) { // 以`on`开头
return str.slice(2).toLowerCase(); // 小写名称,没有`on`
}
return null;
}
属性
好的,我们知道如何添加事件监听器了。对于属性,我们可以直接调用setAttribute
,对吧?嗯,不是。
对于某些事情,我们应该使用setAttribute
函数,而对于其他事情,我们应该直接在DOM对象中设置属性。
例如。如果你有一个<input type="checkbox">
并且调用element.setAttribute("checked", true)
,它将不会变为选中🙃。你应该改为做element["checked"] = true
。这将奏效。
我们怎么知道该用哪一个呢?嗯,这很复杂。我只是根据Elm的Html库正在做什么编制了一个列表。这是结果:
const props = new Set([ "autoplay", "checked", "contentEditable", "controls",
"default", "hidden", "loop", "selected", "spellcheck", "value", "id", "title",
"accessKey", "dir", "dropzone", "lang", "src", "alt", "preload", "poster",
"kind", "label", "srclang", "sandbox", "srcdoc", "type", "value", "accept",
"placeholder", "acceptCharset", "action", "autocomplete", "enctype", "method",
"name", "pattern", "htmlFor", "max", "min", "step", "wrap", "useMap", "shape",
"coords", "align", "cite", "href", "target", "download", "download",
"hreflang", "ping", "start", "headers", "scope", "span" ]);
function setProperty(prop, value, el) {
if (props.has(prop)) {
el[prop] = value;
} else {
el.setAttribute(prop, value);
}
}
创建和修改
有了所有这些,我们现在可以尝试从虚拟DOM创建一个真正的DOM节点。
function create(vnode) {
// 创建一个文本节点
if (vnode.text !== undefined) {
const el = document.createTextNode(vnode.text);
return el;
}
// 使用正确的标签创建DOM元素,并
// 已经将我们的监听器对象添加到其中。
const el = document.createElement(vnode.tag);
el._ui = { listeners : {} };
for (const prop in vnode.properties) {
const event = eventName(prop);
const value = vnode.properties[prop];
// 如果是事件,设置它,否则将值设置为属性。
(event !== null)
? setListener(el, event, value)
: setProperty(prop, value, el);
}
// 递归创建所有子节点,并逐个附加。
for (const childVNode of vnode.children) {
const child = create(childVNode);
el.appendChild(child);
}
return el;
}
modify
函数同样简单直接。它设置和删除节点的适当属性,并将控制权交给apply
函数,以便它更改子节点。注意modify
和apply
之间的递归。
function modify(el, diff) {
// 删除属性
for (const prop of diff.remove) {
const event = eventName(prop);
if (event === null) {
el.removeAttribute(prop);
} else {
el._ui.listeners[event] = undefined;
el.removeEventListener(event, listener);
}
}
// 设置属性
for (const prop in diff.set) {
const value = diff.set[prop];
const event = eventName(prop);
(event !== null)
? setListener(el, event, value)
: setProperty(prop, value, el);
}
// 处理子节点
apply(el, diff.children);
}
处理状态
现在我们有了一个完整的虚拟DOM渲染实现。使用h
和text
我们可以创建一个VDOM,并使用apply
和diffList
我们可以将其实现到真正的DOM并更新它。
我们可以在这里停止,但我认为没有一种结构化的方式来处理状态变化,实现是不完整的。毕竟,虚拟DOM的全部意义在于当状态发生变化时,你会重复创建它。
API
我们将以一种非常简单直接的方式来实现它。将会有两种用户定义的值:
- 应用程序的状态:包含渲染虚拟DOM所需的所有信息的值。
- 应用程序消息:包含有关如何更改状态的信息的值。
我们将要求用户实现两个函数:
view
函数接收应用程序状态并返回虚拟DOM。update
函数接收应用程序状态和一条应用程序消息,并返回新的应用程序状态。
这足以构建任何复杂的应用程序。
用户在程序开始时提供这两个函数,虚拟DOM库将控制何时调用它们。用户从不直接调用它们。
我们还需要为用户提供一种通过update
函数处理消息的方式来发出消息。我们将通过提供一个enqueue
函数来实现这一点,该函数将消息添加到要处理的消息队列中。
我们从用户那里需要的最后几件事是一个初始状态来开始,以及一个HTML节点,在这个节点内应该渲染虚拟DOM。
有了这些最后的片段,我们就有完整的API了。我们定义了一个名为init
的函数,它将获取用户的所有所需输入并启动应用程序。它将返回该应用程序的enqueue
函数。这种设计允许我们在同一个页面上运行多个虚拟DOM应用程序,每个应用程序都有自己的enqueue
函数。
这里有一个使用此设计实现的计数器:
计数器:104
function view(state) {
return [
h("p", {}, [ text(`计数器:${state.counter}`) ])
];
}
function update(state, msg) {
return { counter: state.counter + msg };
}
const initialState = { counter: 0 };
const root = document.querySelector(".my-application");
// 启动应用程序
const { enqueue } = init(root, initialState, update, view);
// 每秒增加计数器一。
setInterval(() => enqueue(1), 1000);
Init函数
API已经完善,让我们思考一下这个init
函数应该如何工作。
我们肯定会为每条消息调用一次update
。但我们不需要每次状态改变时都调用view
,因为那可能会导致我们比浏览器能够显示DOM更新更频繁地更新DOM。我们希望每个动画帧最多调用一次view
。
此外,我们希望用户能够根据需要多次调用enqueue
,并且从任何地方调用它,而不会导致我们的应用程序崩溃。这意味着我们应该接受在update
函数内部调用enqueue
。
我们将通过解耦消息排队、更新状态和更新DOM来实现这一点。
对enqueue
的调用只会将消息添加到数组中。然后,在每个动画帧上,我们将取出所有排队的消息,并通过调用每个消息的update
来处理它们。一旦所有消息都被处理,我们将使用view
函数渲染结果状态。
现在运行应用程序就是每个动画帧重复这个过程。
// 开始管理一个HTML元素的内容。
function init(root, initialState, update, view) {
let state = initialState; // 客户端应用程序状态
let nodes = []; // 虚拟DOM节点
let queue = []; // 消息队列
function enqueue(msg) {
queue.push(msg);
}
// 绘制当前状态
function draw() {
let newNodes = view(state);
apply(root, diffList(nodes, newNodes));
nodes = newNodes;
}
function updateState() {
if (queue.length > 0) {
let msgs = queue;
// 用一个空数组替换队列,这样我们就不会在这一轮处理
// 新排队的消息。
queue = [];
for (msg of msgs) {
state = update(state, msg);
}
draw();
}
// 安排下一轮状态更新
window.requestAnimationFrame(updateState);
}
draw(); // 绘制初始状态
updateState(); // 开始状态更新周期
return { enqueue };
}
便利性
我们的用户可以从任何他们想要的地方调用enqueue
,但目前从update
和view
函数内部调用它有点麻烦。这是因为enqueue
由init
返回,而init
期望update
和view
已经被定义。
让我们首先通过将enqueue
作为第三个参数传递给update
来改进这一点。现在我们的状态更新看起来像这样:
state = update(state, msg, enqueue)
足够简单。现在让我们思考一下如何在view
函数中改善这种情况。
用户在渲染期间不会调用enqueue
。他们会在响应某些事件(如onClick
或onInput
)时调用它。因此,让为这些事件创建的用户处理函数接收enqueue
作为参数,以及事件对象,这是有意义的。
有了这个,事件处理可以是这样的:
const button = h(
"button",
{ onClick: (_event, enqueue) => { enqueue(1) } },
[text("增加计数器")]
);
我们可以通过使事件处理程序返回的任何与undefined
不同的值被视为消息来使它更简单。这将允许上面的按钮被写成:
const button = h(
"button",
{ onClick: () => 1 },
[text("增加计数器")]
);
计数器:0
酷,我们如何实现这一点?我们单一的listener
函数,它调度事件,将需要访问enqueue
。通过_ui
对象传递它是最简单的方法,该对象已经保存了用户定义的监听器。
有了这个,我们的listener
实现变成了:
function listener(event) {
const el = event.currentTarget;
const handler = el._ui.listeners[event.type];
const enqueue = el._ui.enqueue;
const msg = handler(event);
if (msg !== undefined) {
enqueue(msg);
}
}
要在节点创建时将enqueue
添加到_ui
,我们需要通过apply
modify
和create
传递它。
function apply(el, enqueue, childrenDiff) { ... }
function modify(el, enqueue, diff) { ... }
function create(enqueue, vnode) { ... }
有了这些,我们的完整库现在完成了!你可以在这里看到完整代码。