Diff算法
Vue的Diff算法有两种不同的粒度,分别是组件级别(component Diff)和元素级别(Element Diff)。
假如有新旧两个不同的Virtual DOM Tree,如下图所示,Vue只会比较同一层级的节点,即只比较同颜色方框内的节点。其中,深蓝色方框属于组件级别,紫色方框、橙色方框和浅蓝色方框属于元素级别。那Vue具体是如何Diff的呢?

组件更新
当数据发生变化时,组件更新过程是什么样子的呢?
当数据发生变化时,会触发数据的setter,在setter中会触发notify
通知渲染watcher,渲染watcher会通过updateComponent
重新计算watcher的值。
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
// 我们在watcher的构造函数中为vm._watcher设置这个
// 因为观察者的初始补丁可能会调用 $forceUpdate(例如,在内部子组件的mounted钩子),它依赖于已定义的 vm._watcher
new Watcher(vm, updateComponent, noop, null, true /* isRenderWatcher */)
updateComponent
即是执行vm._update
方法,其定义在文件中:src/core/instance/lifecycle.js
。
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
const vm: Component = this
// ...
const prevVnode = vm._vnode
if (!prevVnode) {
// 初始渲染
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// 组件更新
vm.$el = vm.__patch__(prevVnode, vnode)
}
// ...
}
组件更新的时候,会通过prevVnode
判断是否是初始渲染,不过最后都是调用__patch__
方法,只是参数不同,__patch__
定义在文件中:src/core/vdom/patch.js
return function patch (oldVnode, vnode, hydrating, removeOnly, parentElm, refElm) {
if (isUndef(vnode)) {
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}
let isInitialPatch = false
const insertedVnodeQueue = []
if (isUndef(oldVnode)) {
// 初始渲染
// 空的挂载(可能是组件),创建新的根元素
isInitialPatch = true
createElm(vnode, insertedVnodeQueue, parentElm, refElm)
} else {
// 组件更新
const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// 新旧节点相同
// patch existing root node
// 修补现有根节点
patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
} else {
// 新旧节点不同
// ...
}
}
invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
return vnode.elm
}
path的逻辑是根据不同的情况进行处理的,先通过oldVnode
是否存在来判断是初始渲染还是组件更新,如果是初始渲染,就直接通过createElm
来创建元素,如果是组件更新,则通过sameVnode
来判断新旧节点是否相同,相同的话则直接patchVnode
进行更新,若不同则更新逻辑是不一样的。
其中,sameVnode
的定义如下:
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)
)
)
)
}
sameVnode
的逻辑比较清晰,如果两个 vnode 的 key 不相等,则是不同的;否则继续判断,对于同步组件而言,会判断 isComment、data、input 类型等是否相同,对于异步组件而言,会判断 asyncFactory 是否相同。
组件更新时,新旧节点不同的处理逻辑是不一样的,我们先来看看新旧节点不同时的逻辑是怎么样的。
新旧节点不同
当新旧节点不同时,更新逻辑如下:
// 新旧节点不同
if (isRealElement) {
// 挂载到一个真正的元素
// 检查这是否是服务端渲染的内容,以及我们是否可以执行一个成功的hydration
if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
oldVnode.removeAttribute(SSR_ATTR)
hydrating = true
}
if (isTrue(hydrating)) {
if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
invokeInsertHook(vnode, insertedVnodeQueue, true)
return oldVnode
} else if (process.env.NODE_ENV !== 'production') {
warn(
'The client-side rendered virtual DOM tree is not matching ' +
'server-rendered content. This is likely caused by incorrect ' +
'HTML markup, for example nesting block-level elements inside ' +
'<p>, or missing <tbody>. Bailing hydration and performing ' +
'full client-side render.'
)
}
}
// 不是服务端渲染,就是hydration失败。
// 创建一个空节点并替换它
oldVnode = emptyNodeAt(oldVnode)
}
// 替换存在的元素
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)
// 创建新节点
createElm(
vnode,
insertedVnodeQueue,
// extremely rare edge case: do not insert if old element is in a
// leaving transition. Only happens when combining transition +
// keep-alive + HOCs. (#4590)
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
)
// 递归更新父占位符节点元素
if (isDef(vnode.parent)) {
let ancestor = vnode.parent
const patchable = isPatchable(vnode)
while (ancestor) {
for (let i = 0; i < cbs.destroy.length; ++i) {
cbs.destroy[i](ancestor)
}
ancestor.elm = vnode.elm
if (patchable) {
for (let i = 0; i < cbs.create.length; ++i) {
cbs.create[i](emptyNode, ancestor)
}
// #6513
// invoke insert hooks that may have been merged by create hooks.
// e.g. for directives that uses the "inserted" hook.
const insert = ancestor.data.hook.insert
if (insert.merged) {
// start at index 1 to avoid re-invoking component mounted hook
for (let i = 1; i < insert.fns.length; i++) {
insert.fns[i]()
}
}
} else {
registerRef(ancestor)
}
ancestor = ancestor.parent
}
}
// 销毁旧节点
if (isDef(parentElm)) {
removeVnodes(parentElm, [oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}
新旧节点不同时,更新逻辑比较清晰,首先创建新节点,接着更新父占位节点,最后删除旧节点,也就是创建新节点替换旧节点的过程。
新旧节点相同
新旧节点相同时,直接执行代码patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
,其定义在文件中:src/core/vdom/patch.js
function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
if (oldVnode === vnode) {
return
}
const elm = vnode.elm = oldVnode.elm
if (isTrue(oldVnode.isAsyncPlaceholder)) {
if (isDef(vnode.asyncFactory.resolved)) {
hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
} else {
vnode.isAsyncPlaceholder = true
}
return
}
// 对静态树进行重用
// 注意,我们只在vnode被克隆时才这样做-
// 如果未克隆新节点,则表示渲染函数已由热重载api重置,我们需要执行正确的重新渲染。
if (isTrue(vnode.isStatic) &&
isTrue(oldVnode.isStatic) &&
vnode.key === oldVnode.key &&
(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
) {
vnode.componentInstance = oldVnode.componentInstance
return
}
let i
const data = vnode.data
if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
i(oldVnode, vnode)
}
const oldCh = oldVnode.children
const ch = vnode.children
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)
}
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)
}
if (isDef(data)) {
if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
}
}
patchVnode
的主要逻辑是:
- 调用
prepatch
钩子函数 - 调用
update
钩子函数 - 完成
patch
过程- 如果新节点不是文本节点:
- 如果旧节点的子节点和新节点的子节点都存在且不相等的话,则通过
updateChildren
更新子节点 - 如果只有新节点的子节点存在的话,则通过
addVnodes
新增DOM节点;如果旧节点是文本节点的话,则将文本设置为空 - 如果只有旧节点的子节点存在的话,则通过
removeVnodes
删除DOM节点 - 如果旧节点是文本节点的话,则将文本设置为空
- 如果旧节点的子节点和新节点的子节点都存在且不相等的话,则通过
- 如果新节点是文本节点,且与旧节点的文本不同的话,则直接更新DOM的文本
- 如果新节点不是文本节点:
- 调用
postpatch
钩子函数
至此,组件级别的Diff过程就是如此,当组件是初始渲染时,Vue直接创建DOM元素;当组件是更新时,若新旧节点相同,会通过patchVnode
进行更新,若新旧节点不同时,会创建新元素并替换旧元素。那元素级别的Diff过程是什么样的呢?在patchVnode
过程中,如果旧节点的子节点和新节点的子节点都存在且不相等的话,则通过updateChildren
更新子节点,其中updateChildren
就是元素级别的Diff过程了,下面来看看updateChildren
的具体逻辑。
updateChildren
当新旧节点的子节点都存在且不相等时,会调用updateChildren
更新子节点
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是一个特殊标志,仅由<transition group>使用,
// 以确保在离开转换期间移除的元素保持在正确的相对位置
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)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
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)
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)
oldCh[idxInOld] = undefined
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// 相同的键,但不同的元素。视为新元素
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)
}
}
这段逻辑是比较复杂的,核心逻辑是通过对比新旧两个节点的子节点列表,找出列表中相同的节点,然后将相同的旧节点移动到新位置上,如果旧列表中不存在新节点,则进行创建新节点,最后,删除旧列表中的旧节点。这样做可以很大程度地将旧节点重用,从而提高性能。
具体的算法是:
- 两个新旧列表,分别建立头尾两个索引,一共四个索引,
newStartIdx
/newEndIdx
和oldStartIdx
/oldEndIdx
- 进行while循环,条件是
oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx
- 如果
oldStartIdx
和newStartIdx
对应的两个节点相同,则进行patchVnode
,并将oldStartIdx
和newStartIdx
进行自增 - 如果
oldEndIdx
和newEndIdx
对应的两个节点相同,则进行patchVnode
,并将oldEndIdx
和newEndIdx
进行自减 - 如果
oldStartIdx
和newEndIdx
对应的两个节点相同,则进行patchVnode
,并将DOM上oldStartIdx
对应的节点插入到oldEndIdx
之后,接着oldStartIdx
自增,newEndIdx
自减 - 如果
oldEndIdx
和newStartIdx
对应的两个节点相同,则进行patchVnode
,并将DOM上oldEndIdx
对应的节点插入到oldStartIdx
之前,接着newStartIdx
自增,oldEndIdx
自减 - 最后,试图在
oldStartIdx
和oldEndIdx
之间找到和newStartIdx
的节点相同的节点,若存在,则进行patchVnode
,并将找到的节点插入到oldStartIdx
之前;若不存在,则创建新的元素。newStartIdx
进行自增
- 如果
- 循环结束,若
oldStartIdx > oldEndIdx
,则说明newStartIdx
和newEndIdx
之间的节点都是旧列表没有的,所以需要将这些节点添加到DOM上;若newStartIdx > newEndIdx
,则说明oldStartIdx
和oldEndIdx
之间的这些节点是新列表没有的,所以需要将这些节点从DOM上删除。
单纯的代码和文字描述可能有点晦涩,我们举一个具体的例子,如图所示,有新旧两个列表,当前真实DOM是旧列表的映射


