熟悉我的人想必都知道,我的主要技术栈是Vue。虽然平时没怎么接触过React,但是原理和Vue是差不多的–都是通过构建虚拟DOM,然后利用diff算法更新真实DOM实现页面的更新。今天我们就一起来学习虚拟DOM。


相信从事过前端开发的童靴都知道,操作Virtual DOM会比操作原生DOM要快。但是为什么说操作原生DOM慢呢?

为什么操作原生DOM慢?

我们知道执行JS代码有一个JS引擎,执行渲染也有一个渲染引擎。
一方面当我们使用JS去操作DOM时,涉及到两个线程之间的通信,会产生一部分性能的损耗。如果操作DOM的次数过多,那么就相当于这两个线程一直在进行通信。
另一方面当我们操作DOM的时候,很容易就引起重绘和回流操作,对性能会产生很大的影响。

虚拟DOM

相较于直接操作DOM,操作JS对象会快很多。Virtual DOM就是用一个原生JS对象去描述一个DOM节点,当你使用jQuery或者原生api去操作DOM时,浏览器很可能会出现重绘和回流操作。
比如当你在一次操作时,需要更新100个DOM节点,理想情况是DOM节点更新完毕后,浏览器进行一次回流操作,将修改的节点信息渲染出来。
但是浏览器没有那么智能,当它收到第一个更新DOM的请求时,并不知道后来还有99个,因此会马上执行更新操作,这样就会出现100次更新和100次回流。
Virtual DOM就是为了解决类似问题而被设计出来的,虚拟DOM不会立即操作DOM,而是将这100次操作作用在虚拟DOM上,最终通过diff函数生成真实DOM的补丁用来更新真实DOM,通知浏览器去执行绘制工作。
在Vue中,Virtual DOM是用VNode这样的一个类去表示。

VNode对象

一个VNode的实例对象包括以下属性

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
export interface VNode {
// 当前节点的标签名
tag?: string;
// 当前节点的数据对象
data?: VNodeData;
// 当前节点的子节点
children?: VNode[];
// 当前节点的文本,一般注释节点会有该属性
text?: string;
// 当前节点对应的真实节点
elm?: Node;
// 当前节点的nameSpace
ns?: string;
// 当前节点的作用域
context?: Vue;
// 节点的key,有利于diff执行效率
key?: string | number;
// 创建组件实例时会用到的选项信息
componentOptions?: VNodeComponentOptions;
// 组件实例
componentInstance?: Vue;
// 组件的父节点
parent?: VNode;
raw?: boolean;
// 静态节点的标识
isStatic?: boolean;
// 是否作为根节点插入
isRootInsert: boolean;
// 当前节点是否是注释节点
isComment: boolean;
}

下面我们来简单模拟一段代码

1
2
3
<ul class='list'>
<li>1</li>
</ul>

以上代码转换成VNode就是

1
2
3
4
5
6
7
8
9
10
const ul = {
tag: 'ul',
props: {
class: 'list'
},
children: {
tag: 'li',
children: '1'
}
}

用js对象模拟DOM节点的好处是,页面的更新可以先全部反映在js对象上,操作内存中的js对象的速度显然要快多了。等更新完后,再将最终的js对象映射成真实的DOM,交由浏览器去绘制。
那么具体是怎么实现的呢?我们来看看Vue中createElement方法

createElement解析

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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
const SIMPLE_NORMALIZE = 1
const ALWAYS_NORMALIZE = 2

export function createElement (
context: Component,
tag: any,
data: any,
children: any,
normalizationType: any,
alwaysNormalize: boolean
): VNode | Array<VNode> {
// 兼容没有data的情况
if (Array.isArray(data) || isPrimitive(data)) {
normalizationType = children
children = data
data = undefined
}
// 如果alwaysNormalize是true
// 那么normalizationType应该设置为常量ALWAYS_NORMALIZE的值
if (isTrue(alwaysNormalize)) {
normalizationType = ALWAYS_NORMALIZE
}
调用_createElement创建虚拟节点
return _createElement(context, tag, data, children, normalizationType)
}

