Vue的虚拟DOM是怎样的? 为何要引入虚拟DOM? 它的好处是什么呢?

虚拟DOM

虚拟DOM = 描述真实DOM的js对象

1.为什么要有虚拟DOM?

原因有二:

1.虚拟DOM是对渲染过程的抽象,而不是一堆HTML,这意味着可以渲染到 web(浏览器) 以外的平台。

2.经过DOM0,2,3的层层补充加强,一个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
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
// 谷歌浏览器输入以下,你可以发现大约有249个属性
let str ='';for(const key in $$('div')[0]) {str +=`${key}'\n'`}

// 部分属性
sizzle-1584611898211
align
title
lang
translate
dir
hidden
accessKey
draggable
spellcheck
autocapitalize
contentEditable
isContentEditable
inputMode
offsetParent
offsetTop
offsetLeft
offsetWidth
offsetHeight
style
innerText
outerText
oncopy
oncut
onpaste
onabort
onblur
oncancel
oncanplay
oncanplaythrough
onchange
onclick
onclose
oncontextmenu
oncuechange
ondblclick
ondrag
ondragend
ondragenter
ondragleave
ondragover
ondragstart
ondrop
ondurationchange
onemptied
onended
onerror
onfocus
onformdata
oninput
oninvalid
onkeydown
onkeypress
onkeyup
onload
onloadeddata
onloadedmetadata
onloadstart
onmousedown
onmouseenter
onmouseleave
onmousemove
onmouseout
onmouseover
onmouseup
// .........

2.有了虚拟DOM之后怎么结合来优化渲染速度?

Vue的做法是:以虚拟DOM,结合变化侦测。利用js的计算性能来换取操作DOM的性能。通过diff算法来比较新旧DOM节点,以最小的代价来更新DOM,减少操作DOM次数,从而达到优化

templatenew Vue中的模板代码先被编译成VNode并被缓存起来,在监听到变化侦测后,对比缓存的VNode的差异,进行精准的更新(精准与否,就是屠宰和手术的区别~)

VNode类

这个类是vue中来进行DOM描述的核心类,提供一些属性,来描述一个真实DOM节点。

一个例子如下:

1
2
3
4
5
6
7
8
9
10
// body下的 <div id="v" class="classA"><div> 对应的 oldVnode 就是

{
el: div //对真实的节点的引用,本例中就是document.querySelector('#id.classA')
tagName: 'DIV', //节点的标签
sel: 'div#v.classA' //节点的选择器
data: null, // 一个存储节点属性的对象,对应节点的el[prop]属性,例如onclick , style
children: [], //存储子节点的数组,每个子节点也是vnode结构
text: null, //如果是文本节点,对应文本节点的textContent,否则为null
}

源码如下:

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
// 源码位置:src/core/vdom/vnode.js
export default class VNode {
constructor (
tag?: string,
data?: VNodeData,
children?: ?Array<VNode>,
text?: string,
elm?: Node,
context?: Component,
componentOptions?: VNodeComponentOptions,
asyncFactory?: Function
) {
this.tag = tag /*当前节点的标签名*/
this.data = data /*当前节点对应的对象,包含了具体的一些数据信息,是一个VNodeData类型,可以参考VNodeData类型中的数据信息*/
this.children = children /*当前节点的子节点,是一个数组*/
this.text = text /*当前节点的文本*/
this.elm = elm /*当前虚拟节点对应的真实dom节点*/
this.ns = undefined /*当前节点的名字空间*/
this.context = context /*当前组件节点对应的Vue实例*/
this.fnContext = undefined /*函数式组件对应的Vue实例*/
this.fnOptions = undefined
this.fnScopeId = undefined
this.key = data && data.key /*节点的key属性,被当作节点的标志,用以优化*/
this.componentOptions = componentOptions /*组件的option选项*/
this.componentInstance = undefined /*当前节点对应的组件的实例*/
this.parent = undefined /*当前节点的父节点*/
this.raw = false /*简而言之就是是否为原生HTML或只是普通文本,innerHTML的时候为true,textContent的时候为false*/
this.isStatic = false /*静态节点标志*/
this.isRootInsert = true /*是否作为跟节点插入*/
this.isComment = false /*是否为注释节点*/
this.isCloned = false /*是否为克隆节点*/
this.isOnce = false /*是否有v-once指令*/
this.asyncFactory = asyncFactory
this.asyncMeta = undefined
this.isAsyncPlaceholder = false
}

get child (): Component | void {
return this.componentInstance
}
}

