译文:Understanding the Virtual DOM

| 字数 2498
  1. 1. 我们为什么需要虚拟 dom ?
  2. 2. Dom 并不是为此而生的
  3. 3. 虚拟 Dom 因此而出现
  4. 4. 虚拟 Dom 张什么样呢?
  5. 5. 虚拟 Dom 如何使用?
  6. 6. 总结
  7. 7. 附录:snabbdom
  8. 8. 动手模拟 patch 实现过程
  1. 1. 我们为什么需要虚拟 dom ?
  2. 2. Dom 并不是为此而生的
  3. 3. 虚拟 Dom 因此而出现
  4. 4. 虚拟 Dom 张什么样呢?
  5. 5. 虚拟 Dom 如何使用?
  6. 6. 总结
  7. 7. 附录:snabbdom
  8. 8. 动手模拟 patch 实现过程

本文为译文,原文地址:

Understanding the Virtual DOM

        我最近在写一些如何正确区分 dom 与 shadow dom 的文章。总的来说,dom 是HTML文档的基于对象的表示,以及操作该对象的接口。shadow dom 可以被认为是轻量级版本的 dom。它同样是以原生对象为基础的 html document,但是它并不是完整的。可能有些难懂,换一种说法来看,shadow dom 允许我们去将我们的 dom 划分的更小,更轻,可以跨文档使用。(这里作者想表达的意思是我们可以根据需要截取部分 dom 生成 vdom,而不用每次从 html 标签开始写 vdom 直到 html 标签闭合)

        另一个你可能遇到过的相似说法称 “shadow dom” 为 “virtual DOM”。尽管

这个说法已经存在了很多年,但是它真正的流行是在 react 使用它之后。在这篇文章中我将尽力阐述什么是虚拟 dom ,它与原生 dom 又什么不同以及如何使用它。

我们为什么需要虚拟 dom ?

想要了解虚拟dom为什么会出现,让我们先回顾以下原生dom。正如我所提到的,dom 是HTML文档的基于对象的表示,以及操作该对象的接口。举个例子看看:

1
2
3
4
5
6
7
8
9
<!doctype html>
<html lang="en">
<head></head>
<body>
<ul class="list">
<li class="list__item">List item</li>
</ul>
</body>
</html>

可以用如下 dom 树来表示:

让我们来进行以下操作:

  • 修改第一个 li 的内容为 list item one
  • 加上一个 li

要完成上述操作需要创建新节点,添加新属性与内容,最终完成更新:

1
2
3
4
5
6
7
8
const listItemOne = document.getElementsByClassName("list__item")[0];
listItemOne.textContent = "List item one";

const list = document.getElementsByClassName("list")[0];
const listItemTwo = document.createElement("li");
listItemTwo.classList.add("list__item");
listItemTwo.textContent = "List item two";
list.appendChild(listItemTwo);

Dom 并不是为此而生的

        当1998年发布DOM的第一个规范时,我们以非常不同的方式构建和管理web页面。很少像今天这样依赖DOM api来创建和更新页面内容。

document.getElementsByClassName() 这个方法小规模使用没有问题,但是如果在同一页面间隔很短的情况下去更新多个元素,就会使对于 dom 的查询与更新操作变得很昂贵。此外,更新文档中较大一部分比更新特定元素的开销会小一些。回到我们列表例子中,从某种程度上来说用新元素替换整个无序列表比修改某个特定元素要简单一些。代码如下:

1
2
3
4
5
const list = document.getElementsByClassName("list")[0];
list.innerHTML = `
<li class="list__item">List item one</li>
<li class="list__item">List item two</li>
`;

在这俩个例子中,性能差异并不大。但是随着页面数量的增加,选择、更新我们需要的代码会显得尤为重要。

虚拟 Dom 因此而出现

        虚拟 Dom 的出现是为了用更加高效的方式来处理频繁更新 dom 所产生的问题。与 dom 和 shadow dom 不同的是,虚拟 dom 不是一种正式的规范,它更像一种与 dom 交互的新方法。

        虚拟 dom 可以被认为是原生 dom 的一个复制品。在这个复制品上我们可以不通过 dom api 就能频繁更新数据。一旦对虚拟DOM进行了所有更新,我们就可以查看需要对原始DOM进行哪些特定更改,并且使这些改变更加具体与优化。

虚拟 Dom 张什么样呢?

        一听到虚拟,可能会觉得有些神秘感。其实虚拟 dom 就是 js 对象。让我们再来回顾先前创建的 dom 树:

这个树可以表示为如下 js 对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const vdom = {
tagName: "html",
children: [
{ tagName: "head" },
{
tagName: "body",
children: [
{
tagName: "ul",
attributes: { "class": "list" },
children: [
{
tagName: "li",
attributes: { "class": "list__item" },
textContent: "List item"
} // end li
]
} // end ul
]
} // end body
]
} // end html

我们完全可以把这个 js 对象当作虚拟 dom 。我们可以根据需要自由的频繁操作它而不改变原生 dom。

相比于使用整个对象,我们通常的做法是使用部分虚拟 dom。举个例子,我们要操作一个 list 组件,这个组件与我们的无序列表元素相关联。具体如下:

1
2
3
4
5
6
7
8
9
10
11
const list = {
tagName: "ul",
attributes: { "class": "list" },
children: [
{
tagName: "li",
attributes: { "class": "list__item" },
textContent: "List item"
}
]
};

虚拟 Dom 如何使用?

        现在我们见识过了虚拟dom的样子,那么它是如何处理dom操作的性能问题?

正如我所提到的,虚拟 dom 可以专门用来对你需要改变的元素进行操作。(不影响没有改变的元素)让我们重回无序列表的例子。