export function _createElement (
context: Component,
tag?: string | Class<Component> | Function | Object,
data?: VNodeData,
children?: any,
normalizationType?: number
): VNode | Array<VNode> {
// 判断是否存在data.__ob__, 如果存在说明data是被监控的数据
// 不能作为虚拟节点,抛出警告然后返回一个空节点

// data 被监控不能作为虚拟节点的原因:
// data在vnode渲染过程中可能会被改变,这样会触发监控,导致不符合预期的操作
if (isDef(data) && isDef((data: any).__ob__)) {
process.env.NODE_ENV !== 'production' && warn(
`Avoid using observed data object as vnode data: ${JSON.stringify(data)}\n` +
'Always create fresh vnode data objects in each render!',
context
)
return createEmptyVNode()
}
// 当组件的is属性被设置为一个falsy的值
// Vue将不会知道要把这个组件渲染成什么
// 所以渲染一个空节点
if (isDef(data) && isDef(data.is)) {
tag = data.is
}
if (!tag) {
return createEmptyVNode()
}
// 确保节点的key必须为String/Number
if (process.env.NODE_ENV !== 'production' &&
isDef(data) && isDef(data.key) && !isPrimitive(data.key)
) {
if (!__WEEX__ || !('@binding' in data.key)) {
warn(
'Avoid using non-primitive value as key, ' +
'use string/number value instead.',
context
)
}
}
// 支持使用作用域插槽
if (Array.isArray(children) &&
typeof children[0] === 'function'
) {
data = data || {}
data.scopedSlots = { default: children[0] }
children.length = 0
}
// 根据normalizationType的值,选择不同的处理方法
if (normalizationType === ALWAYS_NORMALIZE) {
children = normalizeChildren(children)
} else if (normalizationType === SIMPLE_NORMALIZE) {
children = simpleNormalizeChildren(children)
}
let vnode, ns
// 标签名是string时执行下面的方法
if (typeof tag === 'string') {
let Ctor
// 获取标签的命名空间
ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
// 判断是否是保留的标签
if (config.isReservedTag(tag)) {
// 如果是保留标签,就创建一个这样的vnode
vnode = new VNode(
config.parsePlatformTagName(tag), data, children,
undefined, undefined, context
)
// 如果不是保留标签,那么我们将尝试从vm的components上查找是否有这个标签的定义
} else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
// 如果找到了这个标签的定义,就以此创建虚拟组件节点
vnode = createComponent(Ctor, data, context, children, tag)
} else {
// 正常创建一个VNode
vnode = new VNode(
tag, data, children,
undefined, undefined, context
)
}
// 当标签不是string时就直接创建
} else {
// direct component options / constructor
vnode = createComponent(tag, data, context, children)
}
if (Array.isArray(vnode)) {
// 如果vnode是一个数组就直接返回
return vnode
} else if (isDef(vnode)) {
// 如果有namespace,就应用下namespace,然后返回vnode
if (isDef(ns)) applyNS(vnode, ns)
if (isDef(data)) registerDeepBindings(data)
return vnode
} else {
// 否则,返回一个空节点
return createEmptyVNode()
}
}

或许看上面的代码有点晕,所以我在网上找了张图可以对照着理解上述代码

patch 实现

关于patch的实现,代码量太大了,所以就不在这儿贴出来了,在这里我简述一下它的比较过程,感兴趣的童靴可以去看看Vue源码,源码位于/src/core/vdom/patch.js
首先patch接收四个参数

  • oldVnode:旧的虚拟节点或者当前真实节点
  • vnode:新的虚拟节点
  • hydrating:是否要和真的dom混合
  • removeOnly:特殊flag,用于组件

