什么是虚拟 DOM?
在Vue.js 等主流框架中,我们只需要描述应用状态以及 DOM 之间的映射关系,具体渲染由框架负责。那框架是如何确定状态中发生了什么变化以及需要在哪里更新 DOM 呢?最简单粗暴的方法是把所有 DOM 都删了重新生成一份 DOM。显然这是不可取的,访问 DOM 的操作是相当昂贵的,这样会造成相当多的性能浪费。Vue.js 针对上述问题引入了虚拟 DOM:通过状态生成一棵虚拟节点树,然后使用虚拟节点树进行渲染,渲染前会对比新的虚拟节点树与旧节点树,再渲染不同的部分。
在变化侦测部分我们提到过,Vue.js 在一定程度上能够获知具体是哪个状态发生了变化。如果直接将状态绑定在具体节点上,那状态变化时就可以直接操作具体的节点,不需要做比对。但是这样会造成一个问题,由于粒度太细,每个节点都拥有一个对应的 watcher 来侦测变化。对于大型项目来说,造成的内存开销以及跟踪依赖的开销是非常大的。Vue.js 2.0 正是出于解决这个问题的目的引入了虚拟 DOM。
拿一个典型的 .vue 文件来说,先框架将模板编译成渲染函数(render),再执行渲染函数就能得到一个虚拟节点树,最后使用这个虚拟节点树就可以渲染页面。
如果直接用新节点覆盖旧节点的话会有很多不必要的 DOM 操作,所以实际上渲染过程并没有那么简单,需要先将虚拟节点与上一次渲染视图用的旧虚拟节点进行对比,从而找出真正需要更新的节点来进行 DOM 操作。
总结一下,可以看出虚拟 DOM 在 Vue.js 中主要做两件事情:
- 提供与真实 DOM 节点所对应的虚拟节点 vnode
- 将虚拟节点与旧虚拟节点进行对比,然后更新视图
什么是 VNode?
VNode 是 Vue.js 中的一个类,我们用不同类型的 VNode 实例表示不同类型的 DOM 节点。下面是源码中 VNode 的实现:
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
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
}
get child (): Component | void {
return this.componentInstance
}
}
每次渲染视图时,都是先创建 vnode,然后使用它创建真实 DOM 插入页面,所以可以将上一次渲染视图的 vnode 缓存起来,之后每当需要重新渲染视图时,就将新创建的 vnode 和上一次的 vnode 对比,找出不一致的地方更新真实 DOM。
Vue.js 对状态的侦测策略采用了中等粒度,每当状态发生变化时,只通知到组件级别,然后在组件内使用虚拟 DOM 来渲染视图。也就是说,只要组件使用的众多状态中的一个发生了变化,整个组件就要重新渲染。也是因此,缓存上一次的 vnode 并将其与最新的 vnode 进行对比才显得十分重要。
VNode 类型
目前 VNode 有以下几种类型:
-
注释节点
创建注释节点的过程如下,通过下面这段代码可以很清楚的看到它有哪些属性:
createEmptyNode = text => { const node = new VNode() node.text = text node.isComment = true return node } -
文本节点
文本节点的创建过程类似:
createTextNode(val) { return new VNode(undefined, undefined, undefined, String(val)) } -
元素节点
元素节点顾名思义是用来描述一个 DOM 节点的,通常会存在以下属性:
- tag: 元素标签名;
- data: 该属性包含了一些节点上的数据,比如 attrs,class 和 style 等;
- children: 当前节点的子节点列表,注意此处子节点也是 VNode 实例;
- context: 当前组件的 Vue 实例。
-
组件节点
组件节点和元素节点类似,只是有以下单独的两个属性:
- componentOpitons: 该属性包含了组件节点的选项参数,包括 propsData,tag 和 children
- componentInstance: 组件的 Vue 实例
-
函数式组件
函数式组件与元素组件类似也有两个独有的属性:functionalOptions 和 functionalContext
-
克隆节点
克隆节点是将现有节点的属性复制到新的节点中,让新创建的节点和被克隆的节点的属性保持一致,从而实现克隆效果。它的作用是优化静态节点和插槽节点。比如静态节点,我们知道当组件的某个状态变化之后会通过虚拟 DOM 重新渲染视图,但静态节点的视图不依赖于状态,所以我们可以通过将节点克隆一份,每次使用克隆节点渲染。
patch
前面提到的将新旧节点对比后再渲染视图的过程,是虚拟 DOM 中最核心的部分,我们称之为 patch。patch 的目的是渲染视图,而要达到这个目的,我们需要分析新旧 vnode 之间的差异从而修改现有 DOM,一般来说,对现有 DOM 修改无非这三种方式:
- 创建新增的节点
- 删除废弃的节点
- 修改需要更新的节点
新增节点
虽然 VNode 有很多种类型,但实际上只有三种类型会被创建并插入到 DOM 中:元素节点,注释节点,文本节点。
要判断是否是元素节点,只需要判断是否有 tag 属性即可,接着就调用 createElement 来创建真实元素节点,然后再调用 appendChild 方法将节点插入到父节点。但如果这个元素节点自身又有子节点呢?
创建子节点的过程其实是一个递归的过程。vnode 中的 children 属性保存了当前节点的所有子虚拟节点,所以只需要将 children 循环一遍,将每个子虚拟节点都执行创建元素的逻辑,具体可以查看源码中 createElm 函数的实现(./src/core/vdom/patch.js)。
注释节点和文本节点的创建就比较简单了,在 tag 属性不存在的情况下,通过判断 isComment 区分注释节点和文本节点。
删除节点
当一个节点只出现在 oldVnode 中时,就需要进行删除操作了。实现过程很简单,如下:
function removeVnodes (vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx]
if (isDef(ch)) {
if (isDef(ch.tag)) {
removeAndInvokeRemoveHook(ch)
invokeDestroyHook(ch)
} else { // Text node
removeNode(ch.elm)
}
}
}
}
更新节点
-
判断是否是静态节点
在更新节点时,先判断是否是静态节点,如果是,就不需要更新。源码中具体判断过程如下:
if (isTrue(vnode.isStatic) && isTrue(oldVnode.isStatic) && vnode.key === oldVnode.key && (isTrue(vnode.isCloned) || isTrue(vnode.isOnce)) ) { vnode.componentInstance = oldVnode.componentInstance return } -
新虚拟节点有文本节点
当节点通过静态节点检测时,要以新的节点为准来更新视图,如果新节点有 text 属性,那不论之前旧节点是什么,直接设置 DOM 内容。
-
新虚拟节点没有文本节点 如果新节点没有 text 属性,那么他就是一个元素节点。元素节点又分为有子节点和没有子节点两种情况:
-
有 children 的情况
在 newVnode 有 children 的情况下,需要区分 oldVnode 是否有 children 属性。如果有,那么就需要将各自的 children 进行详细的对比,具体过程我们会在下面详细描述;如果没有,那么旧标签要么就是空要么就是文本节点。如果是文本节点,就先清空变为空标签,再将新节点下的子节点渲染为真实 DOM 后插入当前 DOM 节点。
-
没有 children 的情况:这种情况下直接清空,将 DOM 节点变为空标签即可。
-
子节点的更新
更新子节点分为四种操作:更新节点,新增节点,删除节点,移动节点位置。而要更新子节点首先要对比两个子节点有哪些不同,再针对不同情况做不同处理。比如,newChildren 有一个节点在 oldChildren 中找不到相同节点,那就说明是新增节点。
对比两个子节点列表,首先要做的是循环。循环 newChildren 中每一个新子节点,并在 oldChildren 中找到对应的旧子节点。如果找不到,就做新增节点的操作;找到了就做更新节点的操作。如果找到的旧节点位置和新节点不同,则需要移动位置。
更新策略
-
创建子节点
前面提到过,我们要在 oldChildren 列表中寻找对应的旧子节点。如果没有找到,那就说明本次循环的新子节点是新增节点,需要执行创建子节点的操作,并将本次新创建的子节点插入到所有未处理节点(未处理就是还没有进行任何更新操作的节点)的前面,也就是下图中虚线指向的位置。成功插入 DOM 后,这一轮循环就结束了。
这里插入所有未处理节点之前是有原因的,不妨思考一下如果是插入到所有已处理的节点之后,若某次操作中有两个新增节点,那么第二个新增节点就会插入到在前一个新增节点的前面,显然这是不可取的。
-
更新子节点
如果新旧两个字节点是同一个节点且位置相同,那么我们就进行更新节点的操作,具体内容在上一届提到过。
-
移动子节点
移动子节点发生在两个子节点是同一节点旦位置不同的情况下,通常用 Node.insertBefore 来将一个节点移动到另一个位置。那怎么找到新虚拟节点的位置呢?和创建子节点的情况类似,只需要将需要移动的节点移动到所有未处理的节点前面。
-
删除子节点
删除子节点,本质上就是删除那些出现在 oldChildren 但不在 newChildren 中的子节点。所以,当我们把 newChildren 遍历完一边之后,oldChildren 中还没有被处理的剩余节点就是需要删除的节点。
优化策略
通常情况下,并不是所有子节点位置都会发生移动,所以通过循环来查找是很费时间的。假设有一个场景,我们只是修改了列表中某一项的内容,没有新增和删除,在这种情况下每个新旧子节点的位置都是相同的,是可预测的。 Vue.js 针对这种情况做出了优化:先尝试使用相同位置的两个节点来比对是否是同一个节点,如果是就进入更新节点的操作;尝试失败之后再用循环来查找节点,下面我们来详细了解下这种策略:
在优化后的快速查找中,首先要设置四个锚点分别是: oldStartIdx, oldEndIdx, newStartIdx, newEndIdx,与之下相对应的子节点为 oldStartVnode, oldEndVnode, newStartVnode, newEndVnode,它们分别指向oldChildren 和 newChildren 中未处理节点的第一个和最后一个节点。
当锚点满足 oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx 这个条件时,我们循环进行一系列节点之间的比较:
-
快捷查找
先对上面四个指定的节点进行优先查找:
- 判断 oldStartVnode 是否为空,如果是,则 oldStartIdx 向后移动
if (isUndef(oldStartVnode)) { oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left } - 判断 oldEndVnode 是否为空,如果是,则 oldEndIdx 向前移动
else if (isUndef(oldEndVnode)) { oldEndVnode = oldCh[--oldEndIdx] } - 判断 oldStartVnode 与 newStartVnode 是否是同一节点,如果是,则更新节点并移动锚点位置
else if (sameVnode(oldStartVnode, newStartVnode)) { patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx) oldStartVnode = oldCh[++oldStartIdx] newStartVnode = newCh[++newStartIdx] } - 判断 oldEndVnode 和 newEndVnode 是否是同一节点,如果是,则更新节点并移动锚点位置
else if (sameVnode(oldEndVnode, newEndVnode)) { patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx) oldEndVnode = oldCh[--oldEndIdx] newEndVnode = newCh[--newEndIdx] }
- 判断 oldStartVnode和 newEndVnode 是否是同一节点,如果是,则更新节点,并将对应的 DOM 节点移动至真实节点列表的最后 ``` 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] } ``` - 判断 oldEndVnode 和 newStartVnode 是否为同一节点,如果是,则更新节点,并将对应的 DOM 节点移动至所有未处理的节点之前 ``` 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] } ``` 这一系列的快速查找之后,一些不需要移动的节点得到了快速处理,且减少了待处理节点列表,缩小了后续的查找范围。 - 判断 oldStartVnode 是否为空,如果是,则 oldStartIdx 向后移动
-
循环查找
若节点不满足上面任一条件,则需要进入到循环查找阶段:
else { // oldKeyToIdx 为 oldChildren 中 key 到 index 的映射 if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx) if (isUndef(idxInOld)) { // 在 oldChildren 中找不到对应子节点 createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) } else { vnodeToMove = oldCh[idxInOld] // 找到了对应子节点,先判断是否为同一节点 if (sameVnode(vnodeToMove, newStartVnode)) { // 若相同,则做更新操作,将 oldChildren 中节点置空,DOM 移动至所有未处理节点的前面 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] }
最后当循环结束后,也就是说 oldStartIdx > oldEndIdx && newStartIdx > newEndIdx 至少存在一个条件不满足的情况下,我们需要进行收尾的新增和删除操作:
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)
}
下面具体看一个例子来分析一下它的子节点列表更新过程:
- 初始化过后,循环第一步发现 oldStartVnode和 newEndVnode 为同一节点,则更新锚点,并在真实 DOM 中将 A 移动至队伍的最后
- 最后进入循环后的收尾处理,从 oldStartIdx 到 oldEndIdx 遍历 oldChildren,并删除对应的节点。
到此整个子节点更新过程就完成了。
本系列文章均是深入浅出 Vue.js的学习笔记,有兴趣的小伙伴可以去看书哈。