mini-vue - 双端Diff算法

1.引入

虚拟节点vnode的children有两种类型,一种就是vnode数组 (可以根据数组来patch递归产生子节点),一种就是string类型(就比如 <div>文本</div> 中,div是vnode,"文本"是children )

在进行更新的时候,children的变化有四种可能

  1. vnode[] -> string,这种情况就把原本的children节点全部移除,然后添加text节点(调用DOM的API)
  2. string -> string,这种情况就判断string是否有变化,有变化再调用DOM API设置Text即可
  3. string -> vnode[] ,这种情况,把Text设置为空字符串,然后挂载新的children vnode节点
  4. vnode[] -> vnode[] ,这种情况就比较复杂了,出于性能考虑, 我们不能把所有节点移除,然后再挂载,这时候就需要使用DIff算法,来判断 哪些节点可以复用 ,哪些节点需要移除,哪些节点需要添加

2.我的理解

· 双端Diff ?

在vue中使用的是双端Diff算法

什么是双端Diff算法呢? 就是从两端去分别对比新旧vnode数组

为什么要双端对比呢?

我的理解是, 因为在子节点中,很可能只有中间一部分发生了变化,前后是不变的,使用单端对比,必然会造成节点浪费 (如下图,ab和fg不变,如果使用单端对比,那么除了ab,其它都会被重新渲染,造成性能浪费)

从两端去遍历children,相同的就跳过,直到遇到不同的,从而缩小待处理范围

· 基本过程

从左往右遍历,找到变化区间的左边界 。 let i 从0开始,遍历结束后,最终的i就是左边界

然后从右往左遍历,找到变化区间的右边界 。 let e1 = oldChildren.length -1 和 let e2 = newChildren.length -1。 然后在从右往左遍历,结束后(也就是遇到不相同的节点了),说明 e1 和 e2 分别是新旧children的右边界

然后锁定好变化范围之后,又会发现有以下几种情况

  1. 新节点的数量大于旧节点的数量(单端)。 (即 i > e1 && i <= e2 ) 那么就去创建这些新增的节点
  2. 新节点的数量小于旧节点的数量(单端)。 (即 i > e1 && i <= e2 ) 那么我们就需要把多余的删除
  3. 其它情况就是中间有各种变动的。(else) 这里的逻辑下面再看

· 新节点数量>旧节点(单端)

在这里,新节点的位置又分为两种可能,第一种就是在头部,第二种是在尾部

注,这只是简单的单端情况,如果是 [ b,c] --> [a , b, c, d] 就不适用了

· 新节点数量<旧节点(单端)

在这里一样是有两种可能

注,这只是简单的单端情况,如果是 [ a,b,c,d] --> [b, c] 就不适用了

· 中间对比

剩下的就是最复杂的情况了:

3.简单实现

· 准备

  1. 给出部分ts类型定义(是我自己写的简单的定义,与官方源码不同,但是能对读写时提供帮助)
TypeScript 复制代码
/**虚拟节点 */
export interface vnode {
    /**这个vnode的根容器的DOM元素 */
    el: Element | null
    /**vnode的类型。如果是组件就是一个用户编写的对象(包含setup等),如果是标签就是string(比如 div span) */
    type: string | Symbol | 用户编写的对象 // 这里该对象比较复杂,本代码也用不上,不写了
    /**虚拟节点的key值,可以用于Diff算法。 */
    key: number | string | undefined
    /**标识符。有关位运算的知识可以看 src\shared\shapeFlags.ts */
    shapeFlag: ShapeFlags
    /**props属性 */
    props?: {
      /**虚拟节点的key值,可以用于Diff算法。非必填 */
      key?: string | number
      [key: string]: any //其它键值对
		}
    /**孩子。插槽孩子,或者普通vnode孩子 */
    children?: string | vnode[] // 插槽孩子的类型在这里用不上,不展示了
}

/**组件实例 */
export interface componentInstance { //只给出了本算法可能需要的类型
    /**该组件的虚拟节点 */
    vnode: vnode,
    /**vnode中的type,放在这里方便使用 */
    type: vnode['type']  
    /**该组件的props */
    props: vnode['props']  
    /**父组件的实例。为undefined的话说明是根组件了 */
    parent: componentInstance | undefined
    /**当前的节点树 */
    subTree: vnode 
}