patch函数的执行过程:

  • 如果vnode不存在,但是oldVnode存在,说明需要删除此节点,调用invokeDestroyHook(oldVnode)
  • 如果vnode存在但是oldVnode不存在,说明此节点是新加的节点,调用createElm(vnode, insertedVnodeQueue)创建新节点
  • 如果vnode和oldVnode都存在的情况,就需要比较节点值了。当两个节点值的比较的时候,就调用patchVnode函数。节点的比较主要分为5种情况:
  1. 当它们的引用一致时,可以认为是没有变化,不需要进行操作。
1
2
3
if (oldVnode === vnode) {
return
}
  1. 文本节点的比较,需要修改则调用setTextContent。
1
2
3
if (oldVnode.text !== vnode.text) {
nodeOps.setTextContent(elm, vnode.text)
}
  1. 两个节点都有子节点,而且它们不一样,这是就调用updateChildren进行子节点的比较。
1
2
3
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
}
  1. 只有新的节点有有子节点,调用addVnodes方法。
1
2
3
4
5
6
7
if (isDef(ch)) {
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(ch)
}
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
}
  1. 新节点没有子节点,老节点有子节点,直接删掉老节点。
1
2
3
if (isDef(oldCh)) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1)\
}

1245步执行的操作都比较简单,最重要的还是updateChildred,为了形象的展示updateChildren所进行的操作,我们来看看这样的一张图。

过程可以概括为:oldCh和newCh各有两个头尾的变量StartIdx和EndIdx,它们的2个变量相互比较,一共有4种比较方式。如果4种比较都没匹配,如果设置了key,就会用key进行比较,在比较的过程中,变量会往中间靠,一旦StartIdx>EndIdx表明oldCh和newCh至少有一个已经遍历完了,就会结束比较。
在updateChildren中,算法如下:

  • 分别获取oldVnode和vnode的firstChild、lastChild,赋值给oldStartVnode、oldEndVnode、newStartVnode、newEndVnode
  • 如果oldStartVnode和newStartVnode是同一节点,调用patchVnode进行patch,然后将oldStartVnode和newStartVnode都设置为下一0个子节点,重复上述流程。

  • 如果oldEndVnode和newEndVnode是同一节点,调用patchVnode进行patch,然后将oldEndVnode和newEndVnode都设置为上一个子节点,重复上述流程。

  • 如果oldStartVnode和newEndVnode是同一节点,调用patchVnode进行patch,如果removeOnly是false,那么可以把oldStartVnode.elm移动到oldEndVnode.elm之后,然后把oldStartVnode设置为下一个节点,newEndVnode设置为上一个节点,重复上述流程。

  • 如果newStartVnode和oldEndVnode是同一节点,调用patchVnode进行patch,如果removeOnly是false,那么可以把oldEndVnode.elm移动到oldStartVnode.elm之前,然后把newStartVnode设置为下一个节点,oldEndVnode设置为上一个节点,重复上述流程

  • 如果以上都不匹配,就尝试在oldChildren中寻找跟newStartVnode具有相同key的节点,如果找不到相同key的节点,说明newStartVnode是一个新节点,就创建一个,然后把newStartVnode设置为下一个节点

  • 如果上一步找到了跟newStartVnode相同key的节点,那么通过其他属性的比较来判断这2个节点是否是同一个节点,如果是,就调用patchVnode进行patch,如果removeOnly是false,就把newStartVnode.elm插入到oldStartVnode.elm之前,把newStartVnode设置为下一个节点,重复上述流程

  • 如果在oldChildren中没有寻找到newStartVnode的同一节点,那就创建一个新节点,把newStartVnode设置为下一个节点,重复上述流程
  • 如果oldStartVnode跟oldEndVnode重合了,并且newStartVnode跟newEndVnode也重合了,这个循环就结束了。

Vue源码中这一块还是比较复杂的,本文只是抛砖引玉,希望小伙伴们还是能够亲自去看一看。

参考文档:
vue虚拟dom实现
Vue.js 技术揭秘
面试官:你了解vue的diff算法吗?
解析vue2.0的diff算法