1
本文基于Vue 3.2.30版本源码进行分析
为了增加可读性,会对源码进行删减、调整顺序、改变部分分支条件的操作,文中所有源码均可视作为伪代码
由于ts版本代码携带参数过多,不利于展示,大部分伪代码会取编译后的js代码,而不是原来的ts代码

整体概述

Vue 3.2.30的源码可以知道,源码中有对patchKeyedChildren()方法进行了核心步骤的注释,摘出核心步骤的注册如下面代码块所示,可以分为5个步骤:

  • 步骤1:从头->尾,处理相同的前置元素
  • 步骤2:从尾->头,处理相同的后置元素
  • 步骤3:旧vnode已经处理完毕,但是新vnode还有元素,处理新增元素,直接进行mount
  • 步骤4:旧vnode还有剩余,但是新vnode已经处理完毕,处理已经废弃元素,直接进行unmount
  • 步骤5:最复杂的情况,处理相同的前置元素+处理相同的后置元素后,剩下的元素有新增废弃乱序的情况,需要复杂处理
const patchKeyedChildren = (c1, c2, container, ...args) => {
    // 1. sync from start
    // (a b) c
    // (a b) d e

    // 2. sync from end
    // a (b c)
    // d e (b c)

    // 3. common sequence + mount
    // (a b)
    // (a b) c

    // 4. common sequence + unmount
    // (a b) c
    // (a b)

    // 5. unknown sequence
    // [i ... e1 + 1]: a b [c d e] f g
    // [i ... e2 + 1]: a b [e d c h] f g
}

步骤1:从头->尾,处理相同的前置元素

直接从i=0开始遍历,如果找到相同类型的元素,直接进行patch()进行数据的更新,然后递增i

const patchKeyedChildren = (c1, c2, container, ...args) => {
    let i = 0;
    const l2 = c2.length;
    let e1 = c1.length - 1; // prev ending index
    let e2 = c2.length - 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];
        if (isSameVNodeType(n1, n2)) {
            patch(n1, n2, container, null, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
        } else {
            break;
        }
        i++;
    }
}

步骤2:从尾->头,处理相同的后置元素

end indedx开始向前遍历,如果找到相同类型的元素,直接进行patch()进行数据的更新,然后递减e1e2

const patchKeyedChildren = (c1, c2, container, ...args) => {
    let i = 0;
    const l2 = c2.length;
    let e1 = c1.length - 1; // prev ending index
    let e2 = c2.length - 1; // next ending index
    // 步骤1:从头->尾,patch()处理相同的前置元素,递增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];
        if (isSameVNodeType(n1, n2)) {
            patch(n1, n2, container, null, ...args1);
        } else {
            break;
        }
        e1--;
        e2--;
    }
}

步骤3:旧children已经处理完毕,但是新children还有元素,处理新增元素,直接进行mount

// (a b)
// (a b) c
// i = 2, e1 = 1, e2 = 2
// (a b)
// c (a b)
// i = 0, e1 = -1, e2 = 0

从上面代码块可以知道新增元素有两种情况,都满足i>e1 && i<=e2

  • 一种是新增元素在末尾,那么patch()传入parentAnchor作为参照,将新增元素添加到container子元素的末尾
  • 一种是新增元素在头部,那么patch()传入c2[e2+1].el,即上面示例元素a所对应的el作为参照,将新增元素添加到元素a的前面
const patchKeyedChildren = (c1, c2, container, ...args) => {
    let i = 0;
    const l2 = c2.length;
    let e1 = c1.length - 1; // prev ending index
    let e2 = c2.length - 1; // next ending index
    // 步骤1:从头->尾,patch()处理相同的前置元素,递增i
    // 步骤2:从尾->头,patch()处理相同的后置元素,递减e1和e2
    // 3. common sequence + mount
    if (i > e1) {
        if (i <= e2) {
            const nextPos = e2 + 1;
            const anchor = nextPos < l2 ? c2[nextPos].el : parentAnchor;
            while (i <= e2) {
                patch(null, c2[i], container, anchor, ...args1);
                i++;
            }
        }
    }
}

步骤4:旧children还有剩余,但是新children已经处理完毕,处理已经废弃元素,直接进行unmount

// (a b) c
// (a b)
// i = 2, e1 = 2, e2 = 1
// a (b c)
// (b c)
// i = 0, e1 = 0, e2 = -1