/**虚拟节点的类型标识符 */
export const enum ShapeFlags {
  /**element 类型 (HTML标签) */
  ELEMENT = 1, // 0001
  /**有状态的组件类型 */
  STATEFUL_COMPONENT = 1 << 1, // 0010
  /**vnode.children 为 string 类型 */
  TEXT_CHILDREN = 1 << 2, // 0100
  /**vnode.children 为数组类型 */
  ARRAY_CHILDREN = 1 << 3, // 1000
  /**vnode.children 为 slots 类型 */
  SLOTS_CHILDREN = 1 << 4 // 10000
}
  1. 代码中还会频繁用到 patch 函数 ,这个函数的作用是,递归vnode和它的孩子,判断是 组件 还是 element标签,然后去 创建 或 更新 节点
TypeScript 复制代码
/**patch函数,最重要的函数,会对比n1和n2,来判断是需要更新还是创建,操作的是Element类型还是Component类型,然后进行递归创建/更新节点 
 * @param n1 旧Vnode  (如果是初始化,这个就是null)
 * @param n2 新Vnode
 * @param container 容器
 * @param parentComponent 父组件实例
 * @param anchor 插入的锚点, 如果是需要插入的话,将要插在这个节点之前
 */
function patch(n1: vnode | null, n2: vnode, container: Element, parentComponent: componentInstance, anchor: Element) {
    const { shapeFlag, type } = n2
    switch (type) {
        case Fragment:
            processFragment(n1, n2, container, parentComponent, anchor) //去处理 <Fragment></Fragment> 标签的
            break;
        case Text:
            processText(n1, n2, container) //去处理文本类型标签的
            break;
        default://没有命中的。说明不是特殊标签,进行正常的element或component渲染
        		// 有关位运算的知识可以看 src\shared\shapeFlags.ts
            if (shapeFlag & ShapeFlags.ELEMENT) {//位运算判断是否是element
                processElement(n1, n2, container, parentComponent, anchor)//创建/更新 element 节点
            } else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {//位运算判断是否是组件
                processComponent(n1, n2, container, parentComponent, anchor)//创建/更新 组件
            }
            break;
    }
}

//在 processElement 和  processComponent 这俩函数的某个过程中,还会调用patch,形成递归子节点
  1. 然后就正式开始diff算法:
TypeScript 复制代码
/**对比两个 children 数组  。diff算法就在这里
 * @param c1 旧children数组
 * @param c2 新children数组
 * @param container 容器DOM元素
 * @param parentAnchor 父组件的插入的锚点, 如果是需要插入的话,将要插在这个节点之前
 * @param parentComponent 父组件实例
 */
function patchKeyedChildren(c1: vnode[], c2: vnode[], container: Element, parentComponent: componentInstance, parentAnchor: Element) {
      /**左边界索引 */
      let i = 0;
      /**新children数组的长度 */
      const l2 = c2.length;
      /**旧children数组的右边界索引 */
      let e1 = c1.length - 1;
      /**新children数组的右边界索引 */
      let e2 = l2 - 1; 

    	/**判断两个VNode是否相同 (基于 type 和 key 判断是否相等) */
      const isSameVNodeType = (n1: vnode, n2: vnode) => {
          return n1.type === n2.type && n1.key === n2.key;
      };

  
      //  .... 内容在下面完善
      //  .... 内容在下面完善
      //  .... 内容在下面完善
      //  .... 内容在下面完善
      //  .... 内容在下面完善
      //  .... 内容在下面完善
    
}

· 从左到右 寻找左边界

根据前面理解中的图片和描述,我们可以知道, 左边界索引 i 需要满足 i <= e1 && i <= e2 ,在while循环中,如果发现两个节点相同,就i++ ,继续遍历; 一旦发现有哪个节点不同了,就立刻break,就能得到左边界 (相同的时候要调用patch进行递归)

TypeScript 复制代码
// 先从左到右,找到左边界。 判断 n1 和 n2 是否相等,遇到不相等就跳出。当前的相等,就进行patch递归
while (i <= e1 && i <= e2) {
    /**当前索引对应的旧孩子 */
    const n1 = c1[i]
    /**当前索引对应的新孩子 */
    const n2 = c2[i]
    if (!isSameVNodeType(n1, n2)) { //如果不相同
        console.log("两个 child 不相等(从左往右比对),左边界为", i, `\n旧孩子: `, n1, `\n新孩子: `, n2);
        break
    }
    patch(n1, n2, container, parentComponent, parentAnchor) //进行patch递归
    i++
}

· 从右到左 寻找右边界

由于新旧数组的长度可能不相同,所以需要用两个变量作为索引。 循环条件同样是 i <= e1 && i <= e2

如果发现两个节点相同,就 e1--;e2--; 继续遍历; 一旦发现有哪个节点不同了,就立刻break,就得到右边界 (相同的时候要调用patch进行递归)

