Vue 3.0 Diff 源码分析

348 阅读8分钟

前言

本文章将通过 TDD 测试驱动开发的方式来描述 Vue 3.x 中 Diff 算法的一个执行过程的解析。

本文仅对 Diff 算法中的对比过程进行分析,至于进一步的 patch mountElement move unmount 的具体实现在这就不进行阐述了,如果想来接更细节的部分请参考阅读:Vue2 Mini 源码实现与记录

TDD

TDD是测试驱动开发(Test-Driven Development)的英文简称,是敏捷开发中的一项核心实践和技术,也是一种设计方法论。

TDD的原理是在开发功能代码之前,先编写单元测试用例代码,测试代码确定需要编写什么产品代码。

优点: 在任意一个开发节点都可以拿出一个可以使用,含少量bug并具一定功能和能够发布的产品。

缺点: 增加代码量。测试代码是系统代码的两倍或更多,但是同时节省了调试程序及挑错时间。

接下来就通过 TDD 开发方式来一步步的讲解 Diff 算法的整个过程=

Diff

左边查找

const { diffArray } = require('../diff');

it('1. 左边查找', () => {
    const mountElement = jest.fn();
    const patch = jest.fn();
    const unmount = jest.fn();
    const move = jest.fn();
    diffArray(
      [{ key: 'a' }, { key: 'b' }, { key: 'c' }],
      [{ key: 'a' }, { key: 'b' }, { key: 'd' }, { key: 'e' }],
      {
        mountElement,
        patch,
        unmount,
        move,
      }
    );
    // 第一次调用次数
    expect(patch.mock.calls.length).toBe(2);
    // 第一次调用的第一个参数
    expect(patch.mock.calls[0][0]).toBe('a');
    expect(patch.mock.calls[1][0]).toBe('b');
  });

创建 Diff 算法所需的函数:mountElementpatchmoveunmount

Diff 算法一开始先进行两边比较:

exports.diffArray = (c1, c2, { mountElement, patch, unmount, move }) => {

  // * 1. 左边按序查找,如果节点不能复用,则停止
  function isSameVNodeType(n1, n2) {
    return n1.key === n2.key; //&& n1.type === n2.type;
  }

  // 从左边遍历的下标
  let i = 0;
  // 最后一个老元素节点下标
  let e1 = c1.length - 1;
  // 最后一个新元素元素节点下标
  let e2 = c2.length - 1;

  while (i <= e1 && i <= e2) {
    const n1 = c1[i];
    const n2 = c2[i];
    if (isSameVNodeType(n1, n2)) {
      patch(n1.key);
    } else {
      break;
    }
    i++;
  }
}

右边查找

it('2. 右边查找', () => {
  const mountElement = jest.fn();
  const patch = jest.fn();
  const unmount = jest.fn();
  const move = jest.fn();
  diffArray(
    [{ key: 'a' }, { key: 'b' }, { key: 'c' }],
    [{ key: 'd' }, { key: 'e' }, { key: 'b' }, { key: 'c' }],
    {
      mountElement,
      patch,
      unmount,
      move,
    }
  );
  expect(patch.mock.calls.length).toBe(2);
  expect(patch.mock.calls[0][0]).toBe('c');
  expect(patch.mock.calls[1][0]).toBe('b');
});

比较完左边,现在来检查右边

// *2. 右边按序查找,如果节点不能复用,则停止
while (i <= e1 && i <= e2) {
  const n1 = c1[e1];
  const n2 = c2[e2];
  if (isSameVNodeType(n1, n2)) {
    patch(n1.key);
  } else {
    break;
  }
  e1--;
  e2--;
}

老节点没了

it("3. 老节点没了", () => {
  const mountElement = jest.fn();
  const patch = jest.fn();
  const unmount = jest.fn();
  const move = jest.fn();
  diffArray(
    [{ key: "a" }, { key: "b" }],
    [{ key: "a" }, { key: "b" }, { key: "c" }],
    {
      mountElement,
      patch,
      unmount,
      move,
    }
  );
  expect(patch.mock.calls.length).toBe(2);
  expect(patch.mock.calls[0][0]).toBe("a");
  expect(patch.mock.calls[1][0]).toBe("b");
  expect(mountElement.mock.calls[0][0]).toBe("c");
});
 // * 3. 老节点没了