组合设置类的一些属性,可以模拟出真实的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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// 注释节点
export const createEmptyVnode = (text: string = "") => {
const node = new Vnode();
node.text = text;
node.isComment = true;
return node;
}

// 文本节点
export function createTextVNode (val: string | number) {
return new VNode(undefined, undefined, undefined, String(val))
}
var createEmptyVNode = function (text) {
if ( text === void 0 ) text = '';

var node = new VNode();
node.text = text;
node.isComment = true;
return node
};

// optimized shallow clone
// used for static nodes and slot nodes because they may be reused across
// multiple renders, cloning them avoids errors when DOM manipulations rely
// on their elm reference.
function cloneVNode (vnode) {
var cloned = new VNode(
vnode.tag,
vnode.data,
// #7975
// clone children array to avoid mutating original in case of cloning
// a child.
vnode.children && vnode.children.slice(),
vnode.text,
vnode.elm,
vnode.context,
vnode.componentOptions,
vnode.asyncFactory
);
cloned.ns = vnode.ns;
cloned.isStatic = vnode.isStatic;
cloned.key = vnode.key;
cloned.isComment = vnode.isComment;
cloned.fnContext = vnode.fnContext;
cloned.fnOptions = vnode.fnOptions;
cloned.fnScopeId = vnode.fnScopeId;
cloned.asyncMeta = vnode.asyncMeta;
cloned.isCloned = true;
return cloned
}

还有元素节点,组件节点,函数式组件节点等等,都是经过VNode基类属性和部分其他属性组合来描述不同节点。

HcySunYang大佬的文章中,对如何对Vnode进行设计进行了详细的分析,强烈建议去看看

http://hcysun.me/vue-design/zh/vnode.html#%E7%94%A8-vnode-%E6%8F%8F%E8%BF%B0%E6%8A%BD%E8%B1%A1%E5%86%85%E5%AE%B9

给出了如下图所示的Vnode分类图

vnode types

很有趣的一个点是,在判断Vnode种类的过程中,使用了位运算(主要是<<左移运算符)和按位|还有按位&来对判断过程进行优化。恕在下见识短浅,还是第一次看见位运算在非运算的场景下的应用,感觉学到了。

Diff

​ 上文优化的关键点就是如何快速高效的找出数据更新前后的差异,也是FEer耳濡目染的DOM Diff算法,虚拟DOM核心所在。

​ 首先,相比于传统的Diff不会进行DOM节点的跨级的比较(划重点)reactvue在这点上一致。
然后,我们一般修改的节点的什么? props,data,class,event等等,也就是:1.节点属性2.子节点。这正是vue进行比较的核心部分。

比较过程

Vue中怎么找出需要更新的节点?为了便于理解,保留核心部分的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 源码位置: /src/core/vdom/patch.js
function patch (oldVnode, vnode) {
// ......some code
// 同一类型节点判断
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode)
} else {
// 当前oldVnode对应的真实元素节点
const oEl = oldVnode.el
// 父元素
let parentEle = api.parentNode(oEl)
// 根据Vnode生成新元素
createEle(vnode)
if (parentEle !== null) {
// 将新元素添加进父元素
api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl))
// 移除以前的旧元素节点
api.removeChild(parentEle, oldVnode.el)
oldVnode = null
}
}
// ......some code
return vnode
}
1
2
3
4
5
6
7
8
9
10
// 同一类型节点判断 进行key,tag,注释节点,data,input的比较
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)
)
}
1
2
3
4
5
6
7
8
// input比较
function sameInputType (a, b) {
if (a.tag !== 'input') return true
let i
const typeA = isDef(i = a.data) && isDef(i = i.attrs) && i.type
const typeB = isDef(i = b.data) && isDef(i = i.attrs) && i.type
return typeA === typeB || isTextInputType(typeA) && isTextInputType(typeB)
}

newVnode对比oldVnode,发生变化的有三种情况:

  1. 前者有,后者无,创建节点,增加到后者

    1. 前者无,后者有,从后者上删除节点
    2. 前者有,后者也有,更新节点,使后者和前者一致

    总之,newVnode为准,改造oldVnode,最终使得两者相同,这个过程称为pacth,意为补丁。

下面详细说下这三种情况Vue都是怎么实现的。

1.创建节点

因为这类节点创建之后是被直接插入到DOM中,所以创建的节点类型只能是: 元素节点、文本节点、注释节点 。

那就得判断newVnodeoldVnode没有的节点属于那种类型,再分别进行创建后插入DOM。

  • 创建节点流程:

1.创建节点,如果Vnode类型为元素进行2,注释进行3,文本进行4