TypeScript 复制代码
//此时的i不再开始变化,而是 e1 和 e2 从右往左进行对比 (从后往前判断,看看是否屁股后也有相同的元素,免得插入了中间一个,后面的没变却要重新渲染)
//遇到不相等就跳出。当前的相等,就把这两个child节点进行patch递归
while (i <= e1 && i <= e2) {
    /**当前索引对应的旧孩子 */
    const n1 = c1[e1]
    /**当前索引对应的新孩子 */
    const n2 = c2[e2]
    if (!isSameVNodeType(n1, n2)) { //如果不相同
        console.log(`两个 child 不相等(从右往左比对),右边界分别为 旧 ${e1} 新 ${e2}`, `\n旧孩子: `, n1, `\n新孩子: `, n2);
        break
    }
    patch(n1, n2, container, parentComponent, parentAnchor) //进行patch递归
    e1--
    e2--
}

console.log('边界确定', `i = ${i}, e1 = ${e1}`, `e2 = ${e2}`);

· 新节点>旧节点数量 (单端)

画图分析可知,这种情况就是 i 在 e1 和 e2 之间

1图情况是,新节点要添加在左侧 、 2图情况是,新节点要添加在右侧

注,这只是简单的单端情况,如果是 [ b,c] --> [a , b, c, d] 就不适用了

TypeScript 复制代码
if (i > e1 && i <= e2) {
    // 如果是这种情况的话就说明, 新节点的数量大于旧节点的数量 ==> 也就是说新增了 vnode   (画图可以就理解为什么i在这俩中间)

    // 锚点的计算:新的节点有可能需要添加到尾部,也可能添加到头部,所以需要指定添加的问题

    /**判断要插入锚点的位置 */
    const nextPos = e2 + 1 //应该使用e2 + 1  (而不是 i + 1,因为对于往左侧添加的话,当插入的数量≥2的时候,应该获取到 c2 的第一个元素,i+1就不准确了,可以画图看)
    /**获得要插入的锚点 */
    const anchor = nextPos < l2 ? c2[nextPos].el : parentAnchor; // 如果比新children长度短,就正常以这个为锚点。 否则就越界了,应该使用父亲的Anchor

    while (i <= e2) {
        console.log(`需要新创建一个 vnode:`, c2[i]);
        patch(null, c2[i], container, parentComponent, anchor as container) //第一个参数为null,代表是创建新节点
        i++; // 光标移动,支持一次新建多个节点 
    }
}

· 新节点<旧节点数量(单端)

画图分析可知,此时的i应该满足 i > e2 && i <= e1

注,这只是简单的单端情况,如果是 [ a,b,c,d] --> [b, c] 就不适用了

TypeScript 复制代码
else if (i <= e1 && i > e2) {
    // 这种情况的话说明新节点的数量是小于旧节点的数量的,那么我们就需要把多余的删除  
    while (i <= e1) {
        console.log(`需要删除当前的 vnode:  `, c1[i].el);
        hostRemove(c1[i].el); //这是一个移除DOM元素的函数,主要是调用 el.parentNode.removeChild(el) 删除自身
        i++;
    }
}

· 中间对比

这里就比较复杂了, 可以是改顺序了、删除了、新增了等

情况1,总节点数目相同,需要删除老的。就需要先遍历新孩子,建立key与index映射的Map,然后遍历旧孩子,找出key对应的索引(没有传入key的就根据索引,判断vnode是否相等才取)。如果新旧索引对应上了,就调用patch去更新。如果没有找到索引,就调用删除

情况2,新节点数目比旧节点少,已经对比完新节点,却还存在老节点没对比 ,这时候相比上面的情况,对比完c和e后 (已对比完新节点),就可以直接删除d了(d是个老节点还没对比)

通俗说法:中间部分,老的比新的多, 那么多出来的直接就可以被干掉(优化删除逻辑)

具体的做法就是,在遍历旧孩子的过程中,每次都判断 "已处理数量"是否≥"待处理总数",是的话就直接把后面的全删了

情况3:顺序变化。 对于 cde->ecd,最暴力的解法就是遍历全部,然后一个个插入到指定位置,但是这肯定造成了性能浪费。观察可以发现,实际上需要移动位置的只有e,cd顺序是不变的。

所以我们可以记录他们原本的索引 ,cde的索引是 2 3 4,变化位置之后,变成了 4 2 3,我们找到其中的 最长递增子序列,就可以发现 c d无需变化,只需要移动4 (也就是e)。

也就是给稳定的最长递增子序列保持不变,其它乱序的就执行插入操作,这样就可以减少我们的DOM操作。

为什么是最长递增子序列呢?因为我们要让被移动的节点尽可能少,所以找出最长的

注:最长递增子序列(LIS )概念:例如,{ 3,5,7,1,2,8 } 的 LIS 是 { 3,5,7,8 }