从上面代码块可以知道有两种情况,都满足i>e2 && i<=e1

  • 一种是废弃元素在末尾,直接调用unmount()进行[i, e1]的元素清除
  • 一种是废弃元素在头部,直接调用unmount()进行[i, e1]的元素清除
const patchKeyedChildren = (c1, c2, container, ...args) => {
    let i = 0;
    const l2 = c2.length;
    let e1 = c1.length - 1; // prev ending index
    let e2 = c2.length - 1; // next ending index
    // 步骤1:从头->尾,patch()处理相同的前置元素,递增i
    // 步骤2:从尾->头,patch()处理相同的后置元素,递减e1和e2
    if (i > e1) {
        // 步骤3:旧children已经处理完毕,但是新children还有元素,处理新增元素,直接进行mount
    } else if (i > e2) {
        // 4. common sequence + unmount
        while (i <= e1) {
            unmount(c1[i], parentComponent, parentSuspense, true);
            i++;
        }
    }
}

步骤5:最复杂的情况,处理相同的前置元素+处理相同的后置元素后,剩下的元素有新增废弃乱序的情况,需要复杂处理

Vue 3.2.30的源码可以知道,源码中对步骤5也进行了核心步骤的注释,下面的解析也将围绕着源码注释的步骤而讲解,主要步骤为:

  • 步骤5.1:为新vnode的children建立索引[key: vnode.key, value: index],这样就可以通过key=vnode.key快速找到对应的索引位置
  • 步骤5.2

    • 移除废弃的旧vnode
    • 使用旧vnode去找对应的新vnode,进行vnode的复用。如果找到可复用的vnode,进行patch()更新,同时使用newIndexToOldIndexMap[newIndex - s2] = i + 1新vnode存储对应旧vnodeindex(所有index递增了1位,比如0变成1,1变成2)
    • 使用newIndex < maxNewIndexSoFar判断该diff是否需要移动节点
  • 步骤5.3:移动/新增元素处理
const patchKeyedChildren = (c1, c2, container, ...args) => {
    // 步骤1:从头->尾,patch()处理相同的前置元素,递增i
    // 步骤2:从尾->头,patch()处理相同的后置元素,递减e1和e2
    if (i > e1) {
        // 步骤3:旧children已经处理完毕,但是新children还有元素,处理新增元素,直接进行mount
    } else if (i > e2) {
        // 步骤4:旧children还有剩余,但是新children已经处理完毕,处理已经废弃元素,直接进行unmount
    } else {
        // 5. unknown sequence
        // [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
        // 5.1 build key:index map for newChildren
        // 5.2 loop through old children left to be patched and try to patch
        // 5.3 move and mount
    }
}

步骤5.1:为newChildren建立索引

const s1 = i; // prev starting index
const s2 = i; // next starting index
// 5.1 build key:index map for newChildren
const keyToNewIndexMap = new Map();
for (i = s2; i <= e2; i++) {
    const nextChild = c2[i];
    if (nextChild.key != null) {
        keyToNewIndexMap.set(nextChild.key, i);
    }
}

步骤5.2:移除废弃的旧vnode + 更新能复用的旧vnode + newIndexToOldIndexMapmove的构建为下一步骤做准备

初始化变量

let j;
let patched = 0;
const toBePatched = e2 - s2 + 1;
let moved = false;
let maxNewIndexSoFar = 0;
const newIndexToOldIndexMap = new Array(toBePatched);
for (i = 0; i < toBePatched; i++)
    newIndexToOldIndexMap[i] = 0;

逻辑1:移除废弃的旧vnode

如果prevChild.key缺失,会进行[s2, e2]的所有newChild遍历,使用isSameVNodeType去找可以复用的vnode节点,如果还找不到对应的newIndex,那就说明是废弃vnode
  • 情况1:newIndex === undefined代表新vnode已经找不到目前的旧vnode,说明旧vnode已经废弃,直接unmount()移除
  • 情况2:patched >= toBePatched代表新vnode已经循环完成,不需要再找复用的旧vnode,说明剩下还没复用的旧vnode都已经废弃,直接unmount()移除,一般发生在旧vnode末尾存在废弃节点
