聊聊Vue的virtual-dom实现原理

首先,Vue在1.0的时代还没有虚拟dom这个概念,1.0时候频繁操作dom使用的是fragment来做修改,当然和vdom是无法媲美了,但是和innerHTML来比它还是提高了很大程度的性能的,毕竟fragment是可以比innerHTML减少很多次重排的。而Vue的virtual-dom(后文简称vdom吧~打字好累的哦~_~)是借鉴了React的,因为virtual-dom是React中的一个重要特性,而React是先于Vue发行问世的。所以很大程度上,Vue也是作为后来者吸取了React和Angular的经验,并在其上做了改进,才得以发展至今天的成果。

1. 为什么会有vdom的出现?

假设如果没有vdom,那么我们在事件绑定的方法中需要频繁地操作真实的dom来达到视图更新的目的,但是一旦我们这个应用变得非常大,那么由于频繁的dom操作带来的性能降级是非常大的,而且非常难以维护。所以,有了vdom的概念。具体是什么呢?就是将我们真实的dom抽象成一个以js对象构成的抽象树,然后在抽象树上做修改,将差异更新到真实的视图层。酱紫不仅减少了很多不必要的操作,还大大提高了性能,不得不说,大牛还是大牛~

2. 关于VNode

先看一眼vue源码中对于vnode的定义,目录:/src/core/vdom/vnode.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export default class VNode {
tag: string | void; // 当前节点的标签名
data: VNodeData | void; // 当前节点的data值,包括attrs,style,hook以及绑定的事件
children: ?Array<VNode>; // 当前节点的子节点,是一个数组
text: string | void; // 当前节点的文本内容
elm: Node | void; // 新旧虚拟节点对应的真实dom节点
ns: string | void; // 当前节点的名字空间
context: Component | void; // rendered in this component's scope 创建这个节点的上下文
key: string | number | void; // vnode的标记,在diff过程中可以提高diff的效率
componentOptions: VNodeComponentOptions | void; // 组件的option选项
componentInstance: Component | void; // component instance 当前节点对应的组件实例
parent: VNode | void; // component placeholder node 当前节点的父节点
···
}

打个比方,现在定义一个vnode的数据结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
tag: 'div',
data: {
class: 'app',
},
children: [
{
tag: 'div',
data: {
class: 'say'
},
text: 'hello world!'
}
]
}

在真实的dom中渲染是酱紫的:

1
2
3
<div class="app">
<div class="say">hello world!</div>
</div>

vnode的源码中还有一些createEmptyVNode(创建一个空VNode节点)、createTextVNode(创建一个文本节点)、createComponent(创建一个组件节点)、cloneVNode(克隆一个VNode节点)以及createElement(创建一个虚拟节点)等方法的定义,在此不多做赘述,回到更新视图。

3. 更新视图

聊聊Vue的双向数据绑定 中已经总结过,当数据发生改变的时候,会触发该数据的setter,而setter会触发Observer的Dep调用notify函数来通知对应的Watcher调用update方法来更新视图。接下来就看看update方法的定义,源码目录:src/core/instance/lifycycle.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) { // 接收的第一个参数就是vnode,在这里会将新旧vnode对象做__patch__
const vm: Component = this
const prevEl = vm.$el
const prevVnode = vm._vnode
const prevActiveInstance = activeInstance
activeInstance = vm
vm._vnode = vnode

if (!prevVnode) { // 如果需要diff的prevVnode不存在,那么就用新的vnode创建一个真实dom节点
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// 如果需要diff的prevVnode存在,那么首先对oldvnode(在这里的参数是prevVnode,代表旧vnode)和vnode进行diff,并将需要更新的dom操作以patch的形式打到oldvnode上,并完成真实dom的更新工作
vm.$el = vm.__patch__(prevVnode, vnode)
}
}

这里可以看到最为核心的diff过程,是通过vm.patch来完成的,这也是vdom的核心方法,主要完成了prevVnode和vnode的diff过程并根据需要操作的vdom节点打patch,最后新生成的真实dom节点并完成视图的更新工作。

4. diff过程 —— patch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// src/core/vdom/patch.js
function patch (oldVnode, vnode, hydrating, removeOnly) {
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// 当两个虚拟节点是同一个节点的时候(sameVnode),对oldVnode和vnode的children进行diff,并对oldVnode打patch
patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
} else {
// 如果新旧虚拟节点不一样,就说明vnode完全被改变了,则删除旧虚拟节点对应的真实dom并替换为新虚拟节点对应的真实dom
elm = oldVnode.elm as Node;
parent = api.parentNode(elm);

createElm(vnode, insertedVnodeQueue);

if (parent !== null) {
api.insertBefore(parent, vnode.elm as Node, api.nextSibling(elm));
removeVnodes(parent, [oldVnode], 0, 0);
}
}
}

上面可以看到只有在对oldVnode和vnode进行类型判断时有个sameVnode方法,这个方法决定了是否需要对oldVnode和vnode进行diff及patch的过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/core/vdom/patch.js
function sameVnode (a, b) {
return (
a.key === b.key && (
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
) || (
isTrue(a.isAsyncPlaceholder) &&
a.asyncFactory === b.asyncFactory &&
isUndef(b.asyncFactory.error)
)
)
)
}

它需要传入两个vnode进行基本属性的比较,只有当两个VNode的tag、key、isComment都相同,并且同时定义或未定义data的时候,且如果标签为input则type必须相同的情况下才认为这2个vnode只是局部发生了更新,然后才会对这2个vnode进行diff的patchVnode操作,如果这2个vnode的基本属性存在不一致的情况,那么就会直接跳过diff过程,然后根据vnode创建一个真实的dom,同时删除旧的dom节点。

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
function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
if (oldVnode === vnode) { // 两个vnode节点相同则直接返回
return
}

