Vue diff算法
在 vue 中会维护一个和 DOM 节点对应的 vnode
对象
vnode
的 children
数组中对应子节点的 vnode
对象,所以在 vue 中通过 vnode
和真实的 DOM 树进行映射,我们也称之为虚拟树
。
正是有了虚拟树,当数据更新时。我们可以对比新数据构建的 vnode
和老数据构建的 oldVnode
的差异,来实现精准更新。
而我们对比差异的算法就是采用的 diff。
通过 diff 对比虚拟树的差异,将差异通过打补丁(patch
)的方式更新到对应的真实 DOM 节点上。
patch函数
由上就可以知道patch
函数是 diff 流程的入口函数
首先我们要知道,patch
会先在页面渲染的时候加载一次,然后就在vnode
改变的时候调用。
// 首次渲染是DOM元素,后面是vnode
function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node
const insertedVnodeQueue: VNodeQueue = []
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()
// 不是一个vnode就创建一个空的vnode并关联在DOM元素上
if (!isVnode(oldVnode)) {
// 创建一个空的vnode,并关联DOM元素
oldVnode = emptyNodeAt(oldVnode)
}
if (sameVnode(oldVnode, vnode)) {
// key、tag相同,说明是同一个vnode
patchVnode(oldVnode, vnode, insertedVnodeQueue)
} else {
// key、tag不相同,说明是不同的vnode
elm = oldVnode.elm!
parent = api.parentNode(elm) as Node
// 创建新的DOM元素
createElm(vnode, insertedVnodeQueue)
if (parent !== null) {
// 插入新的DOM元素
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
// 移除老的DOM元素
removeVnodes(parent, [oldVnode], 0, 0)
}
}
return vnode
}
整个逻辑就如下:
如果两个节点都是一样的,那么就深入检查他们的子节点。如果两个节点不一样那就说明Vnode
完全被改变了,就可以直接替换oldVnode
。
// 判断新旧节点是否一样
function sameVnode (a, b) {
return (
a.key === b.key && // key值
a.tag === b.tag && // 标签名
a.isComment === b.isComment && // 是否为注释节点
// 是否都定义了data,data包含一些具体信息,例如onclick , style
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b) // 当标签是<input>的时候,type必须相同
)
}
从代码中可以看到,会判断新旧vnode
的 key
, tag
等是否相等,如果相等就是同一个vnode
.
patchVnode
如果2个节点是一样的,就会进入patchVnode
的方法,判断他的子节点和文本节点。这里也就是对可复用节点进行打补丁,也就是派发更新。
function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
...
// vonde为新的vnode oldvnnode为老的vnode
// 设置vnode关联的DOM元素
const elm = vnode.elm = oldVnode.elm!
// 老children
const oldCh = oldVnode.children as VNode[]
// 新children
const ch = vnode.children as VNode[]
if (oldVnode === vnode) return
...
// 新vnode 无text 有children
if (isUndef(vnode.text)) {
// 新vnode 有children 老vnode 有chidren
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
// 新vnode 有children 旧vnode 无children 有text
} else if (isDef(ch)) {
// 清空text
if (isDef(oldVnode.text)) api.setTextContent(elm, '')
// 添加children
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
// 新vnode 无children 旧vnode 有children
} else if (isDef(oldCh)) {
// 移除children
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
// 老vnode 有text
} else if (isDef(oldVnode.text)) {
// 清空text
api.setTextContent(elm, '')
}
// 新vnode 有text 无children
// 老vnode text 不等于 新vnode text
} else if (oldVnode.text !== vnode.text) {
// 老vnode 有children
if (isDef(oldCh)) {
// 移除老vnode children
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
}
// 设置新vnode text
api.setTextContent(elm, vnode.text!)
}
hook?.postpatch?.(oldVnode, vnode)
}
patchVnode
函数的逻辑:
- 找到对应的 DOM 节点 elm,并且赋值给新的vnode.elm
- 判断新节点类型(
vnode.text
),如果是文本节点,更新elm
文本即可 - 非文本节点下,判断新老节点的子节点
- 如果新老节点都有子节点,走子节点的同层比较流程
updateChildren
- 如果只有新节点有子节点,直接使用
addVnodes
为 elm 添加子节点(先删除文本) - 如果只有旧节点有子节点,使用
removeVnodes
移除即可 - 如果都没有子节点,判断旧数据是否有文本节点,有则清空。
updateChildren
function updateChildren (parentElm: Node,
oldCh: VNode[],
newCh: VNode[],
insertedVnodeQueue: VNodeQueue) {
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: KeyToIndexMap | undefined
let idxInOld: number
let elmToMove: VNode
let before: any
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVnode == null) {
oldStartVnode = oldCh[++oldStartIdx] // Vnode might have been moved left
} else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx]
} else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx]
// 老开始和新开始对比
} 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)
api.insertBefore(parentElm, oldStartVnode.elm!, api.nextSibling(oldEndVnode.elm!))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
// 老结束和新开始对比
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
// 以上四种情况都没有命中
} else {
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
}
// 拿到新开始的key,在老children里去找有没有某个节点有对应这个key
idxInOld = oldKeyToIdx[newStartVnode.key as string]
// 没有在老children里找到对应的
if (isUndef(idxInOld)) { // New element
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
// 在老children里找到了对应的
} else {
// 找到了对应key的元素(key相等)
elmToMove = oldCh[idxInOld]
// key相等 判断tag是否相等
if (elmToMove.sel !== newStartVnode.sel) {
// key相等 tag不相等
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
} else {
// key相等 tag相等
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
oldCh[idxInOld] = undefined as any
api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!)
}
}
newStartVnode = newCh[++newStartIdx]
}
}
if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
if (oldStartIdx > oldEndIdx) {
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
}
}
从图中可以看到 会有四个指针(oldStartVnode
,oldEndVnode
, newStartVnode
, newEndVnode
)分别指向,新老children节点的头尾。
判断逻辑是这样的。
第一步:判断(oldStartVnode
, newStartVnode
)他俩指正指的vnode是否匹配上了,如果匹配上了,oldStartVnode
,newStartVnode
指针向右移动,真实DOM的位置不变。
第二步:(oldEndVnode
, newEndVnode
) 他俩是否匹配上了,如果匹配上了,oldEndVnode
, newEndVnode
指针向左移动,真实DOM的位置不变。
第三步:(oldStartVnode
, newEndVnode
)判断是否匹配上了,如果匹配上了那么真实DOM中的第一个节点会移到最后
第四步:(oldEndVnode
, newStartVnode
)判断匹配上了,如果匹配上了那么真实DOM中的最后一个节点会移到最前,匹配上的两个指针向中间移动。
第五步:如果四种匹配没有一对是成功的,分为两种情况
- 如果新旧子节点都存在key,那么会根据
oldChild
的key生成一张hash表,用newStartVnode
的key与hash表做匹配,匹配成功就判断newStartVnode
和匹配节点是否为sameNode
,如果是,就在真实DOM中将成功的节点移到最前面,否则,将newStartVnode
生成对应的节点插入到DOM中对应的oldS
位置,newStartVnode
指针向中间移动,被匹配old中的节点置为null。 - 如果没有key,则直接将
newStartVnode
生成新的节点插入真实DOM
(ps:这下可以解释为什么v-for的时候需要设置key了,如果没有key那么就只会做四种匹配,就算指针中间有可复用的节点都不能被复用了)