第一件事我们应该复制dom来产生虚拟dom,然后对需要改变的元素在虚拟dom中进行改变操作。如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const copy = {
tagName: "ul",
attributes: { "class": "list" },
children: [
{
tagName: "li",
attributes: { "class": "list__item" },
textContent: "List item one"
},
{
tagName: "li",
attributes: { "class": "list__item" },
textContent: "List item two"
}
]
};

这个复制出来的虚拟 dom 用来和原生 dom 进行比较,从而把比较出来的差异用创建的 diff 来保存。 diff 是像这个样子的:

1
2
3
4
5
6
7
8
9
10
11
const diffs = [
{
newNode: { /* new version of list item one */ },
oldNode: { /* original version of list item one */ },
index: /* index of element in parent's list of child nodes */
},
{
newNode: { /* list item two */ },
index: { /* */ }
}
]

这个 diff 提供了一个结构,这个结构可以用来更新原生的 dom。一旦所有的 diff 检测完毕,我们可以对原生 dom 只进行一次更新操作即可。

在下面的例子中,我们对 diff 进行循环,不论是添加新的元素还是更新旧的元素,我们都可以像下面这样做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const domElement = document.getElementsByClassName("list")[0];

diffs.forEach((diff) => {

const newElement = document.createElement(diff.newNode.tagName);
/* Add attributes ... */

if (diff.oldNode) {
// If there is an old version, replace it with the new version
domElement.replaceChild(diff.newNode, diff.index);
} else {
// If no old version exists, create a new node
domElement.appendChild(diff.newNode);
}
})

总结

  • 虚拟dom是让我们与dom交互更加高效的且性能更好一种方法
  • 虚拟dom是一个js对象,它允许我们对js对象进行频繁的修改
  • 所有的修改在虚拟dom中结束后,我们可以一次性对原生dom中需要改变的地方进行更新

附录:snabbdom

实现 vdom 的库不多,snabbdom 算是佼佼者。vue 中集成了它的核心代码,在每次修改数据后,都会执行函数进行 diff。下面总结一下非框架下vdom的使用方法:

1
2
3
4
5
6
7
8
9
<div id="container"></div>
<button id="btn-submit">change</button>

<script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom.js"></script>
<script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom-class.js"></script>
<script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom-props.js"></script>
<script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom-style.js"></script>
<script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom-eventlisteners.js"></script>
<script src="https://cdn.bootcss.com/snabbdom/0.7.3/h.js"></script>

以上是html结构,引入不同的snabbdom文件是对应解析绑定的事件、属性等。首先使用 patch(c, vnode) 创建无序列表如下:

单击按钮再次调用 patch 函数即可生成:

具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
const snabbdom = window.snabbdom

// init snabbdom
let patch = snabbdom.init([
snabbdom_class,
snabbdom_props,
snabbdom_style,
snabbdom_eventlisteners
])

//define h()
let h = snabbdom.h
let c = document.getElementById('container')
let btn = document.getElementById('btn-submit')

// create vnode
let vnode = h('ul#list', {}, [
h('li.item', {}, 'item1'),
h('li.item', {}, 'item2')
])

// first time create vdom and transform it to dom
patch(c, vnode)

btn.addEventListener('click', () => {
// define new node
let newNode = h('ul#list', {}, [
h('li.item', {}, 'item1'),
h('li.item', {}, 'item22'),
h('li.item', {}, 'item3')
])
// find diffs , update diffs into original DOM
patch(vnode, newNode)
vnode = newNode
})

h 函数的作用是将传入的数据转化为vnode(vnode具体格式参照上述译文中的 copy),patch 函数先判断是否存在 vnode,不存在的话直接将 vnode,生成 dom,插入目标中;若存在进行 diff 算法,找出差异后对原生 dom 进行更新。

动手模拟 patch 实现过程

patch 函数有俩种用法:

  • patch(container, vnode)
  • patch(vnode, newNode)

先来看第一种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
tag: 'ul',
attrs: {
id: 'list'
},
children: [
{
tag: 'li',
attrs: {
classname: 'item'
},
children: ['item1']
}
]
}
1
2
3
<ul id="list">
<li class="item">item 1</li>
</ul>

首先得将vnode转化为html结构,才能加入到container种,那么如何将上述js对象(vnode)转化为 html 呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function createEl(vnode) {
let tag = vnode.tag
let attrs = vnode.attrs || {}
let children = vnode.children || []

if(!tag) return null;

let el = document.createElement(tag)
for(let attrName in attrs) {
if(attrs.hasOwnProperty(attrName)){
el.setAttribute(attrName, attrs[attrName])
}
}

children.forEach(v => {
el.append(createElement(v))
})

return el;
}

大概就是这样,利用递归循环遍历 children,设置 !tag 为终止条件。

第二种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
tag: 'ul',
attrs: {
id: 'list'
},
children: [
{
tag: 'li',
attrs: {
classname: 'item'
},
children: ['item12']
},
{
tag: 'li',
attrs: {
classname: 'item'
},
children: ['item2']
}
]
}
1
2
3
4
<ul id="list">
<li class="item">item 12</li>
<li class="item">item 2</li>
</ul>

这里我们想要将列表更新为上述结构,使用 patch(vnode, newNode) 方法,这个方法中肯定是要不断的去对比,不断的对比children肯定还会用到递归,模拟代码(只考虑最简单的情况)如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function updateEl(vnode, newNode) {
let children = vnode.children || []
let newChildren = newNode.children || []

children.forEach((child, idx) => {
let newChild = newChildren[idx]
if(newChild === null) return ;
if(child.tag === newChild.tag) {
// the same tag
updateEl(child, newChild)
}else{
// the different tag
replaceNode(child, newChild)
}
})
}