patchVnode
,接着oldStart和newStart会前进
(2) oldStart=b oldEnd=d newStart=f newEnd=c

f
插入到b
之前,接着newStart会前进
(3) oldStart=b oldEnd=d newStart=d newEnd=c

(4) oldStart=b oldEnd=c newStart=e newEnd=c

patchVnode
,接着oldEnd会后退,newEnd会后退
(5) oldStart=b oldEnd=b newStart=e newEnd=e

e
插入到b
之前,接着newStart会前进
(6) oldStart=b oldEnd=b newStart=c newEnd=e

b
进行移除,移除后的DOM列表将和new列表是一样的。
至此,子节点列表的Diff过程结束,思想是在旧列表中找到和新列表中相同的元素,并移动到正确的位置;若没找到相同的元素,则进行创建,并插入到正确的位置;若旧列表中含有新列表中没有的元素,则进行删除。这个过程是Vue中元素级别的Diff流程。
小结
Vue的Diff算法分为两个粒度,一个是组件级别(component Diff),另一个是元素级别(Element Diff)。组件级别的Diff算法比较简单,节点不相同就进行创建和替换,节点相同的话就会对其子节点进行更新;对子节点进行更新也就是元素级别的Diff,通过插入、移动和删除等方式对旧列表改造成和新列表一致。

Vue的整个Diff过程就是整个patch
方法的流程,整个流程也会通过递归地调用patchVnode
来完成对整颗Virtual DOM Tree的更新。
