Diff 算法核心原理——源码
在 Vue 里面Diff 算法就是 patch
一、patch(源码地址:src/core/vdom/patch.js -700行)
其实 patch 就是一个函数,先介绍一下源码里的核心流程,再来看一下 patch 的源码,源码里每一行也有注释
1、可以接收四个参数,主要还是前两个
- oldVnode:老的虚拟 DOM 节点
- vnode:新的虚拟 DOM 节点
- hydrating:是不是要和真实 DOM 混合,服务端渲染的话会用到,这里不过多说明
- removeOnly:transition-group 会用到,这里不过多说明
2、主要流程是这样的:
- vnode 不存在,oldVnode 存在,就删掉 oldVnode
- vnode 存在,oldVnode 不存在,就创建 vnode
-
两个都存在的话,通过 sameVnode 函数(后面有详解)对比是不是同一节点
- 如果是同一节点的话,通过 patchVnode 进行后续对比节点文本变化或子节点变化
-
如果不是同一节点,就把 vnode 挂载到 oldVnode 的父元素下
- 如果组件的根节点被替换,就遍历更新父节点,然后删掉旧的节点
- 如果是服务端渲染就用 hydrating 把 oldVnode 和真实 DOM 混合
下面看完整的 patch 函数源码,注释里有说明:
1 // 两个判断函数 2 function isUndef (v: any): boolean %checks { 3 return v === undefined || v === null 4 } 5 function isDef (v: any): boolean %checks { 6 return v !== undefined && v !== null 7 } 8 return function patch (oldVnode, vnode, hydrating, removeOnly) { 9 // 如果新的 vnode 不存在,但是 oldVnode 存在 10 if (isUndef(vnode)) { 11 // 如果 oldVnode 存在,调用 oldVnode 的组件卸载钩子 destroy 12 if (isDef(oldVnode)) invokeDestroyHook(oldVnode) 13 return 14 } 15 16 let isInitialPatch = false 17 const insertedVnodeQueue = [] 18 19 // 如果 oldVnode 不存在的话,新的 vnode 是肯定存在的,比如首次渲染的时候 20 if (isUndef(oldVnode)) { 21 isInitialPatch = true 22 // 就创建新的 vnode 23 createElm(vnode, insertedVnodeQueue) 24 } else { 25 // 剩下的都是新的 vnode 和 oldVnode 都存在的话 26 27 // 是不是元素节点 28 const isRealElement = isDef(oldVnode.nodeType) 29 // 是元素节点 && 通过 sameVnode 对比是不是同一个节点 (函数后面有详解) 30 if (!isRealElement && sameVnode(oldVnode, vnode)) { 31 // 如果是 就用 patchVnode 进行后续对比 (函数后面有详解) 32 patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly) 33 } else { 34 // 如果不是同一元素节点的话 35 if (isRealElement) { 36 // const SSR_ATTR = 'data-server-rendered' 37 // 如果是元素节点 并且有 'data-server-rendered' 这个属性 38 if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) { 39 // 就是服务端渲染的,删掉这个属性 40 oldVnode.removeAttribute(SSR_ATTR) 41 hydrating = true 42 } 43 // 这个判断里是服务端渲染的处理逻辑,就是混合 44 if (isTrue(hydrating)) { 45 if (hydrate(oldVnode, vnode, insertedVnodeQueue)) { 46 invokeInsertHook(vnode, insertedVnodeQueue, true) 47 return oldVnode 48 } else if (process.env.NODE_ENV !== 'production') { 49 warn('这是一段很长的警告信息') 50 } 51 } 52 // function emptyNodeAt (elm) { 53 // return new VNode(nodeOps.tagName(elm).toLowerCase(), {}, [], undefined, elm) 54 // } 55 // 如果不是服务端渲染的,或者混合失败,就创建一个空的注释节点替换 oldVnode 56 oldVnode = emptyNodeAt(oldVnode) 57 } 58 59 // 拿到 oldVnode 的父节点 60 const oldElm = oldVnode.elm 61 const parentElm = nodeOps.parentNode(oldElm) 62 63 // 根据新的 vnode 创建一个 DOM 节点,挂载到父节点上 64 createElm( 65 vnode, 66 insertedVnodeQueue, 67 oldElm._leaveCb ? null : parentElm, 68 nodeOps.nextSibling(oldElm) 69 ) 70 71 // 如果新的 vnode 的根节点存在,就是说根节点被修改了,就需要遍历更新父节点 72 if (isDef(vnode.parent)) { 73 let ancestor = vnode.parent 74 const patchable = isPatchable(vnode) 75 // 递归更新父节点下的元素 76 while (ancestor) { 77 // 卸载老根节点下的全部组件 78 for (let i = 0; i < cbs.destroy.length; ++i) { 79 cbs.destroy[i](ancestor) 80 } 81 // 替换现有元素 82 ancestor.elm = vnode.elm 83 if (patchable) { 84 for (let i = 0; i < cbs.create.length; ++i) { 85 cbs.create[i](emptyNode, ancestor) 86 } 87 const insert = ancestor.data.hook.insert 88 if (insert.merged) { 89 for (let i = 1; i < insert.fns.length; i++) { 90 insert.fns[i]() 91 } 92 } 93 } else { 94 registerRef(ancestor) 95 } 96 // 更新父节点 97 ancestor = ancestor.parent 98 } 99 } 100 // 如果旧节点还存在,就删掉旧节点 101 if (isDef(parentElm)) { 102 removeVnodes([oldVnode], 0, 0) 103 } else if (isDef(oldVnode.tag)) { 104 // 否则直接卸载 oldVnode 105 invokeDestroyHook(oldVnode) 106 } 107 } 108 } 109 // 返回更新后的节点 110 invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch) 111 return vnode.elm 112 }
3、sameVnode
源码地址:src/core/vdom/patch.js -35行
这个是用来判断是不是同一节点的函数,源码:
function sameVnode (a, b) { return ( a.key === b.key && // key 是不是一样 a.asyncFactory === b.asyncFactory && ( // 是不是异步组件 ( a.tag === b.tag && // 标签是不是一样 a.isComment === b.isComment && // 是不是注释节点 isDef(a.data) === isDef(b.data) && // 内容数据是不是一样 sameInputType(a, b) // 判断 input 的 type 是不是一样 ) || ( isTrue(a.isAsyncPlaceholder) && // 判断区分异步组件的占位符否存在 isUndef(b.asyncFactory.error) ) ) ) }
4、patchVnode
源码地址:src/core/vdom/patch.js -501行
这个是在新的 vnode 和 oldVnode 是同一节点的情况下,才会执行的函数,主要是对比节点文本变化或子节点变化
4.1、主要流程是这样的:
- 如果 oldVnode 和 vnode 的引用地址是一样的,就表示节点没有变化,直接返回
- 如果 oldVnode 的 isAsyncPlaceholder 存在,就跳过异步组件的检查,直接返回
- 如果 oldVnode 和 vnode 都是静态节点,并且有一样的 key,并且 vnode 是克隆节点或者 v-once 指令控制的节点时,把 oldVnode.elm 和 oldVnode.child 都复制到 vnode 上,然后返回
-
如果 vnode 不是文本节点也不是注释的情况下
- 如果 vnode 和 oldVnode 都有子节点,而且子节点不一样的话,就调用 updateChildren 更新子节点
- 如果只有 vnode 有子节点,就调用 addVnodes 创建子节点
- 如果只有 oldVnode 有子节点,就调用 removeVnodes 删除该子节点
- 如果 vnode 文本为 undefined,就删掉 vnode.elm 文本
- 如果 vnode 是文本节点但是和 oldVnode 文本内容不一样,就更新文本
function patchVnode ( oldVnode, // 老的虚拟 DOM 节点 vnode, // 新的虚拟 DOM 节点 insertedVnodeQueue, // 插入节点的队列 ownerArray, // 节点数组 index, // 当前节点的下标 removeOnly // 只有在 ) { // 新老节点引用地址是一样的,直接返回 // 比如 props 没有改变的时候,子组件就不做渲染,直接复用 if (oldVnode === vnode) return // 新的 vnode 真实的 DOM 元素 if (isDef(vnode.elm) && isDef(ownerArray)) { // clone reused vnode vnode = ownerArray[index] = cloneVNode(vnode) } const elm = vnode.elm = oldVnode.elm // 如果当前节点是注释或 v-if 的,或者是异步函数,就跳过检查异步组件 if (isTrue(oldVnode.isAsyncPlaceholder)) { if (isDef(vnode.asyncFactory.resolved)) { hydrate(oldVnode.elm, vnode, insertedVnodeQueue) } else { vnode.isAsyncPlaceholder = true } return } // 当前节点是静态节点的时候,key 也一样,或者有 v-once 的时候,就直接赋值返回 if (isTrue(vnode.isStatic) && isTrue(oldVnode.isStatic) && vnode.key === oldVnode.key && (isTrue(vnode.isCloned) || isTrue(vnode.isOnce)) ) { vnode.componentInstance = oldVnode.componentInstance return } // hook 相关的不用管 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)) { // 遍历调用 update 更新 oldVnode 所有属性,比如 class,style,attrs,domProps,events... // 这里的 update 钩子函数是 vnode 本身的钩子函数 for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode) // 这里的 update 钩子函数是我们传过来的函数 if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode) } // 如果新节点不是文本节点,也就是说有子节点 if (isUndef(vnode.text)) { // 如果新老节点都有子节点 if (isDef(oldCh) && isDef(ch)) { // 如果新老节点的子节点不一样,就执行 updateChildren 函数,对比子节点 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) } if (isDef(data)) { // 执行 postpatch 钩子 if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode) } }
5、updateChildren
源码地址:src/core/vdom/patch.js -404行
这个是新的 vnode 和 oldVnode 都有子节点,且子节点不一样的时候进行对比子节点的函数,这里很关键,很关键!
比如现在有两个子节点列表对比,对比主要流程如下:
循环遍历两个列表,循环停止条件是:其中一个列表的开始指针 startIdx 和 结束指针 endIdx 重合
循环内容是:{
- 新的头和老的头对比
- 新的尾和老的尾对比
- 新的头和老的尾对比
- 新的尾和老的头对比。 这四种对比如图
以上四种只要有一种判断相等,就调用 patchVnode 对比节点文本变化或子节点变化,然后移动对比的下标,继续下一轮循环对比
如果以上四种情况都没有命中,就不断拿新的开始节点的 key 去老的 children 里找
- 如果没找到,就创建一个新的节点
-
如果找到了,再对比标签是不是同一个节点
- 如果是同一个节点,就调用 patchVnode 进行后续对比,然后把这个节点插入到老的开始前面,并且移动新的开始下标,继续下一轮循环对比
- 如果不是相同节点,就创建一个新的节点
}
- 如果老的 vnode 先遍历完,就添加新的 vnode 没有遍历的节点
- 如果新的 vnode 先遍历完,就删除老的 vnode 没有遍历的节点
为什么会有头对尾,尾对头的操作?
因为可以快速检测出 reverse 操作,加快 Diff 效率
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) { let oldStartIdx = 0 // 老 vnode 遍历的下标 let newStartIdx = 0 // 新 vnode 遍历的下标 let oldEndIdx = oldCh.length - 1 // 老 vnode 列表长度 let oldStartVnode = oldCh[0] // 老 vnode 列表第一个子元素 let oldEndVnode = oldCh[oldEndIdx] // 老 vnode 列表最后一个子元素 let newEndIdx = newCh.length - 1 // 新 vnode 列表长度 let newStartVnode = newCh[0] // 新 vnode 列表第一个子元素 let newEndVnode = newCh[newEndIdx] // 新 vnode 列表最后一个子元素 let oldKeyToIdx, idxInOld, vnodeToMove, refElm const canMove = !removeOnly // 循环,规则是开始指针向右移动,结束指针向左移动移动 // 当开始和结束的指针重合的时候就结束循环 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) // 然后把指针后移一位,从前往后依次对比 // 比如第一次对比两个列表的[0],然后比[1]...,后面同理 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) // 拿到新开始的 key,在老的 children 里去找有没有某个节点有这个 key idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx) // 新的 children 里有,可是没有在老的 children 里找到对应的元素 if (isUndef(idxInOld)) { /// 就创建新的元素 createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) } else { // 在老的 children 里找到了对应的元素 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 { // 如果标签是不一样的,就创建新的元素 createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) } } newStartVnode = newCh[++newStartIdx] } } // oldStartIdx > oldEndIdx 说明老的 vnode 先遍历完 if (oldStartIdx > oldEndIdx) { // 就添加从 newStartIdx 到 newEndIdx 之间的节点 refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue) // 否则就说明新的 vnode 先遍历完 } else if (newStartIdx > newEndIdx) { // 就删除掉老的 vnode 里没有遍历的节点 removeVnodes(oldCh, oldStartIdx, oldEndIdx) } }
至此,整个 Diff 流程的核心逻辑源码到这就结束了。