Vue diff算法
在 vue 中会维护一个和 DOM 节点对应的 vnode 对象
vnode 的 children 数组中对应子节点的 vnode 对象,所以在 vue 中通过 vnode 和真实的 DOM 树进行映射,我们也称之为虚拟树。
正是有了虚拟树,当数据更新时。我们可以对比新数据构建的 vnode 和老数据构建的 oldVnode 的差异,来实现精准更新。
而我们对比差异的算法就是采用的 diff。
通过 diff 对比虚拟树的差异,将差异通过打补丁(patch)的方式更新到对应的真实 DOM 节点上。
patch函数
由上就可以知道patch函数是 diff 流程的入口函数
首先我们要知道,patch会先在页面渲染的时候加载一次,然后就在vnode改变的时候调用。
// 首次渲染是DOM元素,后面是vnodefunction patch (oldVnode: VNode | Element, vnode: VNode): VNode {let i: number, elm: Node, parent: Nodeconst 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相同,说明是同一个vnodepatchVnode(oldVnode, vnode, insertedVnodeQueue)} else {// key、tag不相同,说明是不同的vnodeelm = 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 , styleisDef(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!// 老childrenconst oldCh = oldVnode.children as VNode[]// 新childrenconst ch = vnode.children as VNode[]if (oldVnode === vnode) return...// 新vnode 无text 有childrenif (isUndef(vnode.text)) {// 新vnode 有children 老vnode 有chidrenif (isDef(oldCh) && isDef(ch)) {if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)// 新vnode 有children 旧vnode 无children 有text} else if (isDef(ch)) {// 清空textif (isDef(oldVnode.text)) api.setTextContent(elm, '')// 添加childrenaddVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)// 新vnode 无children 旧vnode 有children} else if (isDef(oldCh)) {// 移除childrenremoveVnodes(elm, oldCh, 0, oldCh.length - 1)// 老vnode 有text} else if (isDef(oldVnode.text)) {// 清空textapi.setTextContent(elm, '')}// 新vnode 有text 无children// 老vnode text 不等于 新vnode text} else if (oldVnode.text !== vnode.text) {// 老vnode 有childrenif (isDef(oldCh)) {// 移除老vnode childrenremoveVnodes(elm, oldCh, 0, oldCh.length - 1)}// 设置新vnode textapi.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 = 0let newStartIdx = 0let oldEndIdx = oldCh.length - 1let oldStartVnode = oldCh[0]let oldEndVnode = oldCh[oldEndIdx]let newEndIdx = newCh.length - 1let newStartVnode = newCh[0]let newEndVnode = newCh[newEndIdx]let oldKeyToIdx: KeyToIndexMap | undefinedlet idxInOld: numberlet elmToMove: VNodelet before: anywhile (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 rightpatchVnode(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 leftpatchVnode(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里去找有没有某个节点有对应这个keyidxInOld = oldKeyToIdx[newStartVnode.key as string]// 没有在老children里找到对应的if (isUndef(idxInOld)) { // New elementapi.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 anyapi.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].elmaddVnodes(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那么就只会做四种匹配,就算指针中间有可复用的节点都不能被复用了)
