2-5 React 整体架构

Q: 是否了解过 React 架构?新的 Fiber 架构相较于之前 Stack 架构有什么优势?

A: Stack 架构在进行虚拟 DOM 树进行比较的的时候,采用的是递归遍历,计算会消耗大量的时间。新的 Fiber 架构采用的是链表,可以实现时间切片,防止 JS 的计算占用过多的时间从而导致浏览器出现丢帧的现象。

React V15 以及之前的架构称之为 Stack 架构,从 React V16 开始,React 重构了整体的架构,新的架构称之为 Fiber 架构。谈到新的 Fiber 架构,不得不从两个方面来说,一方面是旧架构存在的问题,另一个方面是新架构解决问题的思路。

旧架构存在的问题

在以前的 React 官网首页曾有一句话:React 是用 JavaScript 构建快速响应的大型 Web 应用程序的首选方式。然而有些时候,会导致我们的 Web 应用程序无法快速响应?总结其原因,实际上会有两个场景限制快速响应:

  • 当你需要执行大量计算或者设备本省的性能不满足的时候,页面就会出现丢帧卡顿的现象,这个本质上是源于 CPU 的瓶颈。
  • 当需要进行 I/O的时候,你必须等待网络返回数据之后才能继续后续的操作,在这个等待的过程中,无法做到快速响应。

首先说 CPU 瓶颈,我们平时在浏览网页的时候,网页都是有浏览器绘制出来的,它像一个画家画画一样,从左到右,从上到下,一层一层地绘制。网页中那些动起来的特效,如轮播图、百叶窗之类的,本质上就是浏览器在不停地进行绘制。

目前大多数设备的屏幕刷新频率是 60Hz,也就是每秒刷新 60 次,那么每一帧的渲染时间就是 16.67ms。如果每一帧的渲染时间超过 16.67ms,那么用户就会感觉到卡顿。如果每一帧的渲染时间小于 16.67ms,那么用户就会感觉到流畅。

浏览器在绘制每一帧页面的时候,实际上还有很多事情要做,如解析 HTML、CSS、计算 DOM 样式、布局、绘制等。这些任务被称之为“渲染流水线”,每次执行渲染流水线的时候,一般都需要这些任务,但并不是每一次都要执行所有的任务。

  • 当 JS 或者 CSS 修改 DOM 元素的几何属性(如 width、height、left、top 等)的时候,就会触发完整的渲染流水线,这种情况称之为重排,也就是我们常说的回流。
  • 当修改的属性不涉及到几何信息,仅包含字体、颜色的改变时,浏览器会跳过布局过程,直接进入绘制阶段,这种情况称之为重绘。
  • 当修改不涉及到重排或重绘的属性时,比如修改 tranform 属性,浏览器会跳过布局和绘制,直接进入合成阶段,仅执行合成线程的绘制工作,这种情况称之为合成。

以上三种情况按照性能高低进行排序:合成 > 重绘 > 重排。前面说过,浏览器绘制的频率是 16.67ms 一帧,但是执行 JS 与渲染流水线实际上是在同一个线程上面执行的。当 JS 执行时间过长,就会导致渲染流水线无法在 16.67ms 内完成,不能够及时的渲染出下一帧,也就意味着页面掉帧,表现出来的现象就是页面出现卡顿。

在 React V16之前就存在这个问题, JS 执行的时间过长。在 React 中,需要去计算整棵虚拟 DOM 树。虽然相比与直接操作 DOM,JS 层面的计算节省了不少时间,但是当虚拟 DOM 树非常庞大的时候,每次更新都需要重新计算整棵虚拟 DOM 树,会造成每一帧的 JS 代码执行时间过长,从而导致动画、还有一些实时的交互和更新无法得到及时的响应,造成渲染丢帧,页面卡顿的情况。

加入有如下的 DOM 层次结构:

2023-02-23-072638

那么转换成虚拟 DOM 之后,会变成如下的 JS 对象:

{
   type: "div",
   props: {
    id: "test",
    children:[
      {
      type:"h1",
      props: {
        children: "This is a title"
      }
    }, {
      type: "p",
      props: {
        children: "This is a paragraph"
      }
    }, {
      type:"ul",
      props: {
        children: [{
          type: "li",
          props: {
            children: "list item 1"
          }
        }, {
          type: "li",
          props: {
            children: "list item 2"
          }
          }, {
          type: "li",
          props: {
            children: "list item 3"
          }
          }
        ]
    }
    }]
   }
}