情况4:新增节点。在上面判断顺序变化时,我们会生成一个新索引与旧索引的映射关系newIndexToOldIndexMap,如果遍历时发现该索引对应的值还是初始值,说明"该节点在老数组中不存在",就执行新增操作 (详细见代码)

TypeScript 复制代码
else {
    //剩下的就是中间对比了, 可以是改顺序了、删除了、新增了等
    //最普通的想法就是遍历中间所有节点,然后一个个去改,但是时间复杂度就是O(n)了,所以可以采用 key 来进行
    //同样也分为多种情况
    // 情况1,总节点数目相同,需要删除老的。a,b,(c,d),f,g --> a,b,(e,c),f,g  
    // 情况2,新节点数目比旧节点少,已经对比完新节点,却还存在老节点没对比,可以直接删除老的 a,b,(e,c,d),f,g --> a,b,(e,c),f,g 
    // 情况3,顺序变化。 a,b,(c,d,e),f,g --> a,b,(e,c,d),f,g 
    // 情况4,节点新增。 a,b,(c,e),f,g --> a,b,(e,c,d),f,g

    /**旧children数组的 中间区间 的左边界 */
    let s1 = i
    /**新children数组的 中间区间 的左边界 */
    let s2 = i

    /**需要处理新节点的数量 */
    const toBePatched = e2 - s2 + 1; // 这个可以用于优化情况2
    /**老节点已经被patch处理的数量 */
    let patched = 0; // 这个可以用于优化情况2

    // 怎么判断当前这中间部分,是否有节点需要移动呢 -> 如果发现 所有节点的旧index都是升序的话,说明不需要移动
    //  比如 a,b,(c,d,e),f,g --> a,b,(e,c,d),f,g  ,其中cde的索引从 0 1 2 变成了 1 2 0,不是递增的,所以需要移动
    /**是否有节点需要移动 - 标识符。为true的时候会使用最长递增子序列来进行移动 */
    let moved = false;
    /**当前新索引中,升序部分最大的值,用于判断索引是否为升序 */
    let maxNewIndexSoFar = 0;

    /**根据key查找index的映射Map,键是key,值是index */
    const keyToNewIndexMap = new Map<string | number, number>();
    /**- 新索引与旧索引的映射关系。 
     * - 初始化为0, 后面处理的时候如果发现是0的话,那么就说明新值在老的里面不存在
     * - 在"遍历旧孩子们"的时候进行修改
     */
    const newIndexToOldIndexMap: number[] = new Array(toBePatched).fill(0);  //

    //遍历新孩子们,设置 key 与 index 的映射
    for (let j = s2; j <= e2; j++) {
        /**当前索引对应的新孩子 */
        const nextChild = c2[j]
        if (nextChild.key) keyToNewIndexMap.set(nextChild.key, j) //用户有传入key才存
    }

    //遍历旧孩子们
    for (let j = s1; j <= e1; j++) {
        /**当前索引对应的旧孩子 */
        const preChild = c1[j]

        if (patched >= toBePatched) {//优化情况2
            hostRemove(preChild.el)
            continue//下面的就不需要执行了,优化算法速度
        }

        /**这个旧节点的新索引 */
        let newIndex: number | undefined
        if (preChild.key !== null || preChild.key !== undefined) { //用户有传入key才获取 
            newIndex = keyToNewIndexMap.get(preChild.key!) //从map中取出对应的索引  
        } else {// 用户有可能没有传入key,就用索引代替
            for (let k = s2; k <= e2; k++) { //这时候就需要遍历了。 注意k <= e2,不要忘记等于号了
                if (isSameVNodeType(preChild, c2[k])) {//判断 preChild 和 新孩子[k] 是否相等
                    newIndex = j
                    break
                }
            }
        }

        if (newIndex === undefined) { //如果经过上面的查找key,还没找到对应的索引,说明这个旧节点在新孩子数组中不存在,应该删除
            hostRemove(preChild.el) //移除掉这个DOM
        } else {//存在的话就patch,进行更深层次的更新

            //根据newIndex是否升序,判断是否有节点需要移动
            if (newIndex >= maxNewIndexSoFar) {//如果一直是升序的
                maxNewIndexSoFar = newIndex
            } else {//一旦发现有的不是升序,就认定有节点需要移动,启动最长递增子序列算法
                moved = true
            }

            //在这里给 newIndexToOldIndexMap 赋值。 这时候的j就是旧index
            // newIndex - s2 的含义:因为我们的数组索引是从"变化区域的左侧"开始,而newIndex的索引是从"整个children的左侧"开始,所以需要相减,才是我们真正想赋值的
            // j + 1 的含义:因为j是从0开始,而在这个数组中,0是有特殊含义的(代表新值在老的里不存在)
            newIndexToOldIndexMap[newIndex - s2] = j + 1

            patch(preChild, c2[newIndex], container, parentComponent, null) //进行patch更新节点

            patched++ //被处理过的值++
        }

    }

    //使用最长递增子序列优化:因为如果有部分元素是升序的话,那么这些元素就是不需要移动的,只需要移动乱序的即可。
    /** 若有节点需要移动,得到最长递增子序列(下标数组),仅对移动过的节点处理 */
    const increasingNewIndexSequence = moved ? getSequence(newIndexToOldIndexMap) : [] // 通过 moved 来进行优化,如果没有移动过的话 那么就不需要执行算法(这个算法也是有些时间复杂度的)
    /**最长递增子序列的指针,从 (increasingNewIndexSequence.length - 1) 开始逆序遍历 */
    let point = increasingNewIndexSequence.length - 1

    //处理 顺序移动或新增 的情况。   使用逆序遍历,才能保证插入时的锚点已经被固定!!!
    for (let j = toBePatched - 1; j >= 0; j--) {
        // 假设toBePatched是5 , 此时的递增子序列 increasingNewIndexSequence 是[1, 2, 4] , point和j从末尾开始逆序
        // j=4时,point=2,递增子序列[point] = 4, j === 4,则point--
        // j=3时,ponit=1,递增子序列[point] = 2, j !== 2,则移动位置
        // j=2时,ponit=1,递增子序列[point] = 2, j === 2,则point--
        // j=1时,point=0,递增子序列[point] = 1, j === 1,则point--


        /**确定当前要处理的节点索引 */
        const nextIndex = s2 + j; // 因为j是 "中间区间" 的索引,而我们要拿到的孩子应该是在整个children数组中的索引
        /**根据当前要处理的索引,拿到新孩子数组中指定的孩子 */
        const nextChild = c2[nextIndex];
        /**要插入的锚点。锚点等于当前节点索引+1,也就是当前节点的后面一个节点。 因为是倒遍历,所以锚点是位置确定的节点,不会变动了 */
        const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : parentAnchor; //  判断是否越界,越界了就用默认的

        if (newIndexToOldIndexMap[j] === 0) {//如果当前值是0,说明要创建

            patch(null, nextChild, container, parentComponent, anchor)//进行patch创建新节点

        } else if (moved) {//需要移动的时候才

            if (j !== increasingNewIndexSequence[point] || point < 0) {//除了判断j是否不相等, 还有point小于0的时候,就直接移动就行
                //如果当前j和递增子序列的值不相同,说明是"非递增子序列",需要移动位置
                console.log(nextChild.el, '这个节点要移动位置');
                hostInsert(nextChild.el!, container, anchor) //在指定锚点的前面插入DOM,即 container.insertBefore(el, anchor)
            } else {
                point-- //不需要移动的话,说明匹配成功递增子序列了,就 point-- 即可
            }

        }


    }

}

