- 本文主要对最近学习的diff算法做个总结,会对node节点存在key时的diff算法和节点key不存在时的diff算法进行浅析,节点key存在时的最大索引法,双端比较法,最长递增子序列法进行逐一学习;会结合部分源码函数进行代码分析和执行分析;
1. 节点没有key值时所采用的算法;Vue3中在节点没有key的情况下采用的算法;
- 1.1 遍历新旧节点数组中长度较小的节点数组,应用patch函数进行更新;
- 1.2 对比新旧节点数组的长度,如果newNodeChild.length > oldNodeChild.length, 则说明有新节点需要添加;如果newNodeChild.length < oldNodeChild.length,则说明有旧节点需要移除;
<!-- prevChildren:旧节点数组;nextChildren:新节点数组;-->
// 获取公共长度,取新旧 children 长度较小的那一个
const prevLen = prevChildren.length;
const nextLen = nextChildren.length;
const commonLength = prevLen > nextLen ? nextLen : prevLen;
for (let i = 0; i < commonLength; i++) {
patch(prevChildren[i], nextChildren[i], container);
}
// 如果 nextLen > prevLen,将多出来的新元素添加
if (nextLen > prevLen) {
for (let i = commonLength; i < nextLen; i++) {
mount(nextChildren[i], container);
}
} else if (prevLen > nextLen) {
// 如果 prevLen > nextLen,将多出来的旧元素移除
for (let i = commonLength; i < prevLen; i++) {
container.removeChild(prevChildren[i].el);
}
}
通过key属性,尽可能的复用已有的DOM元素,
注意:
- 新增,删除,移动的操作都是对于真实dom来操作的;
- 真实dom的移动顺序要和新vnode的顺序保持一致;
- 不要用数组下标作为key值;否则对节点进行逆序增加,逆序删除等破坏结构顺序的操作时,会产生真实dom没必要的更新,影响渲染效率;当结构中存在输入类的dom时,可能会产生界面显示问题;
- 不要用数组下标index拼接其他值作为key,这样会导致新节点中的每个key都不能在旧节点中找到可复用的节点,每个新节点都需要新建;
2. 节点key存在时的算法;
2.1. 最大索引法;通过节点key值尽可能多的复用DOM元素;React所采用的;
思路:先外层循环遍历新节点数组,内层循环遍历旧节点数组,根据新旧虚拟节点的key是否相等,判断该新节点是否可以复用旧节点;如果可复用的话是否需要移动以及怎样移动?(复用或新增)然后再循环旧节点数组,查找需要删除的旧节点;
1)遍历新节点数组,找到该新节点在旧节点数组中的下标,并记录下来,maxIndex默认取0;
- 如果找到的下标是递增的,则说明新旧节点数组的顺序一致,无需移动;
- 如果找到的下标 < 最大下标,即递减了,则说明该节点在旧节点数组中比较靠前,但是在新节点数组中靠后,则将该旧节点对应的真实dom移动到上一个新节点对应的真实dom后面;
- 新增:如果该新节点在旧节点数组中未找到,则创建新dom元素,挂载新节点;
2)删除:遍历旧节点数组,判断该旧节点是否在新节点数组中存在,如果不存在,则删除该旧节点对应的真实dom;
<!-- nextChildren:新节点数组;prevChildren:旧节点数组 -->
let lastIndex = 0; // 存放新节点在旧节点数组中的最大下标值
// 以新节点数组为基准遍历
for (let i = 0; i < nextChildren.length; i++) {
const nextVNode = nextChildren[i];
let j = 0,
find = false; // 标识该新节点是否在旧节点数组中存在;
for (j; j < prevChildren.length; j++) {
const prevVNode = prevChildren[j];
if (nextVNode.key === prevVNode.key) {
find = true;
patch(prevVNode, nextVNode, container);
if (j < lastIndex) {
// 需要移动,将旧节点对应的真实dom移动到上一个新节点对应的真实dom后面(即兄弟节点前面)
const refNode = nextChildren[i - 1].el.nextSibling; // 上一个新节点真实dom的兄弟节点
container.insertBefore(prevVNode.el, refNode);
break;
} else {
// 更新 lastIndex
lastIndex = j;
}
}
}
if (!find) {
// 未在旧节点中找到该新节点,挂载新节点
// 找到 refNode
const refNode =
i - 1 < 0 ? prevChildren[0].el : nextChildren[i - 1].el.nextSibling;
mount(nextVNode, container, false, refNode);
}
}
// 移除已经不存在的节点
for (let i = 0; i < prevChildren.length; i++) {
const prevVNode = prevChildren[i];
// 拿着旧 VNode 去新 children 中寻找是否存在相同的节点
const has = nextChildren.find((nextVNode) => nextVNode.key === prevVNode.key);
if (!has) {
// 如果没有找到相同的节点,则移除该旧节点对应的真实dom
container.removeChild(prevVNode.el);
}
}
2.2.双端比较法; Vue2采用的算法;
- 1)oldStartNode 和 newStartNode 对比;
- 2)oldEndNode 和 newEndNode 对比;
- 3)oldStartNode 和 newEndNode 对比;
- 4)oldEndNode 和 newStartNode 对比;
<!--
Vue2中源码路径:Vue2\src\core\vdom\patch.js
-->
function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
let oldStartIdx = 0 // 旧节点数组的开始下标
let newStartIdx = 0 //新节点数组的开始下标
let oldEndIdx = oldCh.length - 1 // 旧节点数组的结束下标
let newEndIdx = newCh.length - 1 // 新节点数组的结束下标
let oldStartVnode = oldCh[0] // 旧节点数组的开始节点
let oldEndVnode = oldCh[oldEndIdx] // 旧节点数组的结束节点
let newStartVnode = newCh[0] // 新节点数组的开始节点
let newEndVnode = newCh[newEndIdx] // 新节点数组的结束节点
/**
* oldKeyToIdx: 对象,旧节点的map结构, {key:index}
* idxInOld: number | undefined,该新节点如果在旧节点中存在时返回的存在下标;
*/
let oldKeyToIdx, idxInOld, vnodeToMove, refElm
const canMove = !removeOnly
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(newCh)
}
/**
注意:真实dom的移动顺序要和新vnode的顺序保持一致;
*/
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
// 处理前面循环在非理想情况下置为undefined的位置;
oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
// 处理前面循环在非理想情况下置为undefined的位置;
oldEndVnode = oldCh[--oldEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
// 旧start节点和新start节点相同,则vnode不移动
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// 旧end节点和新end节点相同,则vnode不移动
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
// 旧start节点和新end节点相同, 则将旧节点对应的真实dom移动到最后面(即最后一个旧节点的下一个兄弟节点前面),
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
// nodeOps.nextSibling(oldEndVnode.elm):返回旧end节点的下一个兄弟节点;
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
// 旧end节点和新start节点相同,则将旧end节点对应的真实dom移动到最前面(即旧start节点的最前面);
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
// 非理想情况下;比较的节点不相同;
// 则在旧节点中查找是否存在与新节点key值相同的节点,如果存在则将旧节点该位置的真实dom进行移动,并将该旧虚拟节点置为undefined;下一次循环式需跳过该位置
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 构建map结构{key: node下标},方便查找,只在第一次构建
// 根据newStart节点的key值在旧节点数组中查找是否存在相同key值的节点,如果存在将下标index记录下来;
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
// newStart节点不存在key属性,则直接用节点在旧数组中比对查找,
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
if (isUndef(idxInOld)) { // New element
// 说明newStart节点未在旧节点数组中找到,则新建节点
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
// 在旧节点数组中通过key找到了该newStart节点
vnodeToMove = oldCh[idxInOld] // 新节点在旧节点数组中对应位置的节点
if (sameVnode(vnodeToMove, newStartVnode)) {
// 相同的节点元素,只需移动
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
//将旧节点数组中对应位置处的vnode置为空,当oldStartIndex或oldEndIndex走到该位置时,跳过该节点的比对
oldCh[idxInOld] = undefined
// 将找到的节点对应的真实dom节点移动到oldStart节点的前面
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) {
// 当旧节点已经循环完毕,但是新节点中还存在没有被处理的全新节点,则直接将剩余的新节点逐个插入到oldStart前面;
// 比如oldNode: [a, b, c],newNode:[d,a,b,c]的情况;
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else if (newStartIdx > newEndIdx) {
// 新节点先循环完毕,则将剩余旧节点对应的真实dom移除;
// 比如oldNode:[a,b,c],newNode:[a,c]
removeVnodes(oldCh, oldStartIdx, oldEndIdx)
}
}
2.3. 最长递增子序列法,Vue3采用的算法;
- 1 定义变量i,查找相同的前缀进行patch;
- 2.定义变量e1(旧节点数组的长度-1),e2(新节点数组的长度 - 1),查找相同的后缀进行patch;
- 3.patch完相同的前缀和后缀后,判断i,e1,e2之间的关系;如果i大于e1则说明旧节点数组中所有节点都已经参与patch了,但是新节点数组中的节点还存在为patch的节点,即有节点需要新建;i>e2时,说明有旧节点需要移除;
- 4.如果新旧节点数组中都存在未patch的节点,则通过构建未参与patch的新节点数组的最长递增子序列,查找需要移动的节点以及判断节点怎样移动;查找需要创建的新节点进行新建与挂载;
参考博客:
vue3.0 diff算法详解(超详细)
源码路径:packages\runtime-core\src\renderer.ts
// can be all-keyed or mixed;节点存在key时的diff比对算法
const patchKeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
parentAnchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
optimized: boolean
) => {
let i = 0
const l2 = c2.length
let e1 = c1.length - 1 // prev ending index
let e2 = l2 - 1 // next ending index
// 1. sync from start;查找相同的前缀,只需一个变量即可
// (a b) c
// (a b) d e
while (i <= e1 && i <= e2) {
const n1 = c1[i]
const n2 = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
if (isSameVNodeType(n1, n2)) {
// n1和n2的节点类型和key值都相同;
patch(
n1,
n2,
container,
null,
parentComponent,
parentSuspense,
isSVG,
optimized
)
} else {
// 一发现节点不相同,则立即退出循环
break
}
i++
}
// 2. sync from end;查找相同的后缀,由于新旧节点数组的长度不一定相同,所以需要两个变量
// a (b c)
// d e (b c)
while (i <= e1 && i <= e2) {
const n1 = c1[e1]
const n2 = (c2[e2] = optimized
? cloneIfMounted(c2[e2] as VNode)
: normalizeVNode(c2[e2]))
if (isSameVNodeType(n1, n2)) {
patch(
n1,
n2,
container,
null,
parentComponent,
parentSuspense,
isSVG,
optimized
)
} else {
break
}
e1--
e2--
}
// 3. common sequence + mount;公共序列+挂载
// (a b)
// (a b) c
// i = 2, e1 = 1, e2 = 2;
// (a b)
// c (a b)
// i = 0, e1 = -1, e2 = 0
if (i > e1) {
// 说明旧节点数组中的所有节点已经参与前两步的patch了
if (i <= e2) {
// 但是新节点数组中还有剩余节点未参与
const nextPos = e2 + 1
const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
while (i <= e2) {
// 将新节点数组中未参与比较的节点逐个进行新建挂载;
// 未参与的节点下标范围:[i, e2]
patch(
null,
(c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i])),
container,
anchor,
parentComponent,
parentSuspense,
isSVG
)
i++
}
}
}
// 4. common sequence + unmount;公共序列+移除
// (a b) c
// (a b)
// i = 2, e1 = 2, e2 = 1
// a (b c)
// (b c)
// i = 0, e1 = 0, e2 = -1
else if (i > e2) {
// 说明新节点数组中的所有节点已经参与前两步的patch了
while (i <= e1) {
// 但是旧节点数组中还有剩余;则逐个移除未参与的旧节点;未参与的旧节点范围:[i, e1]
unmount(c1[i], parentComponent, parentSuspense, true)
i++
}
}
// 5. unknown sequence;未知序列;i既不大于e1也不大于e2;即新旧节点数组中都还有未参与patch的节点,
// 用最长递增子序列查找需要移动的节点以及怎样移动?
// [i ... e1 + 1]: a b [c d e] f g
// [i ... e2 + 1]: a b [e d c h] f g
// i = 2, e1 = 4, e2 = 5
// 下面的代码以prevChild=[a,b,c,d,e,f,g],nextChild=[a,b,e,d,c,h,f,g]例子带入理解
else {
const s1 = i // prev starting index
const s2 = i // next starting index
// 5.1 build key:index map for newChildren;
// 构建未参与的剩余新节点的map结构,方便查找;map的key为该节点的key值,value为该节点在新节点数组中的下标
const keyToNewIndexMap: Map<string | number, number> = new Map()
for (i = s2; i <= e2; i++) {
const nextChild = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
if (nextChild.key != null) {
if (__DEV__ && keyToNewIndexMap.has(nextChild.key)) {
warn(
`Duplicate keys found during update:`,
JSON.stringify(nextChild.key),
`Make sure keys are unique.`
)
}
keyToNewIndexMap.set(nextChild.key, i)
}
}
// 5.2 loop through old children left to be patched and try to patch
// matching nodes & remove nodes that are no longer present(匹配节点&删除不再存在的节点)
let j
let patched = 0 // 记录已patch的节点数量,当patched > toBePatched时,则说明已经patch完毕,多余节点需要移除;
const toBePatched = e2 - s2 + 1 // 新节点数组中剩余未patch的节点个数
let moved = false // 记录节点是否需要移动
// used to track whether any node has moved(用于跟踪是否有任何节点已移动)
let maxNewIndexSoFar = 0 // 最大索引法
// works as Map<newIndex, oldIndex>
// Note that oldIndex is offset by +1
// and oldIndex = 0 is a special value indicating the new node has
// no corresponding old node.
// used for determining longest stable subsequence
// 初始化长度为toBePatched的数组,并将数组元素初始化为0,存放未参与patch的新节点在未参与patch的旧节点数组中的下标
const newIndexToOldIndexMap = new Array(toBePatched)
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
for (i = s1; i <= e1; i++) {
// 遍历未参与patch的旧节点数组,
const prevChild = c1[i]
if (patched >= toBePatched) {
// 已更新的节点数 > 剩余未patch的节点数,则卸载旧节点
// all new children have been patched so this can only be a removal
unmount(prevChild, parentComponent, parentSuspense, true)
continue
}
let newIndex // 存放相同key的新节点在新节点数组中的下标
if (prevChild.key != null) {
// 旧节点存在key
newIndex = keyToNewIndexMap.get(prevChild.key) // 用旧节点的key在新节点对应的map中查找是否存在该key值的节点,存在则返回其所在下标
} else {
// key-less node, try to locate a key-less node of the same type(无密钥节点,尝试定位同一类型的无密钥节点)
for (j = s2; j <= e2; j++) {
// 在未参与patch的剩余新节点中查找是否有相同的节点
if (
newIndexToOldIndexMap[j - s2] === 0 &&
isSameVNodeType(prevChild, c2[j] as VNode)
) {
// 如果找到有新节点对应,则记录该节点在新节点数组中的下标
newIndex = j
break
}
}
}
if (newIndex === undefined) {
// 未找到,则说明该旧节点对应的真实dom需要被移除
unmount(prevChild, parentComponent, parentSuspense, true)
} else {
// 找到了;更新newIndexToOldIndexMap数组中该节点在旧节点数组中的下标值;
newIndexToOldIndexMap[newIndex - s2] = i + 1
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex
} else {
moved = true
}
patch(
prevChild,
c2[newIndex] as VNode,
container,
null,
parentComponent,
parentSuspense,
isSVG,
optimized
)
patched++
}
}
// 5.3 move and mount
// generate longest stable subsequence only when nodes have moved
// 如果需要移动,则返回最长递增子序列的下标数组;
// 如newIndexToOldIndexMap=[5,4,3,0],则返回[2],表示最长递增子序列是由下标为2的元素构成;
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap)
: EMPTY_ARR // [2]
j = increasingNewIndexSequence.length - 1 // 0
// looping backwards so that we can use last patched node as anchor
// i = toBePatched - 1时,即c2[e2]节点
for (i = toBePatched - 1; i >= 0; i--) { // i: 3,2,1,0
// 有多少新节点未被patch
const nextIndex = s2 + i // 新节点的下标
const nextChild = c2[nextIndex] as VNode // 新节点,即 h,c,d,e
// l2 = c2.length,即新节点数组的长度,则新节点的下标为[0, l2-1]
const anchor =
nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
if (newIndexToOldIndexMap[i] === 0) {
// 此新节点在旧节点中不存在,需要新建挂载
// mount new
patch(
null,
nextChild,
container,
anchor,
parentComponent,
parentSuspense,
isSVG
)
} else if (moved) {
// 此新节点在旧节点中存在,且需要移动
// move if:
// There is no stable subsequence (e.g. a reverse)
// OR current node is not among the stable sequence
// 比较的是下标;
if (j < 0 || i !== increasingNewIndexSequence[j]) {
move(nextChild, container, anchor, MoveType.REORDER)
} else {
j--
}
}
}
}
}
处理未知序列的示例:以 prevChild: [a b c d e f g],nextChild: [a b e d c h f g]为例,分析Vue3的diff算法执行;
-
已知条件:c1 = prevChild; c2 = nextChild; i=0;l2=c2.length = 8; l1 = c1.length=7; e1 = 6;e2 = 7
-
1.使用变量i, patch新旧节点数组中相同前缀的节点,如果发现节点不同则退出循环,记录此时变量i的值;
相同前缀节点:[a,b];i=2; -
2.使用变量e1,e2,patch新旧节点数组中相同后缀的节点,如果发现节点不同则退出循环,记录此时e1,e2变量的值;
相同后缀节点:[f,g],此时e1 = 4;e2=5; -
3.判断变量i是否大于e1并且小于e2(即旧节点数组中的所有节点都已经参与patch了,说明新节点数组中[i,e2]范围的节点需要新建),
-
4.判断变量i是否大于e2并且小于e1,(即新节点数组中的所有节点都已经参与patch了,旧节点数组中[i,e1]范围的节点需要被移除);
此时 i<e1 && i<e2;即新旧节点数组中都还有未参与patch的节点; -
5.处理未知序列,即i既不大于e1也不大于e2的情况;
s1 = s2 = i = 2 -
5.1 构建未参与patch的剩余新节点的map结构;
keyToNewIndexMap = {e:2,d:3,c:4,h:4} -
5.2 构建剩余未处理的新节点在旧节点数组中的下标;根据下标判断是否有旧节点需要移除;是否有旧节点需要移动? 剩余未处理的新节点个数:toBePatched = e2 - s2 +1 = 4; 剩余未处理的新节点在旧节点数组中的下标:newIndexToOldIndexMap初始为[0,0,0,0];
-
遍历剩余未处理的旧节点[i,e1]范围的节点,判断剩余未处理的新节点在旧节点数组中的下标,从而更新newIndexToOldIndexMap数组;更新后的值为[5,4,3,0]; newIndex:与旧节点的key相同的新节点在keyToNewIndexMap的键值;
代码执行过程如下:
最大索引法判断是否有节点需要移动:maxNewIndexSoFar:未参与的新节点在旧节点数组中的最大下标; -
5.3 根据newIndexToOldIndexMap数组获取最长递增子序列的下标数组;最长递增子序列的算法可以参考leetcode第300题; increasingNewIndexSequence = [2],表示最长递增子序列是由下标为2的元素构成; j = increasingNewIndexSequence.length-1 = 0
-
5.4 从后往前遍历未patch的新节点;对新增的节点进行新建挂载;如果moved=true,则表明有旧节点需要移动,则对旧节点进行适当的移动操作;
如果文中有错误欢迎大家指正,我们一起学习一起进步,谢谢啦;