在 React V16 之前,进行两棵虚拟 DOM 树的对比时,要涉及到遍历上面的树结构。这个时候,只能使用递归的方式进行遍历,而且这种递归遍历是无法被打断的,一旦开始就无法停止,直到遍历完成。如果虚拟 DOM 树非常庞大,那么递归遍历的时间就会非常长,JS 的执行时间也会特别长。这种架构模式也被称之为 Stack 架构,因为采用的是递归,会不断地开启新的函数调用栈。官方也提供了一张 Stack 架构下 JS 执行递归遍历耗时的示意图:

2022-12-27-070133

针对 IO 尤其是网络延时造成的瓶颈,这也是前端开发过程中经常遇到的问题。针对网络延迟这种客观上会存在的现象,我们无法避免,但是我们能够采用哪些手段可以降低这种现象对用户的影响呢?React 官方团队给出的答案是:将人机交互的研究成果整合到 UI 中。 这个成果的主要观点是:用户对于卡顿的感知是不同的,例如输入框的输入,哪怕只有轻微的延迟,用户也能感知到,认为很卡;而对于有个有大量数据的展示列表,哪怕在展示之前有几秒钟的 loading,用户也不会觉得卡顿。

对于 React 来说,所有的操作都来自于 state 状态这个自变量的变化所导致的重新渲染,我们只需要对不同的操作赋予不同的优先级即可。具体来说,主要有以下三点:

  • 为不同的操作赋予不同的优先级,例如用户输入的优先级最高,而数据请求的优先级相对较低;
  • 统一调度所有的优先级,优先处理高优先级的更新;
  • 在更新的过程中,如果此时有更高优先级的更新到来,那么就中断当前的更新,转而处理更高优先级的更新。

要实现上面三点,就需要 React 在底层实现一个调度器,这个调度器能够设置更新任务的优先级,还要搭配对应的调度算法,同时支持可中断的虚拟 DOM 的实现。所以无论是解决 CPU 瓶颈,还是解决 IO 瓶颈,React 底层的诉求都是要实现时间切片,也就是 Time Slice。

新架构解决问题的思路

首先是解决 CPU 瓶颈,从 React V16 开始,官方团队正式引入了 Fiber 的概念, 这是一种通过链表来描述 UI 的方式,本质上你也可以将其理解为一种虚拟 DOM的实现。在官方的文章中提到过:与其将 “Vitual DOM” 视为一种技术,不如说它是一种模式,人们提到它的时候往往是要表达不同的东西。在 React 的世界中,“Vitual DOM” 通常与 React 元素关联在一起,因为它们都是表示用户界面的 JS 对象。而 React 内部也通过一个叫做 "fiber" 的对象来存放组件树上的附加信息。React 元素和 Fiber 对象共同构成了虚拟 DOM 的核心。

从上面的介绍可以看出 Fiber 的本质也是一个对象,但是和之前 React 元素不同的地方在于,Fiber 对象之间使用链表的结构进行连接,其中 child 指向子元素,sibling 指向兄弟元素,parent 指向父元素。其结构如下图所示:

2023-02-24-032509

使用链表这种数据结构,有一个最大的好处就是在进行整棵树对比计算的时候,也就是 reconcile 的时候,这个过程是可以被打断的。在发现一帧的时间已经不够,不能够再执行 JS 代码,需要渲染下一帧的时候,React 会中断当前的 JS 执行,优先渲染下一帧。等待渲染完成后再接着回来完成上一次没有完成的 JS 计算。

官方还提供了新的 Fiber 架构下 JS 执行递归遍历耗时的示意图:

2022-12-27-070226

下面是 React 源码中创建 Fiber 对象的相关代码:

const createFiber = function (tag, pendingProps, key, mode) {
  // 创建 Fiber 对象
  return new FiberNode(tag, pendingProps, key, mode);
};

function FiberNode(tag, pendingProps, key, mode) {
  // Instance
  this.tag = tag; // Fiber 类型
  this.key = key; // key 属性
  this.elementType = null; // 元素类型
  this.type = null; // Fiber 类型
  this.stateNode = null; // Fiber 对应的真实 DOM

  // Fiber
  // 上下、前后 fiber 通过链表的形式进行连接
  this.return = null; // 父级 Fiber
  this.child = null; // 子级 Fiber
  this.sibling = null; // 兄弟 Fiber
  this.index = 0; // 索引

  // ref
  this.ref = null; // ref 属性
  this.refCleanup = null; // ref 清理

  // 和 hook 相关
  this.pendingProps = pendingProps; // 挂载的属性
  this.memoizedProps = null; // 上一次更新的属性
  this.updateQueue = null; // 更新队列
  this.memoizedState = null; // 上一次更新的状态
  this.dependencies = null; // 依赖

  this.mode = mode;

  // Effects
  this.flags = NoFlags;
  this.subtreeFlags = NoFlags;
  this.deletions = null;

  this.lanes = NoLanes; // 优先级
  this.childLanes = NoLanes; // 子节点的优先级

  this.alternate = null; // 双缓存 Fiber
}