for (i = s1; i <= e1; i++) {
    const prevChild = c1[i];
    if (patched >= toBePatched) { // 情况2
        unmount(prevChild, parentComponent, parentSuspense, true);
        continue;
    }
    let newIndex;
    if (prevChild.key != null) {
        newIndex = keyToNewIndexMap.get(prevChild.key);
    } else {
        // key-less node, try to locate a key-less node of the same type
        for (j = s2; j <= e2; j++) {
            if (newIndexToOldIndexMap[j - s2] === 0 &&
                isSameVNodeType(prevChild, c2[j])) {
                newIndex = j;
                break;
            }
        }
    }
    if (newIndex === undefined) {// 情况1
        unmount(prevChild, parentComponent, parentSuspense, true);
    } else {
        patched++;
    }
}

逻辑2:更新能复用的旧vnode

for (i = s1; i <= e1; i++) {
    const prevChild = c1[i];
    if (patched >= toBePatched) {
        // 逻辑1:移除废弃的旧vnode的情况1
        unmount(prevChild, parentComponent, parentSuspense, true);
        continue;
    }
    let newIndex;
    // -------- 寻找能够复用的vnode的newIndex --------------
    if (prevChild.key != null) {
        newIndex = keyToNewIndexMap.get(prevChild.key);
    } else {
        //...如果prevChild.key缺失,会进行[s2, e2]的所有newChild遍历,使用isSameVNodeType去找可以复用的vnode节点
    }
    // -------- 寻找能够复用的vnode的newIndex --------------

    if (newIndex === undefined) {
        // 逻辑1:移除废弃的旧vnode的情况2
        unmount(prevChild, parentComponent, parentSuspense, true);
    } else {
        // 逻辑2:更新能复用的旧vnode
        patch(prevChild, c2[newIndex], container, null, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
        patched++;
    }
}

逻辑3:newIndexToOldIndexMapmove的构建为步骤5.3做准备

  • move的构建:用旧vnode进行遍历,去找新vnode对应的index,使用maxNewIndexSoFar不断更新找到新vnode对应的index,如果newIndex < maxNewIndexSoFar说明目前的旧vnode的位置应该移动到它前面旧vnode的前面去,即目前的旧vnode是需要移动的,此时将move=true
  • newIndexToOldIndexMap的构建:用新vnodeindex(从0开始)作为key,用旧vnodeindex+1(原来oldChildrenArray的index+1,不是从0开始)作为value,构建一个Array对象,为步骤5.3做准备
for (i = s1; i <= e1; i++) {
    const prevChild = c1[i];
    if (patched >= toBePatched) {
        // ...省略代码 逻辑1:移除废弃的旧vnode的情况1
        continue;
    }
    let newIndex;
    // ...省略代码:寻找能够复用的vnode的newIndex
    if (newIndex === undefined) {
        // ...省略代码 逻辑1:移除废弃的旧vnode的情况2
    } else {
        // ========逻辑3:newIndexToOldIndexMap和move的构建为下一步骤做准备========
        newIndexToOldIndexMap[newIndex - s2] = i + 1;
        if (newIndex >= maxNewIndexSoFar) {
            maxNewIndexSoFar = newIndex;
        } else {
            moved = true;
        }
        // ========逻辑3:newIndexToOldIndexMap和move的构建为下一步骤做准备========
        // ...省略代码 逻辑2:更新能复用的旧vnode
    }
}

步骤5.3:移动/新增处理

步骤5.2可以知道,该步骤会移除所有废弃的旧vnode,因此剩余的逻辑只有 移动可复用的vnode到正确的位置 + 插入之前没有过的新vnode

新增:判断目前新vnode是之前没有过的新vnode

步骤5.2 逻辑3可以知道,newIndexToOldIndexMap是以newChildrenArray建立的初始化都为0的数组,然后在步骤5.2 逻辑3进行oldChildrenArray的遍历,找到可以复用的新vnode,进行newIndexToOldIndexMap[xxx]=旧vnode的index+1的赋值,因此newIndexToOldIndexMap[xxx]=0就代表这个新vnode是之前没有过的,直接进行patch(null, nextChild),即mount()插入新的元素

// 从尾->头进行新vnode的遍历
for (i = toBePatched - 1; i >= 0; i--) {
    const nextIndex = s2 + i;
    const nextChild = c2[nextIndex];
    const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : parentAnchor;
    if (newIndexToOldIndexMap[i] === 0) {
        patch(null, nextChild, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
    } else if (moved) {
    }
}

移动:判断目前新vnode对应的可复用的旧vnode是否需要移动位置

流程1: 构建最长递增子序列
Vue3源码关于最长递增子序列算法的构建可以参考leetcode高赞题解(推导流程详细),在本文中将直接抛弃算法细节,直接使用最长递增子序列的构建方法getSequence()
const increasingNewIndexSequence = moved
    ? getSequence(newIndexToOldIndexMap)
    : EMPTY_ARR;
j = increasingNewIndexSequence.length - 1;

increasingNewIndexSequence:使用newIndexToOldIndexMap数组构建最长递增子序列,如下图所示,我们有newIndexToOldIndexMap=[4, 1, 0, 5, 3],最长递增子序列的意思为item是保持递增的一个序列,即[index=1,index=5]/[index=1,index=3]等等都是递增的值,我们通过getSequence(newIndexToOldIndexMap)获取到的值为newChildren中对应的递增子序列的index数组,通过手动触发getSequence(),我们得到increasingNewIndexSequence=[1,4]

我们初始化newIndexToOldIndexMap时,每一个item都赋值为0,获取oldChildrenindex都主动加上了1,因此newIndexToOldIndexMap[xxx]=0代表是之前旧children没有过的vnode,不参与构建最长递增子序列的逻辑中

Vue3-diff算法-获取最长递增子序列.svg

流程2: 根据increasingNewIndexSequence进行节点的移动
使用倒序遍历新的children数组是因为anchor,如果没有相同的后置元素,则添加到container子元素的末尾,如果有相同的后置元素,则以最前面一个后置元素作为anchor
patch流程中会触发n2.el=n1.elmove()方法本质还是拿到了新vnode对应的el,然后进行hostInsert(el, container, anchor)移动操作
const increasingNewIndexSequence = moved
    ? getSequence(newIndexToOldIndexMap)
    : EMPTY_ARR;
j = increasingNewIndexSequence.length - 1;
for (i = toBePatched - 1; i >= 0; i--) {
    const nextIndex = s2 + i;
    const nextChild = c2[nextIndex];
    const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : parentAnchor;
    if (newIndexToOldIndexMap[i] === 0) {
        patch(null, nextChild, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
    } else if (moved) {
        if (j < 0 || i !== increasingNewIndexSequence[j]) {
            move(nextChild, container, anchor, 2 /* REORDER */);
        } else {
            j--;
        }
    }
}
const move = (vnode, container, anchor, moveType, parentSuspense = null) => {
    const { el, type, transition, children, shapeFlag } = vnode;
    //...多种类型的数据进行不同的move处理,包括组件、Comment、Static
    if (needTransition) {
      //...
    } else {
        hostInsert(el, container, anchor);
    }
};
  • 如果increasingNewIndexSequence.length=0,即构建不出来最长递增子序列的时候,按照上面代码块的逻辑,我们从末尾开始,使用当前新vnode后面的index作为参照,不停将旧vnode移动插入到c2[nextIndex+1]的前面
  • 如果increasingNewIndexSequence.length>0,那么遇到i === increasingNewIndexSequence[j]时,代表目前的nextChild是最长递增子序列的一个元素,由于最长递增子序列代表旧vnode的相关位置在新vnode的相关位置仍然保持递增,因此这些位于最长递增子序列的元素可以不进行move操作,直接进行j--即可
increasingNewIndexSequence最长递增子序列的作用:获取旧的children在新的children的相对位置顺序仍然递增的最长子序列,减少move的次数,提升性能

Vue3-diff-移动的详解.svg

步骤5.3整体流程图解

Vue3-diff算法-第一个示例.svg

Vue3-diff算法-第二个示例.svg
Vue3-diff算法-第三个示例.svg
Vue3-diff算法-第四个示例.svg
Vue3-diff算法-第五个示例.svg

Vue系列其它文章

  1. Vue2源码-响应式原理浅析
  2. Vue2源码-整体流程浅析
  3. Vue2源码-双端比较diff算法 patchVNode流程浅析
  4. Vue3源码-响应式系统-依赖收集和派发更新流程浅析
  5. Vue3源码-响应式系统-Object、Array数据响应式总结
  6. Vue3源码-响应式系统-Set、Map数据响应式总结
  7. Vue3源码-响应式系统-ref、shallow、readonly相关浅析
  8. Vue3源码-整体流程浅析
  9. Vue3源码-diff算法-patchKeyChildren流程浅析

白边
206 声望35 粉丝

源码爱好者,已经完成vue2和vue3的源码解析+webpack5整体流程源码+vite4开发环境核心流程源码+koa2源码