2.创建元素节点,遍历并添加元素节点子节点,进行5

3.创建注释节点,进行5

4.创建文本节点,进行5

5.插入DOM,完成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 源码位置: /src/core/vdom/patch.js
function createElm (vnode, parentElm, refElm) {
const data = vnode.data
const children = vnode.children
const tag = vnode.tag
// 有tag标签就为元素节点
if (isDef(tag)) {
// 创建元素节点
vnode.elm = nodeOps.createElement(tag, vnode)
// 递归遍历,创建元素节点的子节点
createChildren(vnode, children, insertedVnodeQueue)
insert(parentElm, vnode.elm, refElm)
} else if (isTrue(vnode.isComment)) {
// 创建注释节点
vnode.elm = nodeOps.createComment(vnode.text)
insert(parentElm, vnode.elm, refElm)
} else {
// 创建文本节点
vnode.elm = nodeOps.createTextNode(vnode.text)
insert(parentElm, vnode.elm, refElm)
}
}
2.删除节点

这个就很简单咯,直接removeChild就完事

1
2
3
4
5
6
function removeNode (el) {
const parent = nodeOps.parentNode(el) // 获取父节点
if (isDef(parent)) {
nodeOps.removeChild(parent, el) // 调用父节点的removeChild方法
}
}

代码中的nodeOpsVue为了跨平台兼容性,对所有节点操作进行了封装,例如nodeOps.createTextNode()在浏览器端等同于document.createTextNode()

3.更新节点

更新节点因为涉及子节点的比较,所以情况较多,也复杂很多。

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
// 源码位置: /src/core/vdom/patch.js

function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
if (oldVnode === vnode) {
return
}
const elm = vnode.elm = oldVnode.elm

if (isTrue(vnode.isStatic) &&
isTrue(oldVnode.isStatic) &&
vnode.key === oldVnode.key &&
(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
) {
vnode.componentInstance = oldVnode.componentInstance
return
}

const oldCh = oldVnode.children
const ch = vnode.children
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
}
else if (isDef(ch)) {
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
}
else if (isDef(oldCh)) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
}
else if (isDef(oldVnode.text)) {
nodeOps.setTextContent(elm, '')
}
}
else if (oldVnode.text !== vnode.text) {
nodeOps.setTextContent(elm, vnode.text)
}
}

这我看得懂个鬼哦???? 不要着急,先过一遍代码,努力思考一下片段中到底判断了些什么东西~

vnode.isStatic 是否是静态节点(没有任何变量,数据更新与它无关)

最好,拿出笔和纸,如果你对你的脑容量有自信,那就把流程图画在脑子里,看看是否一致!

  • 更新节点流程:

1.新旧节点是否完全相同?是,完成;不是,进行2

2.新旧节点是否是具有相同key的静态节点(没有数据,不会变化)并且新节点是个克隆节点或者老节点上有Vnode指令?是,新节点的组件实例=老节点的组件实例,完成;不是,进行3

3.新节点是否具有text属性?是,进行4;不是,进行5

4.新旧节点是否是具有相同text?是,完成;不是,新节点text替换为真实DOM的text,完成

5.新旧节点是否都具有子节点?是,进行6;不是,进行7

6.新旧节点是否都具有相同的子节点? 是,进行7;不是,updateChildren更新子节点,进行7

7.新节点有子节点?是,进行8;不是,进行9

8.旧节点的子节点是否具有text属性?是,清除真实DOM文本,再把新节点的子节点添加到真实DOM中,进行 9;不是,把新节点的子节点添加到真实DOM中,进行9

9.老节点有子节点?是,清空DOM中的子节点,进行10;不是,进行10

10.老节点有text属性?是,清除老节点text,进行4;不是,进行4


什么,key是什么玩意? 请看Vnode

为什么不画流程图? 因为懒…… 用文字表述的逻辑是一样的,并且你应该知道:

进行2时,新旧节点不完全相同;

进行4时,新旧节点不完全相同,且不是具有相同key的静态节点,且新节点具有text属性

……

为啥只判断新节点是否有text属性,不判断老节点是否有text属性?好好想想,以那啥为准

简单来说,还是比较了新旧节点的文本节点和子节点

以新节点为准,改造老节点,最终使得两者相同,并且删除不需要的文本节点


updateChildren方法是什么?先卖个关子,咱们总结一下。

盯着干嘛,赶紧自己梳理,该记笔记记笔记啊?!别指望我给你写着然后cv大法,想得美!

updateChildren

该函数的调用的前提:新旧节点都有子节点。