const oldCh = oldVnode.children
const ch = vnode.children

if (isUndef(vnode.text)) { // 如果vnode没有文本节点
if (isDef(oldCh) && isDef(ch)) { // 如果新老虚拟节点的子节点存在,则对两个虚拟节点的子节点进行diff操作,调用updateChildren方法
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) {
// 如果只有新虚拟节点存在子节点,老虚拟节点不存在子节点,假如旧虚拟节点的text属性有定义,说明旧的虚拟节点对应的真实节点有且仅有文本子节点,需要先清除文本节点,然后将新虚拟节点子节点对应的真实节点添加到当前真实节点的子节点。
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
// 如果新虚拟节点没有子节点,老虚拟节点有子节点的时候,则移除老虚拟节点对应的真实dom节点的子节点
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
// 当新老节点都无子节点的时候,只是文本的替换,因为这个逻辑中新节点text不存在,所以直接去除ele的文本
nodeOps.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
// 当新老节点text不一样时,直接替换这段文本
nodeOps.setTextContent(elm, vnode.text)
}
if (isDef(data)) {
if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
}
}

这里主要捋一下patchVnode的规则:

  • 首先进行文本节点判断,如果新老节点文本内容不同,则直接进行文本的替换
  • 在新虚拟节点没有文本节点的情况下进入其子节点的diff
  • 当新老节点都存在子节点且不相同的情况下,调用updateChildren方法对其子节点进行diff
  • 如果新虚拟节点存在子节点,老虚拟节点没有子节点,假设老虚拟节点有文本内容那就得先清除老节点的文本内容,然后调用addVnode方法将新的虚拟子节点添加到真实父节点中
  • 如果老节点有子节点而新节点没有子节点,则删除真实节点的老节点下的子节点
  • 如果新老节点都没有子节点,那就只做文本的替换

5. 更新子节点——updateChildren

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
let oldStartIdx = 0
let newStartIdx = 0
let oldEndIdx = oldCh.length - 1
let oldStartVnode = oldCh[0]
let oldEndVnode = oldCh[oldEndIdx]
let newEndIdx = newCh.length - 1
let newStartVnode = newCh[0]
let newEndVnode = newCh[newEndIdx]
let oldKeyToIdx, idxInOld, vnodeToMove, refElm

if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(newCh)
}

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx]
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
if (isUndef(idxInOld)) {
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
vnodeToMove = oldCh[idxInOld]
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
oldCh[idxInOld] = undefined
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
}
newStartVnode = newCh[++newStartIdx]
}
}
if (oldStartIdx > oldEndIdx) {
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
}

有没有觉得长的吐血???嗯,我难产了一个周的vdom源码分析,就是卡在这里,头大······所以换种思考分析方式,不一行一行分析了,先来说下这部分代码做了什么——

  • 首先,它给新旧孩子节点的队列头和尾均设置了变量标记,oldCh的为oldStartIdx和oldEndIdx,newCh的为newStartIdx和newEndIdx。
  • 接着对应索引和vnode间的关系:oldStartIdx => oldStartVnode,oldEndIdx => oldEndVnode,newSatrtIdx => newSatrtVnode,newEndIdx => newEndVnode
  • 对上述对应的四个变量两两做比较,2*2种情况:
    1. 如果oldStartVnode和newSatrtVnode或者如果oldEndtVnode和newEndVnode是同一个节点,则直接将该oldVnode节点进行patchVnode即可,完了变量指针向中间靠拢
    2. 如果oldStartVnode和newEndVnode是同一个节点,则说明原来的第一个节点跑到了最后一个位置,则进行patchvnode
      的同时还需要将对应的真实dom节点移动到oldEndVnode后面
    3. 如果oldEndVnode和newStartVnode是同一个节点,则说明原来的最后一个节点跑到了第一个位置,则进行patchvnode
      的同时还需要将对应的真实dom节点移动到oldStartVnode前面
    4. 如果上述的四种如果都不匹配——
      • 则会通过createKeyToOldIdx函数根据oldCh的key得到oldKeyToIdx,oldKeyToIdx是个map,其内部存放的是key为oldCh的Vnode,value为对应的index。用newStartVnode的key与map表做匹配,如果存在有一致的key并且同时满足sameVnode,那么patchVnode的同时会将成功匹配的节点移动到真实dom节点的前面;否则的话,就将newCh的指针后移并将指向的节点赋为newStartVnode
      • 如果newStartVnode的key在map中匹配不到一致的key或者匹配到了一致的key但是sameVnode结果为false,这时候就调用createElm方法创建一个新的节点插入到真实的dom节点
  • 当oldStartIdx > oldEndIdx或者 newStartIdx > newEndIdx时结束循环,这个时候说明至少有一个已经遍历完了就会结束比较,但是也需要处理多余或者不够的真实dom节点:
    1. 当结束时oldStartIdx > oldEndIdx,这个时候说明旧的vnode已经遍历完了,但是新的还没有,说明新的vnode节点比老的vnode多,也就是比真实的dom多,需要将剩下的vnode节点调用addVnodes(批量调用createElm方法)将这些节点根据自己的index插入到真实的dom中去
    2. 当结束时newSatrtIdx > newEndIdx,这个时候说明新的vnode已经遍历完了,但是老的节点还有剩余,需要将真实dom节点中多余的节点调用removeVnodes将多余的真实dom节点删除

我觉得上面的规则好多呀,有没有很头大,比刘智林的头还大的感觉?emmmm,那我来举几个图示的例子,你就懂了~

image

接下来开始第一次比较:

image

第二次比较:

image

第三次比较

image

第四次比较

image

最后一次比较!!!

image

我觉得我懂了,你懂了没。。。