虚拟DOM和diff算法的原理和实现
一、什么是虚拟DOM
虚拟DOM就是将真实DOM映射成一个数据对象,这个数据对象将以以下形式展现:
1 |
|
二、为什么需要虚拟DOM
在现实开发中,如果频繁去操作真实DOM,其开销是巨大的。
比如说反复修改一百次一个DOM节点的文本内容,如果每次都去操作真实DOM,那就会造成不必要的性能开销。因为其实真正应用到真实DOM的文本内容只有最后一次。
这个时候如果使用虚拟DOM来接收这些操作,那其实就只是将VNode对象中的text属性修改一百次,这样的性能开销基本可以小到忽略不计。
最后将虚拟DOM的最终结果应用到真实DOM上面去,这远比操作一百次真实DOM来的更有效率一些。
当然,这是比较极端的情况,像Chrome这类浏览器本身也有对这种一次性反复修改DOM的操作进行了优化。
而实际使用中,虚拟DOM更多时候是配合diff算法来优化性能的,以应对更复杂的DOM操作场景。
三、如何实现一个虚拟DOM
在第一节已经实现了一个虚拟DOM对象的数据结构,现在需要返回一个子节点也是虚拟DOM的对象,那么就需要做进一步处理:
1 |
|
四、什么是diff算法
接着说说diff算法,diff算法是一种对比算法,它能够高效率的比较出新旧两颗虚拟DOM树之间的差异,最后将差异的部分应用到真实DOM上面去 。
目前最主流的diff算法是snabbdom算法,像Vue用的就是改进版的snabbdom算法。
五、diff算法的实现思路
如果对两颗DOM树进行完全比较,那么时间复杂度起码会是O(n^3),当DOM树非常大的时候,这样的比较会非常消耗性能。
而现实开发中,很少出现跨层级调整节点的情况,所以diff算法就规定只在同层级之间比较。跨层级之间的调整直接暴力删除然后重新创建。
这样下来,时间复杂度就降低到了O(n)。
flowchart LR
direction LR
subgraph OLD_VDOM
direction TB
ul1(ul) ---> li1(li) & li2(li)
subgraph LI1
li1
li2
end
li1 ---> p1(p) & p2(p)
li2 ---> p3(p) & p4(p)
subgraph P1
p1
p2
p3
p4
end
end
subgraph NEW_VDOM
direction TB
ul2(ul) ---> li3(li) & li4(li)
subgraph LI2
li3
li4
end
li3 ---> p5(p) & p6(p)
li4 ---> p7(p) & p8(p)
subgraph P2
p5
p6
p7
p8
end
end
ul1 -.->|对比| ul2
LI1 -.->|对比| LI2
P1 -.->|对比| P2
接着,在对比的时候,算法会优先去比较每个节点的子节点,然后再去比较同层级的相邻节点,所以diff算法是一种深度优先的算法。
在比较的时候,算法首先会先比较节点的sel属性。如果节点之间sel属性不同,那么就直接暴力删除,然后创建一个新的节点。
如果sel属性相同,那就判断新旧VDOM有没有子节点,这里分为三种情况:
- 新VDOM没有子节点
说明新的VDOM是一个文本节点,那么就直接清空旧VDOM的innerHTML,再将text赋值给旧VDOM的innerText属性 - 新VDOM有子节点,旧VDOM没有子节点
那就清空旧VDOM的innerText,然后递归新VDOM的children创建Elemenet。 - 新VDOM有子节点,旧VDOM也有子节点
在这个情况便是diff算法要解决的核心问题,我们需要通过sel和key属性来对比每个子节点之间是否相同,然后决定是要创建、删除或重新调整顺序。
这种场景我们很自然地会想到使用双层嵌套的for循环来进行逐个节点的对比,这样做的时间复杂度将会是O(n^2),很显然不够高效。
而snabbdom算法给出的解决方案是使用首尾指针法+map映射表,可以将时间复杂度降低到O(n)。
具体做法就是:
先给新旧DOM树都定义一对Start和End指针,分别指向子元素数组的第一个跟最后一个,然后按以下四种情况逐个拿出来跟另一颗DOM树对应的节点进行对比:- 旧DOM树的Start节点跟新DOM树的Start节点进行对比。如果相同,指针都向前挪一位,进行下一对节点的对比。如果不同,就进入下一种情况
- 旧DOM树的End节点跟新DOM树的End节点进行对比。相同,指针向后挪一位,不同,进入下一种情况
- 旧DOM树的Start节点跟新DOM树的End节点对比。相同,调整旧DOM树的节点位置,旧DOM树Start指针往前,新DOM树End指针往后。不同,进入下一种情况。
- 旧DOM树的End节点跟新DOM树的Start节点对比。相同,调整旧DOM树的节点位置,旧DOM树End指针往后,新DOM树Start指针往前。不同,就脱离了首尾指针算法的运算范畴,进入第五种情况,使用map映射表算法来查询节点。
- 取出旧DOM树每个节点的key属性和它的index序号,做成一个键值对映射表,接着拿新DOM树节点的key属性去查询这个映射表,如果查到了,就根据index序号调整节点位置,查不到就当场创建这个节点,并插入到旧DOM树Start节点的下面。
- 当以上循环跑完之后,如果出现旧DOM树的Start指针大于End指针,那就说明新DOM树有新增的节点,需要将新增的节点插入到新DOM树End指针指向的节点下面
- 如果是新DOM树的Start指针对于End指针,则说明新DOM树有删除掉的节点,这时只需要将旧DOM树Start指针到End指针之间的节点全部删除即可
到这里就完成了对diff算法的核心思路构想,接下来就是把它实现到代码中。
六、diff算法的具体实现
在snabbdom算法中,首先会有个patch函数将新虚拟DOM的改动应用到旧虚拟DOM之中,具体实现如下:
1 |
|
这里涉及两个新的函数createElm和patchVNode。
createElm实现如下:
1 |
|
而patchVNode的具体实现如下:
1 |
|
接下来就是diff算法最核心函数updateChildren的实现:
1 |
|
这样diff算法的核心实现就算完成了,而在Vue中实现的diff算法跟snabbdom算法是大同小异的,接下来我们可以简单看看Vue中的diff算法是怎么实现的。
七、diff算法在Vue中的实现
首先,大体说一下从Vue实例化开始到最终应用diff算法之间发生了什么事情:
在Vue实例化的时候,会给每个节点都挂上监听器Watcher,当节点上的数据变化是,即触发了setter,这个时候就会调用通知器dep,dep通过notify通知到监听器Watcher。接着Watcher就会通过update函数,对视图进行patch,在patch的过程中就用到了虚拟DOM和diff算法。最终将变化的那部分数据更新到真实DOM上面去。
上面这个过程我们可以分成三个部分:Vue实例化、VNode创建以及diff算法的应用,然后分别讲解这三个部分的具体实现。
- Vue实例化
1)初始化Vue
一般我们引入Vue的时候,会通过向new Vue()传入参数。这部分代码在src/core/instance/index.ts中:这段代码的核心就是将参数透传给init函数,init函数在src/core/instance/init.ts中:1
2
3
4
5
6function Vue(options) {
if (__DEV__ && !(this instanceof Vue)) {
warn('Vue is a constructor and should be called with the `new` keyword')
}
this._init(options)
}可以看到其核心就是调用$mount函数,将Vue挂载到传入的根元素上面去1
2
3
4
5
6
7
8
9
10Vue.prototype._init = function (options?: Object) {
const vm: Component = this
// 省略一系列其它初始化的代码
if (vm.$options.el) {
console.log('vm.$options.el:',vm.$options.el);
vm.$mount(vm.$options.el)
}
}
2)Vue实例挂载
$mount的实现则是在src/platforms/web/runtime-with-compiler.ts之中:
1
2
3
4
5
6
7
8
9
10
11const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && query(el)
// 省略一系列初始化以及逻辑判断代码
return mount.call(this, el, hydrating)
}
可以看到$mount是调用Vue原型上的mount函数,这个在src/platforms/web/runtime/index.ts:
1
2
3
4
5
6
7Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && inBrowser ? query(el) : undefined
return mountComponent(this, el, hydrating)
}
可以看到这个原型mount本质上就是调用了mountComponent,这个函数定义在src/core/instance/lifecycle.ts中:
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
44export function mountComponent (
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
// 省略一系列其它代码
let updateComponent
/* istanbul ignore if */
if (__DEV__ && config.performance && mark) {
updateComponent = () => {
const name = vm._name
const id = vm._uid
const startTag = `vue-perf-start:${id}`
const endTag = `vue-perf-end:${id}`
mark(startTag)
const vnode = vm._render()
mark(endTag)
measure(`vue ${name} render`, startTag, endTag)
mark(startTag)
vm._update(vnode, hydrating)
mark(endTag)
measure(`vue ${name} patch`, startTag, endTag)
}
} else {
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
}
// 实例化一个渲染Watcher,在它的回调函数中会调用 updateComponent 方法
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
hydrating = false
return vm
}
可以看到这段代码的核心就是实例化一个监听器Watcher,它的回调函数会调用updateComponent。而在updateComponent函数中,就会通过vm._render创建VNode,接着通过vm._update将变化的部分更新到真实DOM之中。
VNode创建
1)_render函数
_render函数用于将一个实例转化成VNode,它的实现在src/core/instance/render.ts之中1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17Vue.prototype._render = function (): VNode {
const vm: Component = this
const { render, _parentVnode } = vm.$options
let vnode
try {
// 省略一系列代码
currentRenderingInstance = vm
// 调用 createElement 方法来返回 vnode
vnode = render.call(vm._renderProxy, vm.$createElement)
} catch (e) {
handleError(e, vm, `render`){}
}
// set parent
vnode.parent = _parentVnode
console.log("vnode...:",vnode);
return vnode
}这里调用了$createElement来创建并返回VNode。
2)_createElement
它的实现在src/core/vdom/create-elemenet.ts中: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
49export function _createElement (
context: Component,
tag?: string | Class<Component> | Function | Object,
data?: VNodeData,
children?: any,
normalizationType?: number
): VNode | Array<VNode> {
// 省略一系列非主线代码
if (normalizationType === ALWAYS_NORMALIZE) {
// 场景是 render 函数不是编译生成的
children = normalizeChildren(children)
} else if (normalizationType === SIMPLE_NORMALIZE) {
// 场景是 render 函数是编译生成的
children = simpleNormalizeChildren(children)
}
let vnode, ns
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
)
} else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
// component
vnode = createComponent(Ctor, data, context, children, tag)
} else {
vnode = new VNode(
tag, data, children,
undefined, undefined, context
)
}
} else {
vnode = createComponent(tag, data, context, children)
}
if (Array.isArray(vnode)) {
return vnode
} else if (isDef(vnode)) {
if (isDef(ns)) applyNS(vnode, ns)
if (isDef(data)) registerDeepBindings(data)
return vnode
} else {
return createEmptyVNode()
}
}_createElement方法有5个参数:
context表示VNode上下文环境,它是Component类型。
tag表示标签,对应snabbdom中的sel,它可以是一个字符串,也可以是一个Component,Function或者Object
data对应snabbdom中的data,用来保存key、class、attrs等内容,其定义可以在src/core/instance/types/vnode.ts中看到
children也对应snabbdom中的children,用来保存子节点的信息,一般是一个VNode数组
这个函数最终返回的就是一个VNode数据结构3)VNode
它的定义在src/core/vdom/vnode.ts之中: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
64export default class VNode {
tag: string | void;
data: VNodeData | void;
children: ?Array<VNode>;
text: string | void;
elm: Node | void;
ns: string | void;
context: Component | void; // rendered in this component's scope
key: string | number | void;
componentOptions: VNodeComponentOptions | void;
componentInstance: Component | void; // component instance
parent: VNode | void; // component placeholder node
// strictly internal
raw: boolean; // contains raw HTML? (server only)
isStatic: boolean; // hoisted static node
isRootInsert: boolean; // necessary for enter transition check
isComment: boolean; // empty comment placeholder?
isCloned: boolean; // is a cloned node?
isOnce: boolean; // is a v-once node?
asyncFactory: Function | void; // async component factory function
asyncMeta: Object | void;
isAsyncPlaceholder: boolean;
ssrContext: Object | void;
fnContext: Component | void; // real context vm for functional nodes
fnOptions: ?ComponentOptions; // for SSR caching
devtoolsMeta: ?Object; // used to store functional render context for devtools
fnScopeId: ?string; // functional scope id support
constructor (
tag?: string,
data?: VNodeData,
children?: ?Array<VNode>,
text?: string,
elm?: Node,
context?: Component,
componentOptions?: VNodeComponentOptions,
asyncFactory?: Function
) {
this.tag = tag
this.data = data
this.children = children
this.text = text
this.elm = elm
this.ns = undefined
this.context = context
this.fnContext = undefined
this.fnOptions = undefined
this.fnScopeId = undefined
this.key = data && data.key
this.componentOptions = componentOptions
this.componentInstance = undefined
this.parent = undefined
this.raw = false
this.isStatic = false
this.isRootInsert = true
this.isComment = false
this.isCloned = false
this.isOnce = false
this.asyncFactory = asyncFactory
this.asyncMeta = undefined
this.isAsyncPlaceholder = false
}
}其中最主要的就是tag、data、children、text、elm和key这六个属性,跟snabbdom的vnode数据结构是一一对应的
diff算法
通过上述的分析,我们可以知道监听器Watcher在调用update的时候,其实就是调用了updateComponent这个回调函数来创建VNode。而在创建完VNode之后呢,接下来就会调用vm._update函数来完成视图的更新操作。1)_update函数
这个vm._update的实现就在src/core/instance/lifecycle.ts里面: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
30Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
const vm: Component = this
const prevEl = vm.$el
const prevVnode = vm._vnode
const restoreActiveInstance = setActiveInstance(vm)
vm._vnode = vnode
// Vue.prototype.__patch__ is injected in entry points
// based on the rendering backend used.
if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}
restoreActiveInstance()
// update __vue__ reference
if (prevEl) {
prevEl.__vue__ = null
}
if (vm.$el) {
vm.$el.__vue__ = vm
}
// if parent is an HOC, update its $el as well
if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
vm.$parent.$el = vm.$el
}
// updated hook is called by the scheduler to ensure that children are
// updated in a parent's updated hook.
}这个函数的中最为重要的就是一个__patch__函数,这个函数对应就是snabbdom中的patch.js,其内部就实现了diff算法。
2)patch函数
patch的实现在src/core/vdom/patch.ts中:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17function patch (oldVnode, vnode, hydrating, removeOnly) {
......
if (isUndef(oldVnode)) {
// 当oldVnode不存在时,创建新的节点
isInitialPatch = true
createElm(vnode, insertedVnodeQueue)
}
else {
// 对oldVnode和vnode进行diff,并对oldVnode打patch
const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// patch existing root node
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
}
......
}
}到这里基本逻辑就跟snabbdom算法差不多,存在虚拟节点并且相同就调用patchVnode来比较,不同就是暴力创建。
3)patchVnode函数
这个同样在src/core/vdom/patch.ts中定义: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
31function patchVnode (oldVnode, vnode, insertedVnodeQueue, ownerArray, index, removeOnly) {
......
const elm = vnode.elm = oldVnode.elm
const oldCh = oldVnode.children
const ch = vnode.children
// 如果vnode没有文本节点
if (isUndef(vnode.text)) {
// 如果oldVnode的children属性存在且vnode的children属性也存在
if (isDef(oldCh) && isDef(ch)) {
// updateChildren,对子节点进行diff
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) {
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(ch)
}
// 如果oldVnode的text存在,那么首先清空text的内容,然后将vnode的children添加进去
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
// 删除elm下的oldchildren
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
// oldVnode有子节点,而vnode没有,那么就清空这个节点
nodeOps.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
// 如果oldVnode和vnode文本属性不同,那么直接更新真是dom节点的文本元素
nodeOps.setTextContent(elm, vnode.text)
}
......
}这个函数的大部分实现也跟snabbdom差不多。当新旧两个VNode都有子节点的时候,就调用updateChildren进行对比,也就是diff算法的核心实现。
4)updateChildren函数
同样在src/core/vdom/patch.ts中实现了: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
64function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
// 为oldCh和newCh分别建立索引,为之后遍历的依据
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
// 直到oldCh或者newCh被遍历完后跳出循环
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
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)) { // New element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
vnodeToMove = oldCh[idxInOld]
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldCh[idxInOld] = undefined
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// same key but different element. treat as new element
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)
}
}很明显也是用的首尾指针法跟生成key map来实现diff算法,前面把snabbdom算法琢磨透了,这里扫一眼就过了。
参考
snabbdom源码
虚拟DOM 和 diff算法
深入剖析:Vue核心之虚拟DOM
15张图,20分钟吃透Diff算法核心原理,我说的!!!
Vue源码
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!