React/虚拟DOM及Diff算法

1,057 阅读11分钟

虚拟DOM

  • 什么是虚拟dom
    首先打印出react的dom结构 其生成的结果是一个包含了标签类型type,属性props以及它包含子元素children的对象。这就是虚拟dom的结构。

    C8D96DAF-72B5-485F-A5AB-6F3085CC8977.png

  • 为什么要使用虚拟dom

    1. 观察虚拟dom的结构我们可以知道,它抛弃了原生dom结构中挂载的大量方法与属性,而聚焦在了实际更新过程中的可变部分,通过对虚拟dom的对比即可确定更新过程中发生的改变。提高了更高效的对比可行性。
    2. react本身的异步渲染基于虚拟dom,直接操作dom无法做到异步和渲染等待。
    3. 虚拟dom将原生的dom抽象成了一个公共的体系,抹平平台间的差异,如react native也是根据虚拟dom进行渲染,只是渲染策略不同。
    4. 虚拟dom是react事件合成机制的基础,通过事件合成机制来抹平浏览器间的兼容性问题。
  • 虚拟dom的构建
    输入的jsx代码会经过babel编译,转换成React.createElement。createElement方法的作用是,根据传递给createElement的参数config进行数据处理和赋值,处理完成之后调用ReactElement方法,来生成上面展示出的虚拟dom结构。

    const ReactElement = function(type, key, ref, self, source, owner, props) {
      const element = {
        // This tag allows us to uniquely identify this as a React Element
        $$typeof: REACT_ELEMENT_TYPE,
        // Built-in properties that belong on the element
        type: type, // 元素的类型,可以是原生html类型(字符串),或者自定义组件(函数或 class)
        key: key, // 组件的唯一标识,用于 Diff算法
        ref: ref,
        props: props, // 传入组件的 props
        // Record the component responsible for creating this element.
        _owner: owner,
      };
      return element;
    };
    复制代码
    

    可以看到有个$$typeof属性,这个属性的赋值是一个symbol

    var REACT_ELEMENT_TYPE = (typeof Symbol==='function' && Symbol.for && Symbol.for('react.element')) || 0xeac7;
    复制代码
    

    它的目的是,阻止特定情况下的XSS攻击。如果服务端允许用户存储任意JSON对象时,在无$$typeof的情况下,可以通过json手动构建ReactElement对象传入元素中进行攻击。所以React使用Symbol标记每个元素,由于JSON不支持symbol类型,即可避免此类攻击。

  • 虚拟dom的渲染
    在React16引入fiber之后,整个React的架构也使用fiber重构了一遍,新架构下,虚拟dom树也被称为Fiber树。其结构如下

    未命名文件 (2).png

    React在渲染过程中,首先会创建FiberRoot对象,这是Fiber树的起点,同时记录整个应用更新过程的各种信息。
    React会为遍历到的每个Fiber节点生成他的所有子Fiber,生成子Fiber的同时需要去协调子节点,在进入协调的时候传入节点就是父Fiber。它的子节点在协调之前,是通过更新的状态数据生成的最新虚拟DOM数据,是个数组结构的元素数据,也就是进行Diff的地方。这颗新生成的树称为workInProgress Fiber树,commit阶段会根据该树进行页面的重新渲染。

React-diff算法

React在进行渲染的过程中,通过新旧虚拟DOM对比,找出最小变化的地方转为进行DOM操作,找到最小变化的这个过程也就是diff算法的过程。

diff算法的目的是为了减少DOM操作的性能开销,要尽可能的复用DOM元素,所以需要判断出是否有节点需要移动,应该如何移动以及找出那些需要被添加或删除的节点。

