浅析React中的domDiff

目前主流的前端框架Vue和React,都引入了虚拟DOM的机制,这不可避免的涉及到从虚拟DOM和真实DOM的对比过程,寻找差异后更新到真实DOM树,完成视图的渲染。 React的设计更加的复杂,它不仅有虚拟DOM,为了提升渲染性能,还引入了Fiber架构模式,这让它的Diff过程比较独特。

Diff的策略

Diff的过程是,比较两颗结点树的差异,然后patch,以最小化的成本来更新视图。完全比较两颗树的差异算法是O(n^3)的时间复杂度。假如页面上有1000个结点,消耗的性能非常惊人。

每个结点都去和另一棵树上的结点,逐一比较一次,再加上更新的操作。

js 复制代码
1000 ** 3 = 1,000,000,000 // 10亿

假如页面上的结点更多的话,每次更新一个数据状态,都要如此庞大的性能消耗,页面肯定会比较卡顿。为了减少结点Diff过程中的性能消耗,React框架在设计的时候,制定了3条策略。

  1. 只进行同级的结点比较,跨层级的结点不处理

默认页面上的绝大部分操作都是在同一级内展开的(同一个父级容器内),跨级操作的概率会比较低。

  1. 根据结点的type类型比较,遇到类型不同直接标记为删除

第三个结点,由li标签变成的p标签,这种情况会直接销毁li-c,重新创建成p标签

  1. 会根据结点key标识,来尽量复用结点

这样,新旧列表中的结点类型一致且都能找相同key的结点,只需要移动结点的位置即可。

通过上述这三种Diff策略的实施,将结点树的对比时间复杂度由 O(n^3) 降低到了 O(n)

React中Diff的本质

在介绍 React的结点Diff过程前,我们先熟悉下React的fiber架构

在上面图中所示,每个结点有三个指针,return 指向父级结点,sibling指向相邻的兄弟结点,child指向第一子结点。所有的fiber结点构成了,一个棵链表树。

在每次 setState 的过程中,React都会进行调和(reconcile)的过程,就是拿JSX生成ReactElement 和 上次更新时对应的 Fiber结点比较,这就是Diff的过程,将比较的结果生成新的Fiber结点。

一个DOM节点在某一时刻最多会有4个节点和他相关。

  • current Fiber。如果该DOM节点已在页面中,current Fiber代表该DOM节点对应的Fiber节点。
  • workInProgress Fiber。如果该DOM节点将在本次更新中渲染到页面中,workInProgress Fiber代表该DOM节点对应的Fiber节点。
  • DOM节点本身。
  • JSX对象。即ClassComponent的render方法的返回结果,或 FunctionComponent的调用结果。JSX对象中包含描述DOM节点的信息。

Diff的过程,就是 current Fiber结点 和 JSX对象比较生成 workInProgress Fiber,而真实的DOM结点的更新则发生在commit阶段。

Diff的入口函数 reconcileChildFibers 出发,该函数会根据newChild(即JSX对象)类型调用不同的处理函数。

js 复制代码
// 根据newChild类型选择不同diff函数处理
function reconcileChildFibers(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChild: any,
): Fiber | null {

  const isObject = typeof newChild === 'object' && newChild !== null;

  if (isObject) {
    // object类型,可能是 REACT_ELEMENT_TYPE 或 REACT_PORTAL_TYPE
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE:
        // 调用 reconcileSingleElement 处理
        // 单个 组件元素的处理
    }
  }

  if (typeof newChild === 'string' || typeof newChild === 'number') {
    // 调用 reconcileSingleTextNode 处理
    // 单个文本结点的处理
  }

  if (isArray(newChild)) {
    // 调用 reconcileChildrenArray 处理
    // 多个子节点的处理
  }

  // 一些其他情况调用处理函数

  // 以上都没有命中,删除节点
  return deleteRemainingChildren(returnFiber, currentFirstChild);
}

单结点Diff

单个结点的处理流程

  • 先判断是否存在上次的Fiber结点对应的DOM,不存在的话,直接新生成一个Fiber结点并返回
  • 存在的话,对比下DOM结点是否可复用,能复用的话,直接返回上次Fiber的副本
  • 假如不能复用,标记成 deletion,并新生成一个Fiber结点返回。
js 复制代码
function reconcileSingleElement(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  element: ReactElement
): Fiber {
  const key = element.key;
  let child = currentFirstChild;
  
  // 首先判断是否存在对应DOM节点
  while (child !== null) {
    // 上一次更新存在DOM节点,接下来判断是否可复用

    // 首先比较key是否相同
    if (child.key === key) {

      // key相同,接下来比较type是否相同

      switch (child.tag) {
        // ...省略case
        
        default: {
          if (child.elementType === element.type) {
            // type相同则表示可以复用
            // 返回复用的fiber
            return existing;
          }
          
          // type不同则跳出switch
          break;
        }
      }
      // 代码执行到这里代表:key相同但是type不同
      // 将该fiber及其兄弟fiber标记为删除
      deleteRemainingChildren(returnFiber, child);
      break;
    } else {
      // key不同,将该fiber标记为删除
      deleteChild(returnFiber, child);
    }
    child = child.sibling;
  }

  // 创建新Fiber,并返回 ...省略
}