附:最长递增子序列算法:

TypeScript 复制代码
/**获得最长递增子序列的索引数组
 * @param arr 要被查找的数组  
 * @returns 返回最长子序列的下标数组。 比如 传入[4,2,3,1,5] ,得到 [1,2,4]  (即最长递增序列是 2 3 5)
 */
function getSequence(arr: number[]): number[] {
    const p = arr.slice();
    const result = [0];
    let i: number, j: number, u: number, v: number, c: number;
    const len = arr.length;
    for (i = 0; i < len; i++) {
        const arrI = arr[i];
        if (arrI !== 0) {
            j = result[result.length - 1];
            if (arr[j] < arrI) {
                p[i] = j;
                result.push(i);
                continue;
            }
            u = 0;
            v = result.length - 1;
            while (u < v) {
                c = (u + v) >> 1;
                if (arr[result[c]] < arrI) {
                    u = c + 1;
                } else {
                    v = c;
                }
            }
            if (arrI < arr[result[u]]) {
                if (u > 0) {
                    p[i] = result[u - 1];
                }
                result[u] = i;
            }
        }
    }
    u = result.length;
    v = result[u - 1];
    while (u-- > 0) {
        result[u] = v;
        v = p[v];
    }
    return result;
}

4. 整个流程的完整代码

把前面的代码块都组合在一起,就是下面这个:

里面还有一些函数没有给出来,比如"hostInsert" 是 指定位置插入DOM 函数等,这些就不在这里写出来了,完整代码可以看我跟着视频敲出来的mini-vue代码: 手敲mini-vue: vue3源码的mini版本,写了特别详细的注释与简单的ts类型,便于自己日后复习 (gitee.com)

