前言
关于 vue 的 diff 算法、 patch、和 vnode、 还有为什么要加 key, key 不能为 index 的这些问题,是一个关联性的问题,这些问题聚合在一起,形成了 vue 的整个通过把真实 dom 转换为 vdom 通过 diff 算法依照 key 对比找出最小的更新,然后 patch 作相应的修改和添加真实 dom 的操作整个流程操作。
这篇文章解决的问题
- vue 的整个 dom 更新流程
- 什么是 diff
- 什么是 patch
- key 的作用以及为什么不能用 index 作为 key
演示代码
在代码运行出来的时候,审查元素时,把第一个和最后一个设置了颜色,像这样, 这样是为了最后一个问题,检查 index 作为 key 时出现的问题。
<div id="app">
<button @click="del">删除第一项</button>
<div v-for="(item, index) in list" :key="index"> {{ `这是第${item} 项` }}</div>
</div>
<script>
new Vue({
el: '#app',
data() {
return {
list: [1, 2, 3]
}
},
methods: {
del() {
this.list.splice(0, 1);
}
}
})
</script>
复制代码
首先简略梳理一下,vue 的初始化,过程如图所示
整个图,粗略的展示了初始化时,做的一些基本的操作,首次更新时,因为没有老的虚拟节点,所以对应的没有执行到核心的 diff 流程,源码中的这个,老节点没有,直接创建元素。
if (isUndef(oldVnode)) {
createElm(vnode, insertedVnodeQueue)
}
复制代码
点击按钮删除时又发生了什么
因为借用的 splice 触发数组的响应式,更新,一次执行 dep.notify -> watcher.update -> watcher.run 方法从新执行 update 方法, 这样就产生了新老两份 vnode, 从而可以进行 patchVnode。
当执行删除第一项操作时,摘抄部分源代码如下,
function patchVnode (
oldVnode,
vnode,
insertedVnodeQueue,
ownerArray,
index,
removeOnly
) {
const oldCh = oldVnode.children
const ch = vnode.children
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
// 新老节点都有孩子,并且孩子不相同,则进行diff, 遍历新老两个列表,进行对比。
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) {
// 判断条件能走到这儿,说明已经不是 新老 vnode 的子节点都存在的情况,
// 新节点的孩子存在,老节点的孩子不存在,说明是新增孩子节点
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 操作之后,如果新老节点都有子节点,进行updatechildren 操作,由此也可以看出,vue的对比过程是同级比较
子节点的 diff 过程
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)) {
// 新开和旧开是同一节点,执行 patch
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
// patch 结束后 更新新开和旧开的索引 + 1
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// 新后和旧后是同一节点
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
// patch 结束后,索引-1
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
// 新后和旧开始同一节点, 执行 patch
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
// 新后和旧开相同,说名需要移动节点的位置
// 把 节点移动到 oldChildren 中所有未处理节点的最后面,因为是新后,移动到未处理节点的最后,才能是后
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)
// 移动到所有oldChildren 节点中未处理节点的最前面
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
// 如果上面四种假设都不成立,则通过遍历找到新节点在老节点中的位置索引
// 找到老节点中每个节点 key 和索引之间的关系映射,{key1: idx1, ...}
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]
// 如果两节节点是同一个,值执行 patch
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
// patch 结束,将 老节点设置为 undefined
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)
}
}
复制代码
这里的思路是,因为要遍历新老节点的子节点列表,进行两两对比,这里做了一个优化,就是,不断地减少遍历的长度,而不是直接去比较 这里设置了四种节点新头结点、旧头结点、新尾节点、旧尾结点, 这四种情况先比较
场景:因为考虑到大部分的业务场景,并不是所有的子节点的位置都会发生变动,所以,采用以下已知的几种对比方式,来减小循环的次数

- 新头和旧尾比较
- 新尾和旧头比较
- 新头和旧头比较
- 新尾和旧尾比较
依照代码所示,当比较相同时,就进行 patchVnode 操作,同时更改对应的下标,很好的解决了遍历的次数。当这四种情况都不是的话,在老节点中通过 createKeyToOldIdx
建立key 与 索引的映射,然后找到,找到新节点中对应的 key ,如果新节点的 key 不存在,就建立删除,如果存在,进行 patchVnode
操作
最后,都对比完之后,如果老节点还有剩余,说明剩余节点是新增的,增加节点,如果新节点有剩余,说明是新元素,新增元素。
分析完基本的 patch
和 diff
的流程,来具体看一看删除了列表的第一项,会发生什么
- 首先会执行一个
patchVnode
方法,然后对比执行updateChildren
, 具体生成的vnode 抽取关键部分如下
这是老子节点列表的基本结构
[
{
tag: 'button',
children: [
{text: '删除第一项'}
]
},
{
tag: 'div',
key: 0,
children: [
{text: '这是第1项'}
]
},
{
tag: 'div',
key: 1,
children: [
{text: '这是第2项'}
]
}
{
tag: 'div',
key: 2,
children: [
{text: '这是第3项'}
]
}
]
新的子节点列表的结构
[
{
tag: 'button',
children: [
{text: '删除第一项'}
]
},
{
tag: 'div',
key: 0,
children: [
{text: '这是第2项'}
]
},
{
tag: 'div',
key: 1,
children: [
{text: '这是第3项'}
]
}
]
复制代码
我们抛开按钮的 patchVnode 过程,只看 v-for 循环出来的结果,首先遍历时,比较列表项,oldCh
和 ch
,通过 updateChildren
进行比较,拿到的是两个 div
, 然后因为又同时都有子节点文本节点,所以继续 updateChildren
进行 diff
操作,然后又执行到了 patchVnode
,因为是文本节点,所以走到逻辑是,直接设置新节点的文本到真实dom 上。即下图所示的情况。
接着执行下面一项,会重复上面的过程,从 而实现,这样
此时的情况是,在执行 updateChildren
的过程中,新 Vnode
遍历完毕,这样由于旧 Vnode
还剩下一个没有遍历完, 执行删除节点的操作,然后就出现了这样的情况。
我们观察到一个现象
- 文本删除正确,
- dom 删除不正确
正因为这样,所以 我们在审查元素时把元素增加了颜色,以便我们看清楚到底是否是删除错误。
因为我们把 key
设置为 index
导致了,新老 Vnode 中的前两项被认为是相同的节点,执行了 patchVnode
在这个过程中更改了文本,在 updateChildren
之后老节点有剩余,所以把最后一个 vnode 删除了,这就是误删,所以,不能使用 index 来作为 key。不能使用 index 作为 key。 不能使用 index 作为 key !!!
以上,便是整个过程,看过好多次,看过好多相关博客,总结出来,还有的部分还理解的不是很明白,可能几天后我又读了读,又会发现跟我之前理解的有出入,可能又会有新的认识,然后再看,再查,再理解,慢慢的,经过反反复复的过程可能才会慢慢的进步吧。