在代码的实现逻辑中

  1. 先比较结点的key是否相同,不同则不能复用,标记成 删除 状态
  2. 结点key相同的前提下,再比较type类型,类型一致,则可复用
  3. 结点key相同的前提下,假如type类型不一致的话,将该结点和其兄弟fiber结点都标记成 删除 状态

举个例子:

html 复制代码
// before
section > div * 3
// after
section > h2

更新前是 三个div的子元素,更新后是一个h2标签,它们都同属一section容器。当h2的key已经匹配到第一个div时,此时由于div 和 h2是不同类型的结点, 既然h2 <--key匹配上--> div-1,那么后面的第二个和第三div已经没用了,且由于类型不同,会直接将这三个div全部标记删除状态。

当 h2 <--key没匹配上--> div-1,仅需要标记 div-1 删除状态即可,因为有可能 h2 <--key匹配上--> div-2 或者 h2 <--key匹配上--> div-3

当且仅当 key 和 type都相同时,才能复用结点,二者缺一不可。在该过程会优先使用key,key不同,则 type 就不会比较了,直接将结点标记 删除 状态

多结点Diff

当 reconcileChildFibers 方法中传递的 newChild参数类型为Array时,就开始多结点的Diff了。

html 复制代码
<ul>
  <li key="a">A</li>
  <li key="b">B</li>
  <li key="c">C</li>
  <li key="d">D</li>
</ul>

可能的变化如下几种:

  1. 结点更新

(key一致的前提下)属性信息 或 内容信息 或 结点类型变化了

html 复制代码
<ul>
  <!-- 增加了样式类名称【list-item】 -->
  <li key="a" class="list-item">A</li>
  <!-- 改变了内容信息: -->
  <li key="b">BBB</li>
  <li key="c">C</li>
  <!-- 标签类型变了 -->
  <p key="d">D</p>
</ul>
  1. 结点新增或减少
html 复制代码
<!-- 结点增加情况 -->
<ul>
  <li key="a">A</li>
  <li key="b">B</li>
  <li key="c">C</li>
  <li key="d">D</li>
  <li key="e">E</li>
</ul>

<!-- 结点减少情况 -->
<ul>
  <li key="a">A</li>
  <li key="b">B</li>
  <li key="c">C</li>
</ul>
  1. 结点位置的改变
html 复制代码
<ul>
  <li key="d">D</li>
  <li key="a">A</li>
  <li key="c">C</li>
  <li key="b">B</li>
</ul>

多结点的Diff比较,就在上面三种情况中。

Diff的总体思路

由于 Fiber 结点是链表树的结构,而传递的 newChild 是JSXElement 类型,可以理解一个一维的数组结构。两者没法通过双端指针的比较方式,采用了两轮的比较策略。

  • 第一轮,发现更新情况的结点,并标记处理
  • 第二轮,处理非 更新情况的结点

第一轮,分别从 oldFiber的起始位置 和 newChild的索引为0的位置,逐个比较。

发现发现可以复用的结点,i 和 start 分别向右挪动一个位置,直到 newChild 或 oldFiber 最后。

如果不能复用的话:

  • key不同导致的无法复用,直接结束第一轮遍历
  • key相同type不同导致的无法复用,会将 oldFiber 中的结点标记 删除状态,继续遍历

第一轮结束后,会有如下几种情况:

  1. oldFiber 和 newChild 都遍历完了,那么Diff过程结束
  2. oldFiber 没有遍历完,newChild 遍历完了,说明有 旧的结点会被删除
  3. oldFiber 遍历完,newChild 没有遍历完了,说明有 新的结点会被插入
  4. oldFiber 和 newChild 都没有遍历完,说明结点的位置有改动

第二轮遍历:

  • newChildren与oldFiber同时遍历完,最理想的情况,Diff结束
  • oldFiber 没有遍历完,newChild 遍历完了,需要遍历剩下的oldFiber,依次标记Deletion
  • oldFiber 遍历完,newChild 没有遍历完了,遍历剩下的newChildren为生成的workInProgress fiber依次标记 Placement
  • oldFiber 和 newChild 都没有遍历完,处理移动的结点

处理移动的结点

总体思路:

  1. 首先将 oldFiber 映射成 { key => FiberNode }的哈希Map,便于在遍历 newChild 时快速定位上
  2. 声明一个可以复用结点的最大索引位置 lastPlacedIndex,作为参照物,每次遍历 newChild 时 迭代更新
  3. 遍历 newChild 并找到其在 oldFiber 中的索引位置,记录为 currentIndex,拿 currentIndex 和 lastPlacedIndex。如果 currentIndex > lastPlacedIndex,则将 lastPlacedIndex = currentIndex,不断更新lastPlacedIndex的值,否则维持 lastPlacedIndex 不变