if (i > e1) {
  // 老节点没了
  if (i <= e2) {
    // 新节点还有
    while (i <= e2) {
      const n2 = c2[i];
      mountElement(n2.key);
      i++;
    }
  }
}

新节点没了

it("4. 新节点没了", () => {
  const mountElement = jest.fn();
  const patch = jest.fn();
  const unmount = jest.fn();
  const move = jest.fn();
  diffArray(
    [{ key: "a" }, { key: "b" }, { key: "c" }],
    [{ key: "a" }, { key: "b" }],
    {
      mountElement,
      patch,
      unmount,
      move,
    }
  );
  // 第一次调用次数
  expect(patch.mock.calls.length).toBe(2);
  // 第一次调用的第一个参数
  expect(patch.mock.calls[0][0]).toBe("a");
 expect(patch.mock.calls[1][0]).toBe("b");
  expect(unmount.mock.calls[0][0]).toBe("c");
});
// * 4.新节点没了
else if (i > e2) {
  // 老节点如果有的话,逐个删除
  while (i <= e1) {
    const n1 = c1[i];
    unmount(n1.key);
    i++;
  }
}

新老节点都有,但是顺序不稳定

测试用例:

it("5. 新老节点都有,但是顺序不稳定", () => {
    const mountElement = jest.fn();
    const patch = jest.fn();
    const unmount = jest.fn();
    const move = jest.fn();

    diffArray(
      [ { key: "a" }, { key: "b" },

        { key: "c" }, { key: "d" }, { key: "e" },

        { key: "f" }, { key: "g" },
      ],
      [
        { key: "a" }, { key: "b" },

        { key: "e" }, { key: "d" }, { key: "c" }, { key: "h" },

        { key: "f" }, { key: "g" },
      ],
      {
        mountElement,
        patch,
        unmount,
        move,
      }
    );
    // 第一次调用次数
    expect(patch.mock.calls.length).toBe(7);
    // 第一次调用的第一个参数
    expect(patch.mock.calls[0][0]).toBe("a");
    expect(patch.mock.calls[1][0]).toBe("b");

    expect(patch.mock.calls[2][0]).toBe("g");
    expect(patch.mock.calls[3][0]).toBe("f");

    expect(patch.mock.calls[4][0]).toBe("c");
    expect(patch.mock.calls[5][0]).toBe("d");
    expect(patch.mock.calls[6][0]).toBe("e");

    expect(unmount.mock.calls.length).toBe(0);
    //                 0 1  2 3 4  5 6
    // [i ... e1 + 1]: a b [c d e] f g
    // [i ... e2 + 1]: a b [e d c h] f g
    
    // todo
    // 1. mount
    expect(mountElement.mock.calls[0][0]).toBe("h");
    // 2. move
    expect(move.mock.calls[0][0]).toBe("d");
    expect(move.mock.calls[1][0]).toBe("e");
  });

通过上述的对比后,接下来就是 diff 算法最复杂的地方了,剩下的待比较的节点为新老节点都存在但不完全相等的子串,例如:

// 老:a b [c d e] f g
// 新:a b [e d c h] f g

这部分的子串的新老转换包含了:新增、删除、移动,下面就来看看如何处理:

// 老元素遍历到的下标位置
const s1 = i;
// 新元素元素遍历到的下标位置
const s2 = i;

/**
 * 5.1 把新元素做成key:value的Map图 (key: index)
 * React 也存在 Map,但是由于 React 是链表不是数组,所以产生的是(key: Fiber)类型
 * 为什么要生成这个 map 是为了方便查找老节点在新节点中是否存在,存在的话下标是什么
 */
const keyToNewIndexMap = new Map();
for (i = s2; i <= e2; i++) {
  const nextChild = c2[i];
  keyToNewIndexMap.set(nextChild.key, i);
}

// 实时标记现在还剩下多少新元素需要patch
let patched = 0;

// 标记当前总共剩下有多少新元素需要patch
const toBePatched = e2 - s2 + 1;
    
// 标记是否存在需要移动的节点
let moved = false;

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

/**
 * 如果判断一个老节点在新节点中是否需要进行移动?
 * 是要通过判断一个节点的相对位置的变化来决定是否需要移动,而不能通过绝对位置的不同就需要进行移动的
 * 比如:a b c -> a c b,这个案例中 a b 是不需要移动的,只有 c 需要往前移动即可
 */
let maxNewIndexSoFar = 0;

// 标记新节点下标对应的老节点下标,用于记录老节点是否可以复用的集合,默认每一个新节点全对应的值为 0
const newIndexToOldIndexMap = new Array(toBePatched);
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0;

// *5.2 遍历老元素 unmount
for (i = s1; i <= e1; i++) {
  const prevChild = c1[i];
  if (patched >= toBePatched) {
    // 复用老元素的个数已经够了
    unmount(prevChild.key);
    continue;
  }
  // newIndex是节点在新vdom中的下标
  let newIndex = keyToNewIndexMap.get(prevChild.key);
  if (newIndex === undefined) {
    // 老节点没法复用
    unmount(prevChild.key);
  } else {
    // 节点可以复用
    // 下标是新节点的相对位置为了不浪费空间前后对比过的就不记录了,只记录中间乱序子串,
    // 值是老节点的下标位置 + 1,为什么加一,因为后续判断 0 表示该新节点为无老节点复用需创建,其实为了新老节点的第一个节点开始就无法复用的情况Z
    // 遍历newIndexToOldIndexMap,value===0,这个节点没法复用,value>0,节点可以复用
    newIndexToOldIndexMap[newIndex - s2] = i + 1;

    // 如何判断是否需要移动,从这逻辑判断
    if (newIndex >= maxNewIndexSoFar) {
      maxNewIndexSoFar = newIndex;
    } else {
      // 相对位置发生变化
      moved = true;
    }
    patch(prevChild.key);
    patched++;
  }
}

到这里已经走完了第一步,那就是遍历老节点,进行了一些无法复用节点的 unmount 操作,还有对新老节点可复用与需要新增节点的集合进行组装

接下来再次遍历新节点,处理需要新增的节点和需要进行移动的节点:

// *5.3 遍历新元素 move mount

/**
 * 最长递增子序列的元素不需要动,
 * 注意:获取的是新节点下标对应老节点下标数组的最长递增子序列下标集合
 * 举例:a b [c d e] f g -> a b [e d c h] f g
 * newIndexToOldIndexMap 集合为:[5 4 3 0] 得到的最长递增子序列为 [2] 注意这里的 2 为相对下标
 * 再举例:a b [c d e] f g -> a b [e c d h] f g
 * newIndexToOldIndexMap 集合为:[5 3 4 0] 得到的最长递增子序列为 [1, 2] 注意这里的 1, 2 为相对下标
 * 这里的 getSequence 计算最长递增子序列后续再讲
 */
const increasingNewIndexSequence = moved ? getSequence(newIndexToOldIndexMap) : [];

// 最后一个元素下标
let lastIndex = increasingNewIndexSequence.length - 1;

for (i = toBePatched - 1; i >= 0; i--) {
  // 新节点下标
  const nextIndex = s2 + i;
  const nextChild = c2[nextIndex];
  // todo 判断是move还是mount
  // 如果说当前节点是老节点中就有的,就move,否则就是mount
  if (newIndexToOldIndexMap[i] === 0) {
    // 节点没法复用
    mountElement(nextChild.key);
  } else if (moved) {
    /**
     * 节点可能要移动
     * 1. 已检查完最长递增子序列集合,也就是剩下的都需要移动了
     * 2. 当前新节点下标不等于当前递增子序列集合值的情况,说明该节点需要移动
     */
    if (lastIndex < 0 || i !== increasingNewIndexSequence[lastIndex]) {
      move(nextChild.key);
    } else {
      lastIndex--;
    }
  }
}

以上就完成对 Vue 的 diff 算法的流程了,其中还有一个 getSequence 没有深入探索,接下来就来看看,getSequence 做了什么

/**
 * 以上面使用的案例这种可知道,传入的 arr 为:
 * 1. [5 4 3 0]
 * 2. [5 3 4 0]
 */
function getSequence(arr) {
  // 初始值是arr,p[i]记录第i个位置的索引
  const recordIndexOfI = arr.slice(); // 拷贝一份
  const result = [0]; // 初始化一个结果,因为最长递增子序列最少也有一个
  const len = arr.length; 

  let resultLastIndex;
  let resultLast;

  for (let i = 0; i < len; i++) {
    const arrI = arr[i];
    if (arrI !== 0) { // 不可复用的节点不管
      // result 最后一个元素下标
      resultLastIndex = result.length - 1;
      // resultLast 为 arr 的下标
      resultLast = result[resultLastIndex];
      if (arr[resultLast] < arrI) {
        recordIndexOfI[i] = resultLast;
        result.push(i);
        continue;
      }
      let left = 0,
      right = resultLastIndex;
      while (left < right) {
        const mid = (left + right) >> 1;
        if (arr[result[mid]] < arrI) {
          left = mid + 1;
        } else {
          right = mid;
        }
      }

      if (arrI < arr[result[left]]) {
        if (left > 0) {
          /**
           * 将最长递增子序列准备要加入的 arr 的下标 i,所对应的 arr 中的值
           * 更改为最长递增子序列中前一个的值,也就是所对应的 arr 下标
           */
          recordIndexOfI[i] = result[left - 1];
        }
        /**
         * 因为二分法的时候先比较当前项 arrI 大于 中间项 arr[result[mid]] 就 mid + 1了
         * 所以当前项肯定大于 left - 1 项的,由于贪心策略,所以将原较大的 result[left] 替换为当前较小的  result[left]
         */
        result[left] = i;
      }
    }
  }

  //  recordIndexOfI记录了正确的索引 result 进而找到最终正确的索引
  resultLastIndex = result.length - 1; // 最长递增子序列下标
  resultLast = result[resultLastIndex]; // 最长递增子序列的值,也就是 arr 的下标

  while (resultLastIndex >= 0) {
    result[resultLastIndex] = resultLast; // 最长递增子序列的当前 resultLastIndex 赋值为 resultLast 其实就是 arr 的下标
    resultLast = recordIndexOfI[resultLast]; // 查找 arr 中当前 resultLast,其实就是 arr 的下标的值,这个值记录着在最长递增子序列中前一个位置的值,也就是在 arr 中的下标
    resultLastIndex--; // 在 最长递增子序列 当前索引往前一位
  }
  console.log('recordIndexOfI', recordIndexOfI); //sy-log

  /**
   * 到此就结束了最长递增子序列的计算了
   * 按照前面的案例:
   * 1. [5 4 3 0]
   * 2. [5 3 4 0]
   * 得到的就是
   * 1. [2] 注意这里的 2 为相对下标
   * 2. [1, 2] 注意这里的 1, 2 为相对下标
   */
  return result;
}

到此就结束了 Vue 的 Diff 算法的解析,下面不让再来一个 TDD 测试:

it('6. 新老节点都有,但是顺序不稳定', () => {
    const mountElement = jest.fn();
    const patch = jest.fn();
    const unmount = jest.fn();
    const move = jest.fn();
    const { diffArray } = require('../longestIncreasingSubsequence');
    diffArray(
      [
        { key: 'a' },
        { key: 'b' },
        { key: 'c' },
        { key: 'd' },
        { key: 'e' },
        { key: 'f' },
        { key: 'g' },
      ],
      [
        { key: 'a' },
        { key: 'b' },
        { key: 'e' },
        { key: 'c' },
        { key: 'd' },
        { key: 'h' },
        { key: 'f' },
        { key: 'g' },
      ],
      {
        mountElement,
        patch,
        unmount,
        move,
      }
    );
    // 第一次调用次数
    expect(patch.mock.calls.length).toBe(7);
    // 第一次调用的第一个参数
    expect(patch.mock.calls[0][0]).toBe('a');
    expect(patch.mock.calls[1][0]).toBe('b');
    expect(patch.mock.calls[2][0]).toBe('g');
    expect(patch.mock.calls[3][0]).toBe('f');
    expect(patch.mock.calls[4][0]).toBe('c');
    expect(patch.mock.calls[5][0]).toBe('d');
    expect(patch.mock.calls[6][0]).toBe('e');
    expect(unmount.mock.calls.length).toBe(0);
    //                 0 1  2 3 4  5 6
    // [i ... e1 + 1]: a b [c d e] f g
    // [i ... e2 + 1]: a b [e c d h] f g
    //                      5 3 4 0
    // todo
    // 1. mount
    expect(mountElement.mock.calls[0][0]).toBe('h');
    // 2. move
    expect(move.mock.calls[0][0]).toBe('e');
});

结语

以上就是通过 TDD 方式来编写代码的模式,以上通过一个个 Demo 的方式展示了 Vue Diff 算法的流程,希望能有助于阅读到此的读者能够对 Vue Diff 算法有一个较为清晰的认知,感谢~

附录

image