其次是解决 I/O 瓶颈的问题,从 React V16 引入了 Scheduler,也就是我们常说的调度器,它的作用主要是用来调度任务的优先级。根据之前我们的视图渲染公式 UI = f(state),可以分为两步,第一步是:根据自变量 state 的变化计算出 UI 的变化, 第二步是:根据 UI 的变化执行具体宿主环境的 API,在浏览器环境中,主要是 DOM 操作。

在 React V16 之前,主要通过 Reconciler 协调器和 Renderer 渲染器来完成上面的两步。

  • Reconciler 协调器:负责计算 UI 的变化,也就是计算虚拟 DOM 树;
  • Renderer 渲染器:负责执行宿主环境的 API,也就是执行 DOM 操作。

在 React V16 之后,在 Reconciler 协调器之前加入了 Scheduler 调度器。

  • Scheduler 调度器: 调度任务的优先级,高优先级的任务先进入 Reconciler 协调器;
  • Reconciler 协调器:负责计算 UI 的变化,也就是计算虚拟 DOM 树;
  • Renderer 渲染器:负责执行宿主环境的 API,也就是执行 DOM 操作。

新的 Fiber 架构中,Reconciler 的更新流程也从之前的递归变成了“可中断的循环过程”。其源码如下:

function workLoopConcurrent() {
  // 如果还有任务,且时间切片还有剩余时间
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

function shouldYield() {
  // 当前时间是否大于过期时间
  // 其中 deadline = getCurrentTime() + yieldInterval
  // yieldInterval 为调度器预设的时间间隔 默认是 5ms
  return getCurrentTime() >= deadline;
}

此外,每次循环都用调用 shouldYield() 方法来判断当前的时间切片是否还有剩余时间,没有没有足够的剩余时间,就暂停 reconciler 的执行,将主线程还给渲染流水线,进行下一帧的渲染。渲染完成后,再等待下一个时间切片的到来,继续执行 reconciler 的任务。

最后总结,React 15及其之前的架构:

  • Reconciler 协调器:负责计算 UI 的变化,也就是计算虚拟 DOM 树;
  • Renderer 渲染器:负责执行宿主环境的 API,也就是执行 DOM 操作。

这种架构称之为 Stack 架构,在 Reconciler 中,mount 的组件会调用 mountComponent,update 的组件会调用 updateComponent。这两个方法都会递归更新子组件,更新一旦开始,就无法中断。但是随着应用规模的不断增大,之前的架构模式无法再满足快速响应这一需求。主要受限于以下两个方面:

  • CPU 瓶颈:由于 VDOM 在进行差异对比时,采用是递归的方式,JS 计算会消耗大量的时间,从而导致动画、还有一些需要实时更新的内容产生视觉上的卡顿。
  • IO 瓶颈:由于各种基于自变量的变化而产生的更新任务没有优先级的概念,因此某些更新任务(如文本输入框的输入)有稍微的延迟,也会造成用户的敏感,让用户产生卡顿的感觉。

而 React 16及其之后的架构,称之为 Fiber 架构,其架构如下:

  • Scheduler 调度器: 调度任务的优先级,高优先级的任务先进入 Reconciler 协调器;
  • Reconciler 协调器:负责计算 UI 的变化,也就是计算虚拟 DOM 树;
  • Renderer 渲染器:负责执行宿主环境的 API,也就是执行 DOM 操作。

首先引入 Fiber 的概念,通过一个对象来描述 VDOM 节点,但和之前的方案不同的是,每个 Fiber 节点之间通过链表的方式进行串联,通过 child 来指向子节点,通过 sibling 指向兄弟节点,通过 return 指向父节点。这种链表结构,使得在执行过程中,能够随时中断,然后重新开始,从而实现时间切片。

此外在新架构中,Reconciler 协调器中的更新流程从递归变成了“可中断的循环过程”。每次循环都会调用 shouldYield() 方法来判断当前的时间切片是否还有剩余时间,如果没有足够的剩余时间则暂停更新流程,将主线程归还给渲染流水线,等待下一个宏任务再继续执行。这样就解决 CPU 的瓶颈问题。

其次,新架构中还引入了Scheduler 调度器, 在 Scheduler 调度器中,引入了任务的优先级概念,根据任务的优先级来决定任务的调度顺序。高优先级的任务会优先进入 Reconciler 协调器,从而解决 IO 的瓶颈问题。