react和vue diff算法解析与对比

7,764 阅读2分钟

React的diff算法

image.png

  • 只对同级节点进行对比,如果DOM节点跨层级移动,则不复用
  • 用key来构建一个老节点的map,复用一个后要从map里删除
  • lastPlacedIndex 表示最后一个不需要移动的节点的索引
  • 移动时的原则是尽量少量的移动,如果必须有一个要动,新地位高的不动,新地位低的动

对比顺序

  • 1、如果可以找到key对应的节点,再对比类型,如果类型不同,就删除旧节点重新创建,
  • 2、类型相同,对比lastPlacedIndex 与 oldIndex,lastPlacedIndex <= oldIndex 不需要移动,否则就需要移动位置,并且更新属性

将A B C D E F修改为A C E B G 的执行顺序

  • lastPlacedIndex = 0
  • A在map里面存在,而且位置相同,复用节点更新属性
  • C 对比 lastPlacedIndex < oldIndex,lastPlacedIndex = 2,位置不动,只更新属性
  • E 对比 lastPlacedIndex < oldIndex,lastPlacedIndex = 4,位置不动,只更新属性
  • B 对比 lastPlacedIndex > oldIndex,需要移动位置并更新属性
  • G 在map里找不到,需要创建并插入
  • 将map中剩余的元素 D F标记为删除

修改dom的顺序: 先删除,然后更新与移动,最后做插入操作

const EFFECT_TYPE = {
  UPDATE: 2, // 更新
  INSERT: 4, // 插入
  INSERT_UPDATE: 6, // 插入并更新
  DELETE: 8, // 删除
};

const oldNodes = [{ key: "A" }, { key: "B" }, { key: "C" }, { key: "D" }];

const newNodes = [
  { key: "D" },
  { key: "A" },
  { key: "F" },
  { key: "G" },
  { key: "B" },
];

/**
 *  先删除,然后更新与移动,最后做插入操作
 *   A  B  C  D  变为
 *   D  A  F  G  B
 *   修改顺序
 *1   A  B  D
 *2.  B  D  A
 *3.  D  A  B
 *4.  D  A  F  B
 *5.  D  A  F  G  B
 */

function diff(oldNodes, newNodes) {
  let lastPlacedIndex = 0;
  const oldNodeMap = oldNodes.reduce((memo, v, i) => {
    memo[v.key] = v;
    v.oldIndex = i;
    return memo;
  }, {});

  newNodes.forEach((newNode, newIndex) => {
    const oldNode = oldNodeMap[newNode.key];
    if (!oldNode) {
      // 找不到
      newNode.effectTag = EFFECT_TYPE.INSERT;
      newNode.insertIndex = newIndex;
      return;
    }

    // 从map中删除
    delete oldNodeMap[newNode.key];

    // 位置不同, 插入并更新
    if (lastPlacedIndex <= oldNode.oldIndex) {
      // 看地位, 索引大于lastPlacedIndex 不动
      newNode.effectTag = EFFECT_TYPE.UPDATE;
      lastPlacedIndex = oldNode.oldIndex;
    } else {
      // 索引小于lastPlacedIndex 需要移动
      newNode.effectTag = EFFECT_TYPE.INSERT_UPDATE;
    }
  });
  return Object.keys(oldNodeMap);
}

const deletions = diff(oldNodes, newNodes);

console.log("删除节点", deletions);

console.log(newNodes);

Vue的diff算法

vue中的diff算法涉及到操作dom的逻辑,所以用html来做演示

  • oldNodes表示老的虚拟节点列表,el-真实dom,tag-标签类型
  • domDiff方法接收三个参数,el-要参与对比节点的父节点,oldChildren-老的虚拟dom列表,newChildren-新的虚拟dom列表
  • vue中的节点对比采用双指针,从两端向中间遍历,当指针交叉的时候,就是对比完成了
  • 开始遍历时,首先依次进行头头、尾尾、头尾、尾头对比,这也是vue中diff算法 的一个优化点
  • 都对比完了,再对比其他没有移动规律的节点

image.png