需要注意的是,在React中,只在同一层级进行diff,同一层级未曾匹配到即进行删除或新增操作,不会去递归寻找,这也极大减少了diff算法的时间复杂度。同时,diff算法比较的是内存中的数组与链表(即通过sibling属性链接的fiber链表(vnode)与react元素数组之间的diff),不会操作dom,但是会给fiber贴上需要进行操作的不同标签。 react通过reconcileChildFibers方法去做diff,然后得出effect list。

  function reconcileChildFibers(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
    newChild: any,
    lanes: Lanes,
  ): Fiber | null {
    if (typeof newChild === 'object' && newChild !== null) {
      switch (newChild.$$typeof) {
        case REACT_ELEMENT_TYPE:
          // 处理单个子节点
          return placeSingleChild(
            reconcileSingleElement(
              returnFiber,
              currentFirstChild,
              newChild,
              lanes,
            ),
          );
        case REACT_PORTAL_TYPE:
          return placeSingleChild(
            reconcileSinglePortal(
              returnFiber,
              currentFirstChild,
              newChild,
              lanes,
            ),
          );
        case REACT_LAZY_TYPE:
          const payload = newChild._payload;
          const init = newChild._init;
          return reconcileChildFibers(
            returnFiber,
            currentFirstChild,
            init(payload),
            lanes,
          );
      }

      // 处理多个子节点
      if (isArray(newChild)) {
        return reconcileChildrenArray(
          returnFiber,
          currentFirstChild,
          newChild,
          lanes,
        );
      }
      // 迭代器函数,算法同reconcileChildrenArray
      if (getIteratorFn(newChild)) {
        return reconcileChildrenIterator(
          returnFiber,
          currentFirstChild,
          newChild,
          lanes,
        );
      }

      throwOnInvalidObjectType(returnFiber, newChild);
    }

    // 处理文本节点
    if (
      (typeof newChild === 'string' && newChild !== '') ||
      typeof newChild === 'number'
    ) {
      return placeSingleChild(
        reconcileSingleTextNode(
          returnFiber,
          currentFirstChild,
          '' + newChild,
          lanes,
        ),
      );
    }

    // 其他情况都执行空, 删除 children 中剩余的节点
    return deleteRemainingChildren(returnFiber, currentFirstChild);
  }

  return reconcileChildFibers;
}
复制代码

我们主要关注多个子节点的处理,也就reconcileChildrenArray方法。

该方法主要分为两轮对比。

首先进行预处理。循环对比oldFiber与新dom数组,调用updateSlot对比index相同的child的key是否相同,找到第一个不相等的地方,就退出循环,如果newChildren遍历完毕,就删除旧链表的剩余节点,如果旧链表遍历完毕,就插入newChildren剩余节点。
其中判断oldFiber节点是否能够复用的关键,就是方法updateSlot,该方法根据新老节点的key是否相同,来判断是否可以复用。当不加key的时候,默认key为null。

预处理过后如果还未结束,则遍历旧链表创建一个map,该map以key为key,如果没有key则以index为key,保存所有没有匹配到的节点与该节点的位置。
新的数组根据key从这个map里面查找,如果匹配到则删除map中oldFiber然后复用匹配到的数据,没有则新建fiber节点并打上placement标记。
复用或新建fiber节点之后,接下来的重点即是协调更新位置信息,维护一个初始值为0的lastPlacedIndex记录上一节点的位置。如果新节点位置大于lastPlacedIndex则说明节点位置保持不变,同时更新lastPlacedIndex值。如果新节点位置小于lastPlacedIndex则该节点打上placemenet标记说明节点位置需要移动,同时lastPlacedIndex值保持不变。

图解如下: 假设都存在key,以key指代fiber节点与vDOM节点

1-1.png

首先比对A成功,直接复用oldFiber,接下来B、E不匹配,生成map,进行比对。E无法匹配,则新生成节点,同时打上placement标记。

1-2.png

接下来C在map中匹配成功,删除map中的C,此时c的index为2大于为0的lastPlacedIndex,c节点位置不变,更新lastPlacedIndex的值。

1-3.png

最后B在map中匹配成功,删除map中的B,此时B的index为1小于lastPlacedIndex,将B打上placement标记。虚拟dom数组遍历完成之后,map中剩余的oldFiber都未被复用,在后续的commit阶段删除这些oldFiber对应的dom节点。

1-4.png

源码如下:

