一个200行JavaScript代码的虚拟DOM
在这篇文章中,我将详细介绍一个超过200行的完整虚拟DOM的实现。
结果是一个功能完备且性能足够的虚拟DOM库(演示)。
它在NPM上作为smvc
包提供。
主要目标是阐释像React这样的工具背后的基本技术。
React、Vue和Elm语言都通过允许您描述页面的外观,而不必担心添加/删除元素,来简化交互式网页的创建。
虚拟DOM的目标
这不是关于性能的。
虚拟DOM是一个抽象,用于简化UI的修改行为。
您描述您希望页面看起来如何,库会负责将DOM从当前状态更改为您想要的状态。
关键思想
库将接管一个单一的DOM元素并在其中操作。
这个元素最初应该是空的,我们假设除了我们的库之外,没有任何东西会修改它。
这将是用户应用程序的根。
如果我们只能修改它,那么我们就可以确切知道这个元素内部有什么,而无需检查它。
怎么做?通过跟踪我们到目前为止对它所做的所有修改。
我们将通过保持一个包含每个HTML元素简化表示的结构来跟踪我们的根节点内部是什么。
或者更准确地说,每个DOM节点。
因为这种表示是DOM节点的反映,但它不在真实的DOM中,让我们称它为虚拟节点,它将构成我们的虚拟DOM。
用户永远不会创建真实的DOM节点,只有那些虚拟的。
他们将通过使用虚拟节点告诉我们整个页面应该如何看起来。
然后我们的库将负责修改真实的DOM,使其符合我们的表示。
为了知道要修改什么,我们的库将获取用户创建的虚拟DOM,并将其与代表页面当前外观的虚拟DOM进行比较。这个过程称为diffing。
它将记录差异,例如应该添加或删除哪些元素,以及应该添加或删除哪些属性。diffing的输出是一个虚拟DOMdiff。
然后我们将apply那个diff中的更改到真实的DOM上。一旦我们完成修改,
用户创建的虚拟DOM现在已经成为真实DOM的当前忠实表示。
所以,对于UI部分,我们需要:
- Create 一个DOM的虚拟表示
- Diff 虚拟DOM节点
- Apply 一个虚拟DOM diff到一个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: {},
children: [
{ text: "One" },
{ text: "Two" },
{ text: "Three" }
]
}
然后后续的描述是这样的
{ tag: "div",
properties: {},
children: [
{ 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的副作用。
它的实现非常简单,只是分派每个子节点要执行的适当操作。
create
和modify
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',
'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
我们将以一种非常简单的方式进行。将有两种用户定义的值:
- 应用程序的状态:包含渲染VDOM所需的所有信息的值。
- 应用程序消息:包含有关如何更改状态的信息的值。
我们将要求用户实现两个函数:
view
函数接收应用程序状态并返回VDOM。update
函数接收应用程序状态和一条应用程序消息,并返回新的应用程序状态。
这足以构建任何复杂的应用程序。
用户在程序开始时提供这两个函数,VDOM库将控制何时调用它们。用户永远不直接调用它们。
我们还需要为用户提供一种通过update
函数处理消息的方式来发出消息。
我们将通过提供enqueue
函数来实现这一点,它将消息添加到要处理的消息队列中。
用户需要提供的最后几件事是一个初始状态,以开始,并提供一个HTML节点,在其中应该渲染VDOM。
有了这些最后的部件,我们就有完整的API。
我们定义一个名为init
的函数,它将从用户那里获取所有所需的输入,并启动应用程序。
它将返回该应用程序的enqueue
函数。
这种设计允许我们在同一个页面上运行多个VDOM应用程序,每个应用程序都有自己的enqueue
函数。
这里是一个使用此设计实现的计数器:
计数器:0
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
,而不会破坏我们的应用程序。
这意味着我们应该接受enqueue
甚至在update
函数内部被调用。
我们将通过解耦消息排队、更新状态和更新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
返回,它期望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('增加计数器')])
酷,我们如何实现它?我们单一的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) { ... }
有了这些,我们的完整库现在就完成了!您可以在这里查看完整代码。
参考资源
传送门