转载于
说说 vue2 和 vue3 核心diff算法 - 掘金 (juejin.cn)
觉得很不错,记录一下。
作者:陈小边
链接:https://juejin.cn/post/7092068900589797413
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
命令式和声明式
命令式:代码本身描述的是“做事的过程”,在代码开发的时候,我们需要维护实现目标的整个过程,包括要手动完成DOM元素的创建、更新、删除等工作。如:Jquery就是典型的命令式框架.
声明式:更加关注结果,代码展示的就是我们要的结果,看上去更加直观,至于做事儿的过程,并不需要我们关心.
从上面可以看出声明式的可维护性高于命令式,心智负担也小于命令式,但性能比命令式要差。
命令式代码的更新性能消耗 = 直接修改的性能消耗,
声明式 = 直接修改 + 找出差异的性能消耗。
那么我们只要能把找出差异的性能消耗最小化,就可以让声明式的消耗无限接近命令式。这个时候我们就要使用虚拟dom和diff算法了
什么是虚拟DOM和diff算法
虚拟DOM就是用来表示真实dom的对象,vue通过模版编译生成虚拟DOM树,然后在通过渲染器渲染成真实DOM,当数据更新时,产生新的虚拟dom树,如果直接用新的虚拟DOM树生成真实DOM并不是最优的方法。为了进一步降低找出差异的性能的性能消耗,就要使用diff算法。Diff算法是一种对比算法。对比两者是旧虚拟DOM和新虚拟DOM,对比出是哪个虚拟节点更改了,找出这个虚拟节点,并只更新这个虚拟节点所对应的真实节点,实现精准地更新真实DOM。
注:下图示中的a,b,c为节点的key值,所有的移动插入删除操作都在真实dom,下文所讲是diff算法中的核心部分
vue2 双端diff算法的实现
vue2采用了双端diff算法。核心方法是updateChildren,通过新前与旧前、新后与旧后、新后与旧前、新前与旧后、暴力比对5种查找。
新前:newChildren中所有未处理的第一个节点
新后:newChildren中所有未处理的最后一个节点
旧前:oldChildren中所有未处理的第一个节点
旧后:oldChildren中所有未处理的最后一个节点
在具体介绍前我们还需要了解isSameVnode这个用来对比两个节点是否相同的方法
function isSameVnode(oldVnode, newVnode) { return oldVnode.tag === newVnode.tag && oldVnode.key === newVnode.key; }
|
新前与旧前

新前与旧前对比,如果相同那么新,老的开始下标往后移动一格,上图中a的新老节点相同,位置移动b位置,此时新节点为f,两节点不同,进入新后与旧后比对
新后与旧后

新后与旧后对比,如果相同那么新,老的结束下标往前移动一格,上图中g的新老节点相同,位置移动f位置,此时新节点为b,两节点不同,这时发现新后与旧后,新前与旧前都不满足,进入新后与旧后比对
新后与旧前
新后与旧前对比,如果相同那么,把老的开始节点移动到老的结束节点后面,然后老的开始下标往后移动一格,新的结束下标往前移动一格。这时发现新的位置以上3种都不能满足,进入新前与旧后比对
新前与旧后
新前与旧后对比,如果相同那么,把老的结束节点移动到老的开始节点前面,然后新的开始下标往后移一格,老的结束下标往前移动一格。
暴力比对(乱序)