function reconcileChildrenArray(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
    newChildren: Array<*>,
    expirationTime: ExpirationTime,
  ): Fiber | null {

      //存储链表的第一个指针
    let resultingFirstChild: Fiber | null = null;
    //链表的当前指针
    let previousNewFiber: Fiber | null = null;
    //
    let oldFiber = currentFirstChild;
    let lastPlacedIndex = 0;
    let newIdx = 0;
    let nextOldFiber = null;
    for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
      if (oldFiber.index > newIdx) {
        nextOldFiber = oldFiber;
        oldFiber = null;
      } else {
        nextOldFiber = oldFiber.sibling;
      }
      //// 如果可以复用就返回复用的fiber节点 否则返回null
    // 根据key来判断是否能够复用
      const newFiber = updateSlot(
        returnFiber,
        oldFiber,
        newChildren[newIdx],
        expirationTime,
      );
      //newFiber为null,跳出循环
      if (newFiber === null) {

        if (oldFiber === null) {
          oldFiber = nextOldFiber;
        }
        break;
      }
      if (shouldTrackSideEffects) {
          //返回的newFiber是新创建的,没有复用
        if (oldFiber && newFiber.alternate === null) {
            //删除oldFiber
          deleteChild(returnFiber, oldFiber);
        }
      }
      // 判断节点的 effects 是否为 Placement
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);

      // 将复用的fiber节点链接成一个链表
      if (previousNewFiber === null) {
        // TODO: Move out of the loop. This only happens for the first run.
        resultingFirstChild = newFiber;
      } else {

        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;
      oldFiber = nextOldFiber;
    }
    //遍历newChildren完毕,删除旧链表剩余节点
    if (newIdx === newChildren.length) {
      // We've reached the end of the new children. We can delete the rest.
      deleteRemainingChildren(returnFiber, oldFiber);
      return resultingFirstChild;
    }
    // oldFiber不存在,表明旧的children已经遍历完毕,但是newChildren存在,说明是新增,剩余的新数组的项就可以作为新的项直接插入进去了。
    if (oldFiber === null) {
      // 遍历newChildren 创建新的fiber,通过sibling关联
      for (; newIdx < newChildren.length; newIdx++) {
        const newFiber = createChild(
          returnFiber,
          newChildren[newIdx],
          expirationTime,
        );
        if (newFiber === null) {
          continue;
        }
        lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
        if (previousNewFiber === null) {
          // TODO: Move out of the loop. This only happens for the first run.
          resultingFirstChild = newFiber;
        } else {
          previousNewFiber.sibling = newFiber;
        }
        previousNewFiber = newFiber;
      }
      return resultingFirstChild;
    }


    // 创建一个map,保存所有没有匹配到的节点,然后新的数组根据key从这个 map 里面查找,如果有则复用,没有则新建。
    const existingChildren = mapRemainingChildren(returnFiber, oldFiber);

    // Keep scanning and use the map to restore deleted items as moves.
    //从上面挑出for循环的索引开始继续向后遍历
    for (; newIdx < newChildren.length; newIdx++) {
        // 从map中取出和newChild的key值相同的fiber,然后创建fiber,更新属性,
      const newFiber = updateFromMap(
        existingChildren,
        returnFiber,
        newIdx,
        newChildren[newIdx],
        expirationTime,
      );
      if (newFiber !== null) {
        if (shouldTrackSideEffects) {
          if (newFiber.alternate !== null) {
            // The new fiber is a work in progress, but if there exists a
            // current, that means that we reused the fiber. We need to delete
            // it from the child list so that we don't add it to the deletion
            // list.
            // 存在则删除map中的fiber
            existingChildren.delete(
              newFiber.key === null ? newIdx : newFiber.key,
            );
          }
        }
        // 判断是否移动
        lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
        if (previousNewFiber === null) {
          resultingFirstChild = newFiber;
        } else {
          previousNewFiber.sibling = newFiber;
        }
        previousNewFiber = newFiber;
      }
    }

    if (shouldTrackSideEffects) {
      // Any existing children that weren't consumed above were deleted. We need
      // to add them to the deletion list.
      existingChildren.forEach(child => deleteChild(returnFiber, child));
    }

    return resultingFirstChild;
  }
复制代码

updateSlot方法源码如下