TypeScript 复制代码
    /**对比两个 children 数组  。diff算法就在这里
     * @param c1 旧children数组
     * @param c2 新children数组
     * @param container 容器DOM元素
     * @param parentComponent 父组件实例
     * @param parentAnchor 父插入的锚点, 如果是需要插入的话,将要插在这个节点之前
     */
    function patchKeyedChildren(c1: vnode[], c2: vnode[], container: container, parentComponent: componentInstance, parentAnchor: container) {
        /**左边界索引 */
        let i = 0;
        /**新children数组的长度 */
        const l2 = c2.length;
        /**旧children数组的右边界索引 */
        let e1 = c1.length - 1;
        /**新children数组的右边界索引 */
        let e2 = l2 - 1;

        /**判断两个VNode是否相同 (基于 type 和 key 判断是否相等) */
        const isSameVNodeType = (n1: vnode, n2: vnode) => {
            return n1.type === n2.type && n1.key === n2.key;
        };

        // 先从左到右,找到左边界。 判断 n1 和 n2 是否相等,遇到不相等就跳出。当前的相等,就进行patch递归
        while (i <= e1 && i <= e2) {
            /**当前索引对应的旧孩子 */
            const n1 = c1[i]
            /**当前索引对应的新孩子 */
            const n2 = c2[i]
            if (!isSameVNodeType(n1, n2)) { //如果不相同
                console.log("两个 child 不相等(从左往右比对),左边界为", i, `\n旧孩子: `, n1, `\n新孩子: `, n2);
                break
            }
            patch(n1, n2, container, parentComponent, parentAnchor) //进行patch递归
            i++
        }

        //此时的i不再开始变化,而是 e1 和 e2 从右往左进行对比 (从后往前判断,看看是否屁股后也有相同的元素,免得插入了中间一个,后面的没变却要重新渲染)
        //遇到不相等就跳出。当前的相等,就把这两个child节点进行patch递归
        while (i <= e1 && i <= e2) {
            /**当前索引对应的旧孩子 */
            const n1 = c1[e1]
            /**当前索引对应的新孩子 */
            const n2 = c2[e2]
            if (!isSameVNodeType(n1, n2)) { //如果不相同
                console.log(`两个 child 不相等(从右往左比对),右边界分别为 旧 ${e1} 新 ${e2}`, `\n旧孩子: `, n1, `\n新孩子: `, n2);
                break
            }
            patch(n1, n2, container, parentComponent, parentAnchor) //进行patch递归
            e1--
            e2--
        }

        console.log('边界确定', `i = ${i}, e1 = ${e1}`, `e2 = ${e2}`);

        if (i > e1 && i <= e2) {
            // 如果是这种情况的话就说明, 新节点的数量大于旧节点的数量 ==> 也就是说新增了 vnode   (画图可以就理解为什么i在这俩中间)

            // 锚点的计算:新的节点有可能需要添加到尾部,也可能添加到头部,所以需要指定添加的问题

            /**判断要插入锚点的位置 */
            const nextPos = e2 + 1 //应该使用e2 + 1  (而不是 i + 1,因为对于往左侧添加的话,当插入的数量≥2的时候,应该获取到 c2 的第一个元素,i+1就不准确了,可以画图看)
            /**获得要插入的锚点 */
            const anchor = nextPos < l2 ? c2[nextPos].el : parentAnchor; // 如果比新children长度短,就正常以这个为锚点。 否则就越界了,应该使用父亲的Anchor

            while (i <= e2) {
                console.log(`需要新创建一个 vnode:`, c2[i]);
                patch(null, c2[i], container, parentComponent, anchor as container) //第一个参数为null,代表是创建新节点
                i++; // 光标移动,支持一次新建多个节点 
            }
        } else if (i <= e1 && i > e2) {
            // 这种情况的话说明新节点的数量是小于旧节点的数量的,那么我们就需要把多余的删除  
            while (i <= e1) {
                console.log(`需要删除当前的 vnode:  `, c1[i].el);
                hostRemove(c1[i].el);
                i++;
            }
        } else {
            //剩下的就是中间对比了, 可以是改顺序了、删除了、新增了等
            //最普通的想法就是遍历中间所有节点,然后一个个去改,但是时间复杂度就是O(n)了,所以可以采用 key 来进行
            //同样也分为多种情况
            // 情况1,总节点数目相同,需要删除老的。a,b,(c,d),f,g --> a,b,(e,c),f,g  
            // 情况2,新节点数目比旧节点少,已经对比完新节点,却还存在老节点没对比,可以直接删除老的 a,b,(e,c,d),f,g --> a,b,(e,c),f,g 
            // 情况3,顺序变化。 a,b,(c,d,e),f,g --> a,b,(e,c,d),f,g 
            // 情况4,节点新增。 a,b,(c,e),f,g --> a,b,(e,c,d),f,g

            /**旧children数组的 中间区间 的左边界 */
            let s1 = i
            /**新children数组的 中间区间 的左边界 */
            let s2 = i

            /**需要处理新节点的数量 */
            const toBePatched = e2 - s2 + 1; // 这个可以用于优化情况2
            /**老节点已经被patch处理的数量 */
            let patched = 0; // 这个可以用于优化情况2

            // 怎么判断当前这中间部分,是否有节点需要移动呢 -> 如果发现 所有节点的旧index都是升序的话,说明不需要移动
            //  比如 a,b,(c,d,e),f,g --> a,b,(e,c,d),f,g  ,其中cde的索引从 0 1 2 变成了 1 2 0,不是递增的,所以需要移动
            /**是否有节点需要移动 - 标识符。为true的时候会使用最长递增子序列来进行移动 */
            let moved = false;
            /**当前新索引中,升序部分最大的值,用于判断索引是否为升序 */
            let maxNewIndexSoFar = 0;

            /**根据key查找index的映射Map,键是key,值是index */
            const keyToNewIndexMap = new Map<string | number, number>();
            /**- 新索引与旧索引的映射关系。 
             * - 初始化为0, 后面处理的时候如果发现是0的话,那么就说明新值在老的里面不存在
             * - 在"遍历旧孩子们"的时候进行修改
             */
            const newIndexToOldIndexMap: number[] = new Array(toBePatched).fill(0);  //

            //遍历新孩子们,设置 key 与 index 的映射
            for (let j = s2; j <= e2; j++) {
                /**当前索引对应的新孩子 */
                const nextChild = c2[j]
                if (nextChild.key) keyToNewIndexMap.set(nextChild.key, j) //用户有传入key才存
            }

            //遍历旧孩子们
            for (let j = s1; j <= e1; j++) {
                /**当前索引对应的旧孩子 */
                const preChild = c1[j]

                if (patched >= toBePatched) {//优化情况2
                    hostRemove(preChild.el)
                    continue//下面的就不需要执行了,优化算法速度
                }

                /**这个旧节点的新索引 */
                let newIndex: number | undefined
                if (preChild.key !== null || preChild.key !== undefined) { //用户有传入key才获取 
                    newIndex = keyToNewIndexMap.get(preChild.key!) //从map中取出对应的索引  
                } else {// 用户有可能没有传入key,就用索引代替
                    for (let k = s2; k <= e2; k++) { //这时候就需要遍历了。 注意k <= e2,不要忘记等于号了
                        if (isSameVNodeType(preChild, c2[k])) {//判断 preChild 和 新孩子[k] 是否相等
                            newIndex = j
                            break
                        }
                    }
                }

                if (newIndex === undefined) { //如果经过上面的查找key,还没找到对应的索引,说明这个旧节点在新孩子数组中不存在,应该删除
                    hostRemove(preChild.el) //移除掉这个DOM
                } else {//存在的话就patch,进行更深层次的更新

                    //根据newIndex是否升序,判断是否有节点需要移动
                    if (newIndex >= maxNewIndexSoFar) {//如果一直是升序的
                        maxNewIndexSoFar = newIndex
                    } else {//一旦发现有的不是升序,就认定有节点需要移动,启动最长递增子序列算法
                        moved = true
                    }

                    //在这里给 newIndexToOldIndexMap 赋值。 这时候的j就是旧index
                    // newIndex - s2 的含义:因为我们的数组索引是从"变化区域的左侧"开始,而newIndex的索引是从"整个children的左侧"开始,所以需要相减,才是我们真正想赋值的
                    // j + 1 的含义:因为j是从0开始,而在这个数组中,0是有特殊含义的(代表新值在老的里不存在)
                    newIndexToOldIndexMap[newIndex - s2] = j + 1

                    patch(preChild, c2[newIndex], container, parentComponent, null) //进行patch更新节点

                    patched++ //被处理过的值++
                }

            }

            //使用最长递增子序列优化:因为如果有部分元素是升序的话,那么这些元素就是不需要移动的,只需要移动乱序的即可。
            /** 若有节点需要移动,得到最长递增子序列(下标数组),仅对移动过的节点处理 */
            const increasingNewIndexSequence = moved ? getSequence(newIndexToOldIndexMap) : [] // 通过 moved 来进行优化,如果没有移动过的话 那么就不需要执行算法(这个算法也是有些时间复杂度的)
            /**最长递增子序列的指针,从 (increasingNewIndexSequence.length - 1) 开始逆序遍历 */
            let point = increasingNewIndexSequence.length - 1

            //处理 顺序移动或新增 的情况。   使用逆序遍历,才能保证插入时的锚点已经被固定!!!
            for (let j = toBePatched - 1; j >= 0; j--) {
                // 假设toBePatched是5 , 此时的递增子序列 increasingNewIndexSequence 是[1, 2, 4] , point和j从末尾开始逆序
                // j=4时,point=2,递增子序列[point] = 4, j === 4,则point--
                // j=3时,ponit=1,递增子序列[point] = 2, j !== 2,则移动位置
                // j=2时,ponit=1,递增子序列[point] = 2, j === 2,则point--
                // j=1时,point=0,递增子序列[point] = 1, j === 1,则point--


                /**确定当前要处理的节点索引 */
                const nextIndex = s2 + j; // 因为j是 "中间区间" 的索引,而我们要拿到的孩子应该是在整个children数组中的索引
                /**根据当前要处理的索引,拿到新孩子数组中指定的孩子 */
                const nextChild = c2[nextIndex];
                /**要插入的锚点。锚点等于当前节点索引+1,也就是当前节点的后面一个节点。 因为是倒遍历,所以锚点是位置确定的节点,不会变动了 */
                const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : parentAnchor; //  判断是否越界,越界了就用默认的

                if (newIndexToOldIndexMap[j] === 0) {//如果当前值是0,说明要创建

                    patch(null, nextChild, container, parentComponent, anchor)//进行patch创建新节点

                } else if (moved) {//需要移动的时候才

                    if (j !== increasingNewIndexSequence[point] || point < 0) {//除了判断j是否不相等, 还有point小于0的时候,就直接移动就行
                        //如果当前j和递增子序列的值不相同,说明是"非递增子序列",需要移动位置
                        console.log(nextChild.el, '这个节点要移动位置');
                        hostInsert(nextChild.el!, container, anchor) //在指定锚点的前面插入DOM,即 container.insertBefore(el, anchor)
                    } else {
                        point-- //不需要移动的话,说明匹配成功递增子序列了,就 point-- 即可
                    }

                }


            }

        }



    }