如果节点比对的时候上面4种方法都不适用时,此时我们只能用最暴力的方法,首先我们需要循环oldChildren生成一个key
和index
的映射表{'a': 0, 'b': 1}
,然后我们用新的开始节点的key
,去映射表中查找,如果找到就把该节点移动到最前面,且原来的位置用undefined
占位,避免数组塌陷 防止老节点移动走了之后破坏了初始的映射表位置,如果没有找到就直接把新节点插入
新节点有剩余
有时候我们会添加新的数据,这时后上面循环结束后,newChildren还有剩余的节点还没有处理,我们需要循环这些节点,逐个插入。如上图所示。当oldStartIndex > oldEndIndex
时,新的子节点还有c, d两个节点多余,需循环插入这2个节点。
老节点有剩余
另一种情况就是当我们删除元素,这时当对比循环结束后,oldChildren还有剩余的节点还没有处理,我们需循环这些节点,逐个删除。如上图所示。当newStartIndex > newEndIndex
时,老的子节点还有c,d两个节点多余,循环删除这2个节点
全部核心代码
function updateChildren(el, oldChildren, newChildren) { let oldStartIndex = 0; let oldStartVnode = oldChildren[0]; let oldEndIndex = oldChildren.length - 1; let oldEndVnode = oldChildren[oldEndIndex] let newStartIndex = 0; let newStartVnode = newChildren[0]; let newEndIndex = newChildren.length - 1; let newEndVnode = newChildren[newEndIndex] const makeIndexBykey = (children) => { return children.reduce((memo, cur, index) => { memo[cur.key] = index return memo }, {}) } const keysMap = makeIndexBykey(oldChildren) while(oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { if (!oldStartVnode) { oldStartVnode = oldChildren[++oldStartIndex] } else if (!oldEndVnode) { oldEndVnode = oldChildren[--oldEndIndex] }
if (isSameVnode(oldStartVnode, newStartVnode)) { patch(oldStartVnode, newStartVnode) oldStartVnode = oldChildren[++oldStartIndex] newStartVnode = newChildren[++newStartIndex] } else if (isSameVnode(oldEndVnode, newEndVnode)) { patch(oldEndVnode, newEndVnode) oldEndVnode = oldChildren[--oldEndIndex] newEndVnode = newChildren[--newEndIndex] } else if (isSameVnode(oldStartVnode, newEndVnode)) { patch(oldStartVnode, newEndVnode) el.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling) oldStartVnode = oldChildren[++oldStartIndex] newEndVnode = newChildren[--newEndIndex] } else if (isSameVnode(oldEndVnode, newStartVnode)) { patch(oldEndVnode, newStartVnode) el.insertBefore(oldEndVnode.el, oldStartVnode.el) oldEndVnode = oldChildren[--oldEndIndex] newStartVnode = newChildren[++newStartIndex] } else { const moveIndex = keysMap[newStartVnode.key] if (!moveIndex) { el.insertBefore(createElm(newStartVnode), oldStartVnode.el) } else { const moveNode = oldChildren[moveIndex] oldChildren[moveIndex] = undefined el.insertBefore(moveNode.el, oldStartVnode.el) patch(newStartVnode, moveNode) } newStartVnode = newChildren[++newStartIndex] } } if (newStartIndex <= newEndIndex ) { for (let i = newStartIndex; i <= newEndIndex; i++) { let anchor = newChildren[newEndIndex + 1] == null ? null : newChildren[newEndIndex + 1].el el.insertBefore(createElm(newChildren[i]), anchor) } } if (oldStartIndex <= oldEndIndex) { for (let i = oldStartIndex; i <= oldEndIndex; i++) { if (oldChildren[i] != null) { el.removeChild(oldChildren[i].el) } } } # }
|
vuu2 双端diff算法流程图(放大查看)

vue3 快速diff算法的实现
注:下边所讲diff算法是vuejs设计与开发书的版本,与源码版有些差别,但核心部分是一样的,可去mini-vue查看源码版
vue3 使用了快速diff算法
,核心方法是patchKeyedChildren
,首先是借鉴了纯文本diff算法中的预处理思路,处理新旧两个组子节点中相同的前置节点和后置节点。处理完后,如果剩余节点无法简单的通过挂载新节点或者卸载已经不存在的节点来完成更新,则需要根据节点的索引关系,构建出一个最长递增子序列。最长递增子序列所指向的节点即为不需要移动的节点。
相同前置节点处理

前置节点的处理是定义了一个j
变量,分别指向新,老两个组子节点,比较指向的新,老节点是否相同,如果相同指针 +1
,直到两个节点不同时结束前置节点的处理
相同后置节点处理

后置节点的处理是定义了索引oldEnd指向旧的一组子节点的最后一个节点和索引newEnd指向新的一组子节点的最后一个节点。然后比较两个指向的新旧节点,如果相同指向 -1
,直到两个节点不同时结束后置节点的处理
剩余节点的处理
当我们处理完相同的前置节点和后置节点后,如果还有剩余节点,就要对剩余节点进行处理,剩余节点分为3中情况,分别是只有新的一组的子节点有剩余
,只有老的一组的子节点有剩余
,新老两组的子节点都有剩余
;
只有新的一组的子节点有剩余

当条件满足j > oldEnd
且 j <= newEnd
时,表示只有新的一组的子节点还有未处理的节点,我们需要循环 j -> newEnd
中的节点进行插入
只有老的一组的子节点有剩余
当条件满足 j > newEnd
且 j <= oldEnd
时,表示只有老的一组的子节点还有未处理的节点,我们需要循环 j -> oldEnd
中的节点进行删除
新老两组的子节点都有剩余
该状态下主要核心为3个部分:
构建source数组用于存放新的一组子节点每个节点在老的一组中存在的原来位置(索引) 首先是定义一个长度为剩余新的一组子节点的长度的数组source
,初始值都为-1
,还定义了一个变量patched
用于记录更新过的节点数量,然后遍历新的一组子节点,构建key
与index
的映射表,最后遍历老的一组节点,去映射表中寻找,k = keyIndex[oldVnode.key];
,如果找到就把对应的索引存入到source
对应的位置中,没有找到说明该节点多余,直接删除。

判断是否需要移动节点,首页我们会定义两个变量,moved
用于记录是否需要移动的阀值,pos
用于记录最后上一个节点在新组中的索引,然后用上述的k
与 pos
比较,如果 k < pos
,说明不是升序需要移动, 否则 pos = k
利用最长递增子序列来优化移动逻辑,如果moved = true
, 首先通过最长递增子序列获取到升序列表存放的是索引
,然后从后遍历新的一组节点,节点的索引与升序列表对比,如果对比上了说明不需要移动,否则需要移动。

全部核心代码
const newChildren = n2.children; const oldChildren = n1.children; let j = 0; let oldVnode = oldChildren[j]; let newVnode = newChildren[j]; while(oldVnode.key === newChildren.key) { patch(oldVnode, newVnode, container); j++; oldVnode = oldChildren[j]; newVnode = newChildren[j]; } let oldEnd = oldChildren.length - 1; let newEnd = newChildren.length - 1; oldVnode = oldChildren[oldEnd]; newVnode = newChildren[newEnd];
while(oldVnode.key === newVnode.key) { patch(oldVnode, newVnode, container) oldEnd-- newEnd-- oldVnode = oldChildren[oldEnd] newVnode = newChildren[newEnd] } if (j > oldEnd && j <= newEnd) { const anchorIndex = newEnd + 1; const anchor = anchorIndex < newChildren.length ? newChildren[anchorIndex].el : null; while (j <= newEnd) { patch(null, newChildren[j++], container, anchor) } } else if (j > newEnd && j <= oldEnd) { while (j <= oldEnd) { unmount(oldChildren[j++]) } } else { const count = newEnd - j + 1; const source = new Array(count); source.fill(-1);
const oldStart = j; const newStart = j; let moved = false; let pos = 0; const keyIndex = {}; for(let i = newStart; i <= newEnd; i++) { keyIndex[newChildren[i].key] = i; } let patched = 0; for(let i = oldStart; i <= oldEnd; i++) { oldVnode = oldChildren[i]; if (patched <= count) { const k = keyIndex[oldVnode.key]; if (typeof k !== 'undefined') { newVnode = newChildren[k]; patch(oldVnode, newVnode, container); patched++; source[k - newStart] = i; if (k < pos) { moved = true } else { pos = k } } else { unmount(oldVnode) } } else { unmount(oldVnode) } } } if (moved) { const seq = lis(source); let s = seq.length - 1; for (i = count - 1; i >=0; i--) { const pos = i + newStart; const newVnode = newChildren[pos]; const nextPos = pos + 1; const anchor = nextPos < newChildren.length ? newChildren[nextPos].el : null; if (source[i] === -1) { patch(null, newVnode, container, anchor); } else if(s < 0 || i !== seq[s]) { insert(newVnode.el, container, anchor) } else { s-- } } }
}
|
vue3 快速diff算法流程图(放大查看)