function updateSlot(
  returnFiber: Fiber,
  oldFiber: Fiber | null,
  newChild: any,
  expirationTime: ExpirationTime,
): Fiber | null {
  // Update the fiber if the keys match, otherwise return null.

  const key = oldFiber !== null ? oldFiber.key : null;

  if (typeof newChild === 'string' || typeof newChild === 'number') {
    // 对于新的节点如果是 string 或者 number,那么都是没有 key 的,
    // 所有如果老的节点有 key 的话,就不能复用,直接返回 null。

    if (key !== null) {
      return null;
    }
     // 老的节点 key 为 null 的话,代表老的节点是文本节点,就可以复用
    return updateTextNode(
      returnFiber,
      oldFiber,
      '' + newChild,
      expirationTime,
    );
  }

  if (typeof newChild === 'object' && newChild !== null) {
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE: {
        if (newChild.key === key) {
          if (newChild.type === REACT_FRAGMENT_TYPE) {
            return updateFragment(
              returnFiber,
              oldFiber,
              newChild.props.children,
              expirationTime,
              key,
            );
          }
          return updateElement(
            returnFiber,
            oldFiber,
            newChild,
            expirationTime,
          );
        } else {
          return null;
        }
      }
      case REACT_PORTAL_TYPE: {
        if (newChild.key === key) {
          return updatePortal(
            returnFiber,
            oldFiber,
            newChild,
            expirationTime,
          );
        } else {
          return null;
        }
      }
    }

    if (isArray(newChild) || getIteratorFn(newChild)) {
      if (key !== null) {
        return null;
      }

      return updateFragment(
        returnFiber,
        oldFiber,
        newChild,
        expirationTime,
        null,
      );
    }

    throwOnInvalidObjectType(returnFiber, newChild);
  }

  if (__DEV__) {
    if (typeof newChild === 'function') {
      warnOnFunctionType();
    }
  }

  return null;
}
复制代码

对比vue-diff算法

vue2

vue2中我们同样关注多个子节点的diff方式,在新旧节点都存在children的时候,使用updateChildren方法进行diff。 vue对比新旧虚拟dom,都为数组结构,vue2设置了双指针进行优化,设定4个变量分别指向新旧节点数组的首尾。

vue2通过双指针进行双端对比。首先进行新头旧头,新尾旧尾,新头旧尾与新尾旧头四种对比,其中旧头匹配到新尾,则将旧头插到旧尾后。旧尾匹配到新头,则将旧尾插到旧头前。

如果四种都未匹配上,则使用旧数组创建一个老的节点的索引Map,拿新虚拟DOM开头的第一个节点去map中进行查找,该map同样以key为key,如果没有key的话则遍历查找。
如果未查找到则新建节点插入到未处理的节点(oldStartVnode.elm)的前面,查找成功则递归比对新旧节点的子节点,同时将比对到的节点同样插入到未处理的节点的前面,并且将老数组原来的位置内容设置为undefind。

图解如下:  

2-1.png

首先是旧头与新尾匹配到,那么将旧头dom节点插到旧尾的dom节点之后,移动旧头指针与新尾指针

2-2.png

此时4种均未匹配,则根据key创建map,c节点在map中匹配到,则将c节点对应的dom插入到未处理的节点(oldStartVnode.elm)的前面,移动新头指针,同时将旧数组中对应节点设为undefined

2-3.png

接下来新头旧头匹配成功,不进行操作,移动新头指针与新尾指针

2-4.png

旧头指针指向为undefined,移动旧头指针,此时旧头旧尾已重合,新头与旧头再次匹配,新头旧头指针移动,不进行dom操作。

2-5.png

再次指针移动之后旧头指针大于旧尾指针,退出循环,添加从 新头到新尾之间的节点也就是E节点。

2-6.png