/**获得最长递增子序列的索引数组
 * @param arr 要被查找的数组  
 * @returns 返回最长子序列的下标数组。 比如 传入[4,2,3,1,5] ,得到 [1,2,4]  (即最长递增序列是 2 3 5)
 */
function getSequence(arr: number[]): number[] {
    const p = arr.slice();
    const result = [0];
    let i: number, j: number, u: number, v: number, c: number;
    const len = arr.length;
    for (i = 0; i < len; i++) {
        const arrI = arr[i];
        if (arrI !== 0) {
            j = result[result.length - 1];
            if (arr[j] < arrI) {
                p[i] = j;
                result.push(i);
                continue;
            }
            u = 0;
            v = result.length - 1;
            while (u < v) {
                c = (u + v) >> 1;
                if (arr[result[c]] < arrI) {
                    u = c + 1;
                } else {
                    v = c;
                }
            }
            if (arrI < arr[result[u]]) {
                if (u > 0) {
                    p[i] = result[u - 1];
                }
                result[u] = i;
            }
        }
    }
    u = result.length;
    v = result[u - 1];
    while (u-- > 0) {
        result[u] = v;
        v = p[v];
    }
    return result;
}

5.结语

关于DIff算法的理解就到这里啦,可能有的地方写的不对,欢迎在评论区指出告诉我