示例一

假如更新前是a b c d的结点顺序,更新后是 a c d b 的顺序

第一轮结束后,lastPlacedIndex = 0,开启第二轮针对 newChild剩余结点的遍历。此时

js 复制代码
oldFiber: [a, b, c, d]
newChild: [a, → c, d, b]
  • c在oldFiber中的索引是 2,即 currentIndex = 2
  • 由于 currentIndex > lastPlacedIndex,意味着 c 结点不需要移动
  • 重置参照物结点的索引:lastPlacedIndex = currentIndex = 2

进行 newChild中 下一个结点 d的处理

js 复制代码
oldFiber: [a, b, c, d]
newChild: [a, c, → d, b]
  • d在oldFiber中的索引是 3,即 currentIndex = 3
  • 由于 currentIndex > lastPlacedIndex,意味着 d 结点不需要移动
  • 重置参照物结点的索引:lastPlacedIndex = currentIndex = 3

进行 newChild中 下一个结点 b的处理

js 复制代码
oldFiber: [a, b, c, d]
newChild: [a, c, d, → b]
  • b 在oldFiber中的索引是 1,即 currentIndex = 1
  • 上一次 lastPlacedIndex 是 3
  • 由于 currentIndex < lastPlacedIndex,意味着 b 结点需要移动
  • 参照物结点的索引,lastPlacedIndex 保持 3不变

最终 a c d 三个结点没有移动,只有 b 结点标记为移动状态

示例二

假如更新前是a b c d的结点顺序,更新后是 d a b c 的顺序

第一轮结束后,没有结点可复用,开启第二轮针对 newChild剩余结点的遍历。首先处理 d 结点

js 复制代码
oldFiber: [a, b, c, d]
newChild: [ → d, a, b, c]
  • d在oldFiber中的索引是 3,即 currentIndex = 3
  • 由于 currentIndex > lastPlacedIndex,意味着 d 结点不需要移动
  • 重置参照物结点的索引:lastPlacedIndex = currentIndex = 3

进行 newChild中 下一个结点 a 的处理

js 复制代码
oldFiber: [a, b, c, d]
newChild: [d, → a, b, c]
  • a 在oldFiber中的索引是 0,即 currentIndex = 0
  • 上一次 lastPlacedIndex 是 3
  • 由于 currentIndex < lastPlacedIndex,意味着 a 结点需要移动
  • 参照物结点的索引,lastPlacedIndex 保持 3不变

进行 newChild中 下一个结点 b 的处理

js 复制代码
oldFiber: [a, b, c, d]
newChild: [d, a, → b, c]
  • b 在oldFiber中的索引是 1,即 currentIndex = 1
  • 上一次 lastPlacedIndex 是 3
  • 由于 currentIndex < lastPlacedIndex,意味着 b 结点需要移动
  • 参照物结点的索引,lastPlacedIndex 保持 3不变

进行 newChild中 下一个结点 c 的处理

js 复制代码
oldFiber: [a, b, c, d]
newChild: [d, a, b, → c]
  • c 在oldFiber中的索引是 2,即 currentIndex = 2
  • 上一次 lastPlacedIndex 是 3
  • 由于 currentIndex < lastPlacedIndex,意味着 c 结点需要移动
  • 参照物结点的索引,lastPlacedIndex 保持 3不变

至此比较结束,d 结点不需要移动,而 a b c 三个结点都需要移动

总结

  • a, b, c, d\] =\> \[a, c, d, b\] 需要移动 b 一个结点

从两个示例中可以看出,应尽量减少后面的结点移动到前面,来提高性能。

相关推荐
N_NAN_N1 小时前
类图+案例+代码详解:软件设计模式----原型模式
java·设计模式·原型模式
马特说2 小时前
React金融数据分析应用性能优化实战:借助AI辅助解决18万数据量栈溢出Bug
react.js·金融·数据分析
归于尽2 小时前
useEffect玩转React Hooks生命周期
前端·react.js
G等你下课2 小时前
React useEffect 详解与运用
前端·react.js
缘来是庄2 小时前
设计模式之组合模式
java·设计模式·组合模式
DKPT2 小时前
Java组合模式实现方式与测试方法
java·笔记·学习·设计模式·组合模式
鼠鼠我呀22 小时前
【设计模式09】组合模式
设计模式·组合模式
中微子2 小时前
React Props 传值规范详解
前端·react.js
卸任2 小时前
性能优化大作战:React.memo 在可编辑列表中的奇效
前端·javascript·react.js
LeeAt3 小时前
React Hooks 编程:useState和useEffect的详解
前端·react.js