源码如下:

  function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    let oldStartIdx = 0 // 老 vnode 首指针
    let newStartIdx = 0 // 新 vnode 首指针
    let oldEndIdx = oldCh.length - 1 // 老 vnode 尾指针
    let oldStartVnode = oldCh[0] // 老 vnode 列表第一个子元素
    let oldEndVnode = oldCh[oldEndIdx] // 老 vnode 列表最后一个子元素
    let newEndIdx = newCh.length - 1 // 新 vnode 列表尾指针
    let newStartVnode = newCh[0] // 新 vnode 列表第一个子元素
    let newEndVnode = newCh[newEndIdx] // 新 vnode 列表最后一个子元素
    let oldKeyToIdx, idxInOld, vnodeToMove, refElm

    const canMove = !removeOnly
    
    // 循环,规则是开始指针向右移动,结束指针向左移动移动
    // 当开始和结束的指针重合的时候就结束循环
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (isUndef(oldStartVnode)) {
        oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
      } else if (isUndef(oldEndVnode)) {
        oldEndVnode = oldCh[--oldEndIdx]
        
        // 老开始和新开始对比
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        // 是同一节点 递归调用 继续对比这两个节点的内容和子节点
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        // 然后把指针后移一位,从前往后依次对比
        // 比如第一次对比两个列表的[0],然后比[1]...,后面同理
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
        
        // 老结束和新结束对比
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        // 然后把指针前移一位,从后往前比
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
        
        // 老开始和新结束对比
      } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
        // 老的列表从前往后取值,新的列表从后往前取值,然后对比
        oldStartVnode = oldCh[++oldStartIdx]
        newEndVnode = newCh[--newEndIdx]
        
        // 老结束和新开始对比
      } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        // 老的列表从后往前取值,新的列表从前往后取值,然后对比
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
        
        // 以上四种情况都没有命中的情况
      } else {
        if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        // 拿到新开始的 key,在老的 children 里去找有没有某个节点有这个 key
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
          
        // 新的 children 里有,可是没有在老的 children 里找到对应的元素
        if (isUndef(idxInOld)) {
          /// 就创建新的元素,并且插入到未处理的节点(oldStartVnode.elm)的前面
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        } else {
          // 在老的 children 里找到了对应的元素
          vnodeToMove = oldCh[idxInOld]
          // 判断标签如果是一样的
          if (sameVnode(vnodeToMove, newStartVnode)) {
            // 就把两个相同的节点做一个更新
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
            oldCh[idxInOld] = undefined
            canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
          } else {
            // 如果标签是不一样的,就创建新的元素
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
          }
        }
        newStartVnode = newCh[++newStartIdx]
      }
    }
    // oldStartIdx > oldEndIdx 说明老的 vnode 先遍历完
    if (oldStartIdx > oldEndIdx) {
      // 就添加从 newStartIdx 到 newEndIdx 之间的节点
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    
    // 否则就说明新的 vnode 先遍历完
    } else if (newStartIdx > newEndIdx) {
      // 就删除掉老的 vnode 里没有遍历的节点
      removeVnodes(oldCh, oldStartIdx, oldEndIdx)
    }
  }
复制代码

vue3

vue3使用了快速diff算法,核心方法是patchKeyedChildren,首先是进行预处理操作,首尾指针分别处理新旧两个组子节点中相同的前置节点和后置节点,但与vue2不同的是不会再进行旧首新尾与旧尾新首的比对。
处理完成后,如果旧组与新组都存在未处理的元素,则需要根据节点的索引关系,构建出一个最长递增子序列。最长递增子序列所指向的节点即为不需要移动的节点。关于最长递增子序列如何计算可以查看文章
首先是构建source数组用于存放新的一组子节点每个节点在老的一组中存在的原来位置,source数组初始化时以0填充。使用新组创建一个map,遍历老数组以查找新组元素的位置,查找到则+1后存入source数组中,未查找到则删除该元素。
注意,在遍历老数组时不用全部遍历,维护一个patched变量,每查找到一个新组元素则patched+1,当patched的值与新数组的长度一样的时候,则可以将老数组中剩下的部分进行删除。
接着是查找source数组中的最长递增子序列,vue3采用的diff算法判断是否移动的思想也是采用递增法,当source数组中的值为递增之时,说明这些节点在新老虚拟dom中的顺序一致,则不需要移动。找出source中最长严格递增子序列之后,即最大程度的减少了移动的节点数量,只需要倒序遍历,移动剩下的节点即可。

图解如下:

3-1.png

首先进行头头比较与尾尾比较,匹配到A,新头旧头指针移动,其余部分未曾匹配,创建长度为4的source数组,同时使用新数组创建map,遍历老数组获取索引值。

3-2.png

最长递增子序列为[3,4],也就是C、D节点保持不变,其他节点移动,从后向前遍历即是首先移动B节点至最后,D、C二节点保持不变,E节点新增,完成diff。

除了负责部分的处理之外,vue3还新增了静态标记,也就是在生成VNode的时候,同时打上标记,patch过程中就会判断这个标记来 Diff优化流程,跳过一些静态节点对比。

总结

通过对比我们可以看出,React与Vue在虚拟DOM以及diff算法上的思想是相近的,都是通过虚拟DOM记录dom节点的变化,再通过diff算法寻找dom最小改变进行更新渲染。不过React与vue的diff算法有较大的不同,主要体现在:

  • 静态节点处理。Vue2在diff的过程中跳过静态节点,Vue3静态节点提升之后不会进入diff过程,而React由于是通过jsx进行编译,过于灵活的jsx导致React无法进行静态节点分析。

  • 双端对比算法。Vue采用了双端对比算法,而React的Fiber树由于是单向链表的结构,所以暂时无法实现双端对比。

  • 异步更新。React16之后采用Fiber结构,对比与更新都是异步进行,而Vue在比对过程中是立即执行更新操作的。

作者:kk549
链接:juejin.cn/post/715875…
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。