相关推荐
熊的猫1 小时前
JS 中的类型 & 类型判断 & 类型转换
前端·javascript·vue.js·chrome·react.js·前端框架·node.js
mosen8682 小时前
Uniapp去除顶部导航栏-小程序、H5、APP适用
vue.js·微信小程序·小程序·uni-app·uniapp
别拿曾经看以后~3 小时前
【el-form】记一例好用的el-input输入框回车调接口和el-button按钮防重点击
javascript·vue.js·elementui
Gavin_9153 小时前
【JavaScript】模块化开发
前端·javascript·vue.js
Devil枫9 小时前
Vue 3 单元测试与E2E测试
前端·vue.js·单元测试
GIS程序媛—椰子10 小时前
【Vue 全家桶】6、vue-router 路由(更新中)
前端·vue.js
毕业设计制作和分享11 小时前
ssm《数据库系统原理》课程平台的设计与实现+vue
前端·数据库·vue.js·oracle·mybatis
程序媛小果11 小时前
基于java+SpringBoot+Vue的旅游管理系统设计与实现
java·vue.js·spring boot
从兄11 小时前
vue 使用docx-preview 预览替换文档内的特定变量
javascript·vue.js·ecmascript
凉辰12 小时前
设计模式 策略模式 场景Vue (技术提升)
vue.js·设计模式·策略模式