代码量较多,这里主要的是进行新旧子节点数组的循环对比,目的是找出两者之间的差异并以新节点为准改造旧节点,使之相同(再强调一遍)

循环过程

我们假定:

新旧节点子节点都没有设置key

旧数组 = 真实DOM = [a,b,c,d];

新数组=[e,f,g,h];

oldStartIdx = newStartIdx = 0;

旧首 = 旧数组第一个元素;旧尾=旧数组最后一个元素;

两两对比有四种结果:

  • 旧首 = 新首;继续下个循环
  • 旧首 = 新尾;真实DOM= [b,c,d,a],也就是a挪到最后;递归检查子节点;继续下个循环
  • 旧尾 = 新首;真实DOM= [d,a,b,c],也就是d挪到最前;递归检查子节点;继续下个循环
  • 旧尾 = 新尾;继续下个循环

下次循环,旧首,旧尾,新首,新尾都根据index和数组变化更新,进行上述比较。

这里忽略了常规的边缘判断,例如:旧首,旧尾,新首,新尾其中为空的情况,源码之中是有处理的

细心的可能会发现xxxstartIndex只会增,xxxEndIndex只会减,循环是从两边向中间循环的!为什么是双端比较?

这要从react的diff算法说起,它是存在优化空间的

占位!!!!

img

循环完成后

循环结束条件为:oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx

循环结束后只有两种情况:oldStartIdx > oldEndIdx ,newStartIdx > newEndIdx

如果 oldStartIdx > oldEndIdx,说明old先遍历完,并且new中新增了节点,需要将newStartIdxnewEndIdx之间的新增子节点添加到真实DOM中。

如果 newStartIdx > newEndIdx,说明new先遍历完,并且old中移除了节点,需要将oldStartIdxoldEndIdx之间的移除的子节点从真实DOM中移除。

为什么可以根据新旧首尾idx来得出那么多信息? 我们可以逆向思维一下。

比如:什么时候会出现oldStartIdx > oldEndIdx ?

旧: a,b,c

新:d,a,b,c

第一轮循环: 新旧c找到了可复用的,pacth更新

旧: a,b

新:d,a,b

第二轮循环: 新旧b找到了可复用的,pacth更新

旧: a

新:d,a

第三轮循环: 新旧a找到了可复用的,pacth更新,–oldEndIdx后,oldEndIdx就为-1了,

旧:

新:d

这时候发现新中还有节点未匹配上,如此就找到新增的节点了。

如果 出现 newStartIdx > newEndIdx,那明显就是旧中有,新中没有的节点,我们需要删除该节点

根据key对比

1.会根据oldCh key建立索引树,即调用createKeyToOldIdx函数

2.查找newStartVnodekey,如果在oldCh索引中没有,则被认为是新节点,直接在头部创建节点

3.如果有,找到oldCh对应索引的节点且赋值给vnodeToMove,调用sameVnodenewStartVnode比较

4.是同一个节点,递归检查子节点,删除掉oldCh上的vnodeToMovevnodeToMove的位置与newStartVnode保持一致(即vnodeToMove把挪到最前)

5.不是,说明也是新节点,与2一样对待。

6.index自增

有索引的对比与上面循环对比更加简单高效,这也是为什么在开发过程中devtools总是叫你绑定key的原因。

1
2
3
4
5
6
7
8
9
10
11
// 源码位置: /src/core/vdom/patch.js

function createKeyToOldIdx (children, beginIdx, endIdx) {
let i, key
const map = {}
for (i = beginIdx; i <= endIdx; ++i) {
key = children[i].key
if (isDef(key)) map[key] = i
}
return map
}

建议:使用key时不要使用数组的index,在删除数组元素时会发生意料之外的错误,详见:

https://juejin.im/post/5e8694b75188257372503722#heading-14

合起来整个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
66
67
68
69
70
71
72
73
// 源码位置: /src/core/vdom/patch.js

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

// removeOnly is a special flag used only by <transition-group>
// to ensure removed elements stay in correct relative positions
// during leaving transitions
const canMove = !removeOnly

if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(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(oldCh, oldStartIdx, oldEndIdx)
}
}

总结:

从虚拟DOM概念说起,到详细了解了Vue中的虚拟DOM实现方案。

Vnode类描述真实节点,用Vue独有的Diff策略进行新旧节点对比,以新节点为准,patch旧节点,使两者一致

updateChildren函数是整个Vue Diff中最复杂的一块,但也是最核心的一块,对新旧节点的子节点数组以两边向中间的方式进行循环对比,十分有意思。

评论