什么是虚拟DOM
从 vue内部运行机制系列文章-template模板编译原理 知道,template经过编译会形成render function,然后render function 会被转化成 VNode 节点。Virtual DOM 其实就是一棵以 JavaScript 对象(VNode 节点)作为基础的树,用对象属性来描述节点,它是一层对真实 DOM 的抽象。
为什么要用虚拟DOM呢?
例如,我们来执行以下一段代码。
for(var key in document.createElement('div')){console.log(key)}
通过以上代码,可以看到,我们每创建一个新的元素都需要创建数十个属性,所以说频繁的操作真实DOM是非常昂贵的操作,会减慢网页的加载速度, 因此, vue中用了虚拟DOM来减少对真实DOM的操作。
vue中的虚拟DOM(视图更新)
我们知道当我们修改数据时,会触发set方法,进而执行watcher对象的update方法来跟新视图。在这一过程中,会重新执行render function形成一颗新的newDom,这时候会和旧的oldDom进行比较,进而找出不同,并将这些不同跟更新到视图上。
那么,vue中是怎么比较新旧vDom的呢?话不多说,我们马上来介绍。
diff算法
在vue中,比较两个虚拟DOM的差异主要是用了diff算法。
vue中的diff算法有个特点,就是只能在同级比较,不能跨级比较。 即图中颜色相同部分进行比较。
举个例子:
<!-- 之前 -->
<div> <!-- 层级1 -->
<p> <!-- 层级2 -->
<b> aoy </b> <!-- 层级3 -->
<span>diff</Span>
</P>
</div>
<!-- 之后 -->
<div> <!-- 层级1 -->
<p> <!-- 层级2 -->
<b> aoy </b> <!-- 层级3 -->
</p>
<span>diff</Span>
</div>
我们可能期望将<span>直接移动到<p>的后边,这是最优的操作。但是实际的diff操作是移除<p>里的<span>在创建一个新的<span>插到<p>的后边。因为新加的<span>在层级2,旧的在层级3,属于不同层级的比较。vue中的diff算法可能不是最优的操作,但是在一颗虚拟DOM树比较复杂的情况下是相对比较友好的。
我们知道在vue中,执行render()可以生成虚拟DOM,首次render的执行发生在beforeMount()生命周期函数之后,作为new Watcher()的回调传入。
// 这个方法在vm.$mount()方法里调用
// src/core/instance/lifecycle.js
export function mountComponent (
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
vm.$el = el
if (!vm.$options.render) {
vm.$options.render = createEmptyVNode
}
callHook(vm, 'beforeMount')
let updateComponent
/* istanbul ignore if */
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
// updateComponent作为回调传入,当数据变化重新执行render
// 首次执行时进行依赖收集,同时生成虚拟dom
// 数据/组件更新时,生成新的虚拟DOM,并和旧的虚拟DOM比较
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted && !vm._isDestroyed) {
// 如果已经加载,则先执行beforeUpdate
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
hydrating = false
// 如果是首次mount则执行mounted方法。
if (vm.$vnode == null) {
vm._isMounted = true
callHook(vm, 'mounted')
}
return vm
}
当数据更新时,会通过watcher的run()方法来执行这个回调,从而生成新的虚拟DOM,而我们的dom diff就发生在这里,因此,我们重点来分析下这个回调。它执行了有一个叫_update()的方法。
Vue.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
if (!prevVnode) {
// 初始化render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// 组件跟新时
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.
}
_ipdate()方法里主要做了一件事,就是vm.__patch__(),这个方法其实就是我们dom diff的核心。
ok,我们接下来,主要简要分析下vue当中具体的对比方法。
patch
先来介绍几个有用的api
invokeDestroyHook: 用来删除dom节点createElm:用来创建一个节点patchVnode: patch的核心方法,主要对比就发生在这个方法中。invokeInsertHook: 用来插入节点。removeVnodes: 用来移除旧节点。sameVnode: 比较两个node的tag、isComment、inputType是否相同以及是否都有data属性。
// 这里的patch方法比较复杂,在这里我进行了一定程度的简写。
function patch (oldVnode, vnode, hydrating, removeOnly) {
if (isUndef(vnode)) {
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}
if (isUndef(oldVnode)) {
createElm(vnode, insertedVnodeQueue)
} else {
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
} else {
// destroy old node
if (isDef(parentElm)) {
removeVnodes([oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}
}
}
invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
return vnode.elm
}
oldVnode: 以下表示旧节点
vnode:以下表示新节点
patch的方式主要分为4步
- 如果
oldVnode存在,vnode不存在,则是要做删除oldVnode节点的操作。 - 如果
oldVnode不存在,vnode存在,则是要做创建vnode节点的操作。 - 如果
oldVnode、vnode都存在,且标签名相同、inputType属性(若有)相同且都存在data,则执行patchVnode方法。 - 如果
oldVnode、vnode都存在,但是不满足第三步条件,则删除oldVnode节点,创建vnode节点
patchVnode
function patchVnode (
oldVnode,
vnode,
insertedVnodeQueue,
ownerArray,
index,
removeOnly
) {
// 完全相同,则什么都不做
if (oldVnode === vnode) {
return
}
const elm = vnode.elm = oldVnode.elm
// 都是静态节点且key相同,且当vnode是克隆节点或是v-once指令控制的节点
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(oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
nodeOps.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) { // 都是文本节点
nodeOps.setTextContent(elm, vnode.text)
}
}
patchVnode方法主要分为以下步骤:
- 若
vnode和oldVnode完全相同,则不需要做任何事情 - 若
vnode和oldVnode都是静态节点,且具有相同的key,则当vnode是克隆节点或是v-once指令控制的节点时,只需要把oldVnode.elm和oldVnode.child都复制到vnode上,也不用再有其他操作。 - 若
vnode和oldVnode不是文本节点或注释节点时- 如果
oldVnode和vnode都有子节点,且2方的子节点不完全一致,就执行更新子节点的操作(这一部分其实是在updateChildren函数中实现,之后会介绍)。 - 如果只有
oldVnode有子节点,那就把这些节点都删除 - 如果只有
vnode有子节点,那就创建这些子节点 - 如果
oldVnode和vnode都没有子节点,但是oldVnode是文本节点或注释节点,就把vnode.elm的文本设置为空字符串
- 如果
- 如果
vnode是文本节点或注释节点,但是vnode.text != oldVnode.text时,只需要更新vnode.elm的文本内容就可以.
updateChildren
ok,接下来就介绍updateChildren函数
/**
* parentElm:父级元素节点
* oldCh: oldVnode的children
* newCh: vnode的children
**/
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
// 定义4个索引
let oldStartIdx = 0 // 旧头
let newStartIdx = 0 // 新头
let oldEndIdx = oldCh.length - 1 // 旧尾
let oldStartVnode = oldCh[0] // oldStartIdx对应的node
let oldEndVnode = oldCh[oldEndIdx] // oldEndIdx对应的node
let newEndIdx = newCh.length - 1 // 新尾
let newStartVnode = newCh[0] // newStartIdx对应的node
let newEndVnode = newCh[newEndIdx] // newEndIdx对应的node
let oldKeyToIdx, idxInOld, vnodeToMove, refElm
// 当vnode和oldVnode在下标之间有node存在时
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)
}
}
首先我们定义 oldStartIdx、newStartIdx、oldEndIdx 以及 newEndIdx 分别是新老两个 VNode 的两边的索引,同时oldStartVnode、newStartVnode、oldEndVnode 以及 newEndVnode 分别指向这几个索引对应的 VNode 节点。
我们举个例子
假设现在oldch、newCh分别如上图所示。那么接下来就要执行
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx)
在这个循环中,oldStartIdx、oldEndIdx和newStartIdx、newEndIdx分别从两边向中间移动,直到有其中一个存在交叉部分(startIdx>=endIdx)
- 首先当
oldStartVnode或者oldEndVnode不存在的时候,oldStartIdx与oldEndIdx继续向中间靠拢,并更新对应的oldStartVnode与oldEndVnode的指向,这里需要注意就是伴随着Idx移动,其对应的指向node也发生变化
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
}
- 接下来就是新旧vnode的首首、首尾、尾尾、尾首对比的过程,即
oldStartVnode、newStartVnode和oldEndVnode、newEndVnode两两之间执行patchVnode,同时Idx向中间移动
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]
}
ok,接下来我们来分别分析
-
首先如果
oldStartVnode、newStartVnode符合sameVnode时,说明oldVnode节点的头部与vNode节点的头部是相同的VNode节点,直接进行patchVnode,同时oldStartIdx与newStartIdx向后移动一位。 -
oldEndVnode、newEndVnode同理,若两者符合sameVnode,直接进行patchVnode,同时oldEndIdx与newEndIdx向前移动一位。 -
接下来比较
oldStartVnode、newEndVnode,若两者符合sameVnode,也就是老oldVnode节点的头部与新vNode节点的尾部是同一节点的时候,将oldStartVnode.elm这个节点直接移动到oldEndVnode.elm这个节点的后面即可。然后oldStartIdx向后移动一位,newEndIdx向前移动一位。 -
同理,
oldEndVnode与newStartVnode符合sameVnode时,也就是老oldVnode节点的尾部与新vNode节点的头部是同一节点的时候,将oldEndVnode.elm插入到oldStartVnode.elm前面。同样的,oldEndIdx向前移动一位,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]
}
//
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
}
createKeyToOldIdx 的作用是产生 key 与 index 索引对应的一个 map 表。比如说有这么一个oldChild(为举例,格式不正确):
[
{xx: xx, key: 'key0'},
{xx: xx, key: 'key1'},
{xx: xx, key: 'key2'}
]
经过createKeyToOldIdx转换后就会变为
{
key0: 0,
key1: 1,
key2: 2
}
通过这种方式,就可以在oldCh中快速找到与当前节点(newStartVnode) key相同的节点的索引idxInOld.
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
- 如果没有找到这个
idxInOld,则通过 createElm 创建一个新节点,并将newStartIdx向后移动一位。
if (isUndef(idxInOld)) { // 创建新节点
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
- 如果存在这个
idxInOld,且符合sameVnode,则执行patchVnode并将oldCh[idxInOld] = undefined,最后将newStartIdx向后移动一位。
vnodeToMove = oldCh[idxInOld]
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldCh[idxInOld] = undefined
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
}
- 如果不符合
sameVnode,只能创建一个新节点插入到parentElm的子节点中,newStartIdx往后移动一位。
最后,当while循环执行完成,会有两种情况
- 如果
oldStartIdx > oldEndIdx,说明老节点比对完了,但是新节点还有多的,需要将新节点插入到真实 DOM 中去,调用addVnodes将这些节点插入即可。 - 如果
newStartIdx > newEndIdx条件,说明新节点比对完了,老节点还有多,将这些无用的老节点通过removeVnodes批量删除即可。