一、Virtual DOM定义
虚拟DOM是一棵以Javascript对象作为基础的树,虚拟DOM中的每个节点称为VNode,用对象属性来描述节点,实际上它是一层对真实DOM的抽象,最终可以通过渲染操作使这棵树映射到真实环境上。
总结来说虚拟DOM其实就是一个js对象,用属性和结构来描述整个树的文档。
例如:下面是一颗HTML的节点树
<div class="root" name="root">
<p>1</p>
<div>11</div>
</div>
如果通过Js转化为一棵类似的节点树,便会转化成类似于下列结构,有一定的结构和一些带有部分信息属性的描述文档。
{
type: "tag",
tagName: "div",
attr: {
className: "root"
name: "root"
},
parent: null,
children: [{
type: "tag",
tagName: "p",
attr: {},
parent: {} /* 父节点的引用 */,
children: [{
type: "text",
tagName: "text",
parent: {} /* 父节点的引用 */,
content: "1"
}]
},{
type: "tag",
tagName: "div",
attr: {},
parent: {} /* 父节点的引用 */,
children: [{
type: "text",
tagName: "text",
parent: {} /* 父节点的引用 */,
content: "11"
}]
}]
}
二、Virtual DOM的作用
由上述定义可以大概窥见,Vue中其实是会首先解析<template>标签中定义的HTML节点和组件节点,将其转化为具有一定结构信息的描述文档,为后续的render()函数执行作前置准备。在这个解析过程中,同时会使用到两个函数,_v()和_c()函数,renderHelpers就是使用_v()创建文本节点,而_c()函数就是用来创建VNode节点,就是_createElment()函数,通过这个函数来确定创建的是普通节点还是组件节点,当这两个函数解析完成过后,就可以生成最后的render函数,render函数执行过后的结果就是VNode节点组成的虚拟DOM树,树中每一颗节点都会如同上述结构中存有大量渲染时需要用到的重要信息,然后虚拟DOM树会通过diff算法以及patch过程中的createElement或者patchVnode渲染到真实DOM树上。
以下是该过程的源码
if (typeof tag === 'string') {
let Ctor
ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
if (config.isReservedTag(tag)) {
// platform built-in elements
if (process.env.NODE_ENV !== 'production' && isDef(data) && isDef(data.nativeOn)) {
warn(
`The .native modifier for v-on is only valid on components but it was used on <${tag}>.`,
context
)
}
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 {
// unknown or unlisted namespaced elements
// check at runtime because it may get assigned a namespace when its
// parent normalizes children
vnode = new VNode(
tag, data, children,
undefined, undefined, context
)
}
} else {
// direct component options / constructor
vnode = createComponent(tag, data, context, children)
}
根据上述过程,其实可以了解到,Vue中对于虚拟DOM的使用,其实就是希望在渲染开销很大的真实DOM之前,有一个js结构可以用来存储大量需要渲染的信息形成一个描述文档,最后通过diff算法和patch来优化渲染信息,这样当某个数据或属性修改时,会优先修改描述文档,然后交给diff算法来更新DOM结构中需要更新的部分,而不是更新整个DOM,减少了浏览器的重绘与回流的操作,加快了页面的更新速度。
三、diff算法
Vue中,当双向绑定的数据发生改变时,会调用set方法,set方法会调用Dep.notify通知所有订阅者Watcher数据发生更新,而订阅者就会调用patch进行比较,然后将相应部分渲染到真实DOM结构,这个patch的过程就是diff算法。
1.时间复杂度
首先进行一次完整的diff需要0(n^3)的时间复杂度,这是一个最小编辑距离的问题,在比较字符串的最小编辑距离时使用动态规划的方案,需要的时间复杂度是0(mn),但是对于DOM来说是一个树形结构,而树形结构的最小编辑距离问题的时间复杂度在30多年的演进中从0(m^3n^)演进到了0(n^3),数据来源以下论文
https://grfia.dlsi.ua.es/ml/algorithms/references/editsurvey_bille.pdf
根据这个结论,我们能够大概知道对于原本想要提高效率而引入diff算法使用0(n^3)的时间复杂度显然是不太合适的,因为如果有1000个节点元素的话,就将要进行十亿次比较,这是开销非常昂贵的算法,所以需要通过妥协一些策略来优化整体进程加快速度,将时间复杂度缩小到0(n),虽然并不是最小编辑距离,但是作为编辑距离与时间性能的这种是一个比较好的方案。
2.diff策略
上面的时间复杂度就是通过一定策略进行优化,React中提出了两个假设,在Vue中也同样适用:
- 两个不同类型的元素将产生不同的树。
- 通过渲染器携带一些key值属性,开发者可以示意哪些元素可能是稳定的。
具像化来说就是:
- 只进行统一层级的比较,如果跨层级的移动则视为创建和删除操作。
- 如果是不同类型的元素,则认为是创建了新的元素,而不会通过递归比较他们的孩子。
- 如果是列表元素等比较相似的内容,可以通过key来唯一确定是移动还是创建或者删除操作。
通过上述的策略比较之后,则会出现以下几种情况和操作
- 此节点被添加或移除->添加或移除新的节点。
- 属性被改变->旧属性改为新属性。
- 文本内容被改变->旧内容改为新内容
- 节点tag或key是否改变->改变则移除后创建新元素。
3.源码分析
Vue的源码实现比较复杂,我们只针对部分核心的代码进行一些分析。
在调用patch方法时,会判断时否是VNode,isRealElement其实就是根据有没有nodeType来判断是否为真实DOM,VNode是不存在这个字段的,如果不是真实DOM元素,而且这两个节点是相同的,那么就会进入if内部,调用patchVnode对children进行diff以决定该如何更新。
// line 714
const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// patch existing root node
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
}else{
// ...
}
如何判断是相同节点?
// line 35
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)
)
)
)
}
从代码中可以看出,主要有两个判断条件:
- key必须相同,如果是undefined则也是相同的。
- DOM元素的标签必须相同
如果满足以上条件,那么就判断为是相同的VNode,因此就可以进行patchVnode操作,如果不是就认为是一个完全新的VNode,就会在上边的判断后执行下面的createElm。
梳理逻辑则可以得到以下流程,当进入patch方法之后,有两种分支可以走,如果是第一次patch,即组件第一次挂载的时候,或者发现元素的标签不同的时候,那么就会判读为不是相同元素,直接进行createElm创建新的DOM元素进行替换,否则,就是对已存在的DOM元素进行更新,那么通过patchVnode进行diff,有条件的更新以提升新能,这样其实就已经实现了策略原则中的第一条,即两个不同类型的元素将产生不同的树,只要发现两个元素的类型不同,我们直接删除旧的并创建一个新的,从而避免递归比较。
在认为这是两个相同的的Vnode之后,就需要比较并更新当前元素的差异,以及递归比较children,在patchVnode方法中实现了这两部分。
// line 501
function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
// ...
if (isDef(data) && isPatchable(vnode)) {
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
}
//...
}
cbs.update主要是用来更新attributes,其实是一个hooks,这里面用到的hooks其实有以下几个const hooks = ['create', 'activate', 'update', 'remove', 'destroy'],这些hooks都代表了VNodes更新中的几个相对应的操作,update中包含了以下几个回调函数:updateAttrs
、updateClass
、updateDOMListeners
、updateDOMProps
、updateStyle
、update
、updateDirectives
,其主要都是更新当前结点的一些相关attributes
。
以上都是针对当前节点的操作,如果要更新孩子节点会先进行判断:
- 如果孩子节点不是textNode
- 如果孩子节点是textNode,则直接更新
如果孩子节点是VNode又存在三种情况:
- 有新孩子无旧孩子,直接创建新孩子节点。
- 有旧孩子无新孩子,直接移除旧孩子节点。
- 既有新孩子又有旧孩子,调用updateChildren。
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else 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)
} else if (isDef(oldCh)) {
removeVnodes(oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
nodeOps.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
nodeOps.setTextContent(elm, vnode.text)
}
调用updateChildren方法时,依然存在判断
- 更新节点
- 删除节点
- 增加节点
- 移动节点
updateChildren是diff的核心算法,源码实现如下
// line 404
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)
}
}
源码中对新旧的两个children数组分别在首位中个用了一个指针,总共四个指针,由于指针仅仅对数组进行了一次遍历,所以时间复杂度为0(n),举个简单例子说明
old VNode: a(oldStartIdx) b c d e f(oldEndIdx)
new VNode: b(newStartIdx) f g(newEndIdx)
DOM Node: a b c d e f
首先指针相互比较,存在四种对比
- oldStartIdx、newStartIdx
- oldStartIdx、newEndIdx
- oldEndIdx、newStartIdx
- oldEndIdx、newEndIdx
如果没有相等的则继续,此时又分为两种情况,有key或无key,无key的情况下则直接创建新的DOM Node插入到a(oldStartIdx)之前,如果key存在,则取newStartIdx的key值,到oldVNode去找,记录此时的oldKeyToIdx,随机调整VNode,将b移动到a之前,然后找到oldVNode中oldKeyToIdx对应的节点值设置为undefined,newStartIdx指针向中间靠拢,即++newStartIdx。
old VNode: a(oldStartIdx) undefined c d e f(oldEndIdx)
new VNode: b f(newStartIdx) g(newEndIdx)
DOM Node: b a c d e f
循环继续,此时对比
oldStartIdx
和newStartIdx
、oldStartIdx
和newEndIdx
、oldEndIdx
和newStartIdx
、oldEndIdx
和newEndIdx
,发现newStartIdx
与oldEndIdx
相同,将DOM Node
中的f
进行移动调整到DOM Node
中的a(oldStartIdx)
之前,此时newStartIdx
与oldEndIdx
指针向中间靠拢,即++newStartIdx
与--oldEndIdx
。
old VNode: a(oldStartIdx) undefined c d e(oldEndIdx) f
new VNode: b f g(newStartIdx)(newEndIdx)
DOM Node: b f a c d e
循环继续,此时对比
oldStartIdx
和newStartIdx
、oldStartIdx
和newEndIdx
、oldEndIdx
和newStartIdx
、oldEndIdx
和newEndIdx
,并没有相同的情况,取newStartIdx
的key
值,到old VNode
去找,没有发现相同的值,则直接创建一个节点插入到DOM Node
中的a(oldStartIdx)
之前,newStartIdx
指针向中间靠拢,即++newStartIdx
。
old VNode: a(oldStartIdx) undefined c d e(oldEndIdx) f
new VNode: b f g(newEndIdx) (newStartIdx)
DOM Node: b f g a c d e
此时循环结束,可能会出现两种情况:
- 如果oldStartIdx > oldEndIdx,说明老节点遍历完成,新节点要多于老节点,所以需要将多出来的这一部分创建并添加到真实DOM Node后。
- 如果newStartIdx > newEndIdx,说明新节点遍历完成,老节点要多于新节点,所以需要将老节点多出来的部分从真实DOM Node中删除。
此时我们符合场景二,所以需要从真实
DOM Node
中删除[oldStartldx,oldEndldx]
区间 中的Node
节点,根据上述内容,即需要删除a c d e
四个节点,至此diff
完成。
old VNode: a(oldStartIdx) undefined c d e(oldEndIdx) f
new VNode: b f g(newEndIdx) (newStartIdx)
DOM Node: b f g
diff
完成之后便是将new VNode
作为old VNode
以便下次diff
时使用,此外关于组件的diff
,组件级别的diff
算法比较简单,节点不相同就进行创建和替换,节点相同的话就会对其子节点进行更新,最后关于调用createElm
来根据VNode
创建真实的DOM
元素,如果是一个组件,那么createComponent
会返回true
,因此不会进行接下来的操作,如果不是组件,会进行节点创建工作,并会递归对孩子创建节点。