image.png

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <ul id="container">
      <li id="domA">A</li>
      <li id="domB">B</li>
      <li id="domC">C</li>
      <li id="domD">D</li>
    </ul>
    <script>
      const oldNodes = [
        { key: "A", el: domA, tag: "li" },
        { key: "B", el: domB, tag: "li" },
        { key: "C", el: domC, tag: "li" },
        { key: "D", el: domD, tag: "li" },
      ];

      const newNodes = [
        { key: "C", tag: "li" },
        { key: "A", tag: "li" },
        { key: "F", tag: "li" },
        { key: "G", tag: "li" },
        { key: "B", tag: "li" },
      ];

      /**
       * 看两个节点是否相同节点, 对比tag和key是否一样
       * @param {*} newVnode
       * @param {*} oldVnode
       */
      function isSameVnode(newVnode, oldVnode) {
        return newVnode.tag === oldVnode.tag && newVnode.key == oldVnode.key;
      }

      /**
       *
       * @param {*} el 真实dom节点
       * @param {*} oldChildren 老虚拟dom
       * @param {*} newChildren 新虚拟dom
       */
      function domDiff(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];
        
        // 根据老的节点,构造一个map
        let oldNodeMap = oldChildren.reduce((memo, item, index) => {
          // A: 0  记录位置
          memo[item.key] = index;
          return memo;
        }, {});

        // 双指针对比,从两端向中间遍历,当指针交叉的时候,就是对比完成了
        while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
          // 指针移动的时候,可能元素已经被移走了,那就跳过这一项
          if (!oldStartVnode) {
            oldStartVnode = oldChildren[++oldStartIndex];
            console.log("1. oldStartVnode 为空");
          } else if (!oldEndVnode) {
            oldEndVnode = oldChildren[--oldEndIndex];
            console.log("2. oldEndVnode 为空");
          } else if (isSameVnode(oldStartVnode, newStartVnode)) {
            console.log("3. 头头相同", newStartVnode.key);
            // 头头比较,如果相同就移动头指针
            oldStartVnode = oldChildren[++oldStartIndex];
            newStartVnode = newChildren[++newStartIndex];
          } else if (isSameVnode(oldEndVnode, newEndVnode)) {
            console.log("4. 尾尾相同", newEndVnode.key);
            // 尾尾比较,如果相同,移动尾指针
            oldEndVnode = oldChildren[--oldEndIndex];
            newEndVnode = newChildren[--newEndIndex];
          } else if (isSameVnode(oldStartVnode, newEndVnode)) {
            console.log(
              `5. 头尾相同 移动 ${oldStartVnode.key}${oldEndVnode.key}的下一节点之前`
            );
            // 头尾比较
            // 将oldStartVnode.el 老节点的真实dom,移动到老的节点的最后
            el.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling);
            oldStartVnode = oldChildren[++oldStartIndex];
            newEndVnode = newChildren[--newEndIndex];
          } else if (isSameVnode(oldEndVnode, newStartVnode)) {
            // 尾头比较
            console.log(
              `6. 尾头相同 移动${oldEndVnode.key}${oldStartVnode.key}之前`
            );
            el.insertBefore(oldEndVnode.el, oldStartVnode.el);
            oldEndVnode = oldChildren[--oldEndIndex];
            newStartVnode = newChildren[++newStartIndex];
          } else {
            // 上面都是特殊情况
            // 头头、尾尾、头尾、尾头都对比完了
            // 对比乱序的
            let moveIndex = oldNodeMap[newStartVnode.key];
            if (moveIndex === undefined) {
              // 找不到索引, 是新的节点,要创建一下
              el.insertBefore(createElm(newStartVnode), oldStartVnode.el);
              console.log(`7. 创建新节点${newStartVnode.key} 插入到 ${oldStartVnode.key}之前`);
            } else {
              // 找到了
              let moveVnode = oldChildren[moveIndex];
              el.insertBefore(moveVnode.el, oldStartVnode.el);
              // 将已经移动的节点标记为undefine
              oldChildren[moveIndex] = undefined;
              console.log(
                `8. 移动乱序节点${moveVnode.key}${oldStartVnode.key} 之前`
              );
            }
            newStartVnode = newChildren[++newStartIndex];
          }
        }

        // 新的多,那么就将多的插入进去即可
        if (newStartIndex <= newEndIndex) {
          // 参照物
          let anchor =
            newChildren[newEndIndex + 1] === null
              ? null
              : newChildren[newEndIndex + 1].el;
          for (let i = newStartIndex; i <= newEndIndex; i++) {
            console.log("插入", newChildren[i].key);
            el.insertBefore(createElm(newChildren[i]), anchor);
          }
        }

        // 老的多余,需要清理掉,删除即可
        if (oldStartIndex <= oldEndIndex) {
          for (let i = oldStartIndex; i <= oldEndIndex; i++) {
            let child = oldChildren[i];
            console.log("删除", child.key);
            child && el.removeChild(child.el);
          }
        }
      }

      /**
       * 根据虚拟dom创建真实dom
       * @param {*} vnode
       * @returns
       */
      function createElm(vnode) {
        let { tag, text, key } = vnode;

        if (typeof tag === "string") {
          vnode.el = document.createElement(tag);
          vnode.el.innerText = key;
        } else {
          vnode.el = document.createTextNode(text);
        }
        return vnode.el;
      }

      domDiff(container, oldNodes, newNodes);
    </script>
  </body>
</html>

区别

相同点

  • 都是两组虚拟dom的对比(react16.8之后是fiber与虚拟dom的对比)
  • 只对同级节点进行对比,简化了算法复杂度
  • 都用key做为唯一标识,进行查找,只有key和标签类型相同时才会复用老节点
  • 遍历前都会根据老的节点构建一个map,方便根据key快速查找

不同点

  • react在diff遍历的时候,只对需要修改的节点进行了记录,形成effect list,最后才会根据effect list 进行真实dom的修改,修改时先删除,然后更新与移动,最后插入
  • vue 在遍历的时候就用真实dominsertBefore方法,修改了真实dom,最后做的删除操作
  • react 采用单指针从左向右进行遍历
  • vue采用双指针,从两头向中间进行遍历
  • react的虚拟diff比较简单,vue中做了一些优化处理,相对复杂,但效率更高