2-6 React 渲染流程

Q: 是否了解过 React 整体的渲染流程?主要有哪些阶段?

可以从现代框架的公式 UI = f(state) 来展开,这个公式可以进行一个拆分:

  • 根据自变量(state)的变化计算出 UI 的变化。
  • 根据 UI 的变化执行具体宿主环境的 API,在浏览器环境中,主要是 DOM 操作。

对应的公式如下:

const state = reconcile(update); // 通过 reconcile 计算出最新的状态
const UI = commit(state); // 根据上一步计算出来的 state 渲染出 UI

对应到 React 里面就有两大阶段:

  • render 阶段:调和虚拟 DOM,并计算出最终要渲染出来的虚拟DOM。
  • commit 阶段:根据 render 阶段计算出来的虚拟 DOM,进行 DOM 操作,渲染出具体的 UI 界面。

而每个阶段又对应不同的组件:

2023-02-23-101849

其中 render 阶段包含调度器 Scheduler 和 协调器 Reconciler,commit 阶段包含渲染器 Renderer。

  • 调度器 Scheduler: 负责调度任务的优先级,为任务排序优先级,高优先级的任务先进入协调器 Reconciler;
  • 协调器 Reconciler:负责计算 UI 的变化,也就是计算虚拟 DOM 树,这个阶段会生成 Fiber 对象,收集副作用,找出哪些节点发生了变化,打上不同的 flags ,著名的 diff 算法也是在这个组件中生成;
  • 渲染器 Renderer:根据协调器计算出来的虚拟 DOM 同步的渲染节点到视图上。

接下来我们来看一个例子:

export default () => {
  const [count, setCount] = useState(0);

  return (
    <ul>
      <button onClick={() => setCount(count + 1)}>乘以{count}</button>
      <li>{1 * count}</li>
      <li>{2 * count}</li>
      <li>{3 * count}</li>
    </ul>
  );
};

当用户点击按钮的时候,首先由 Scheduler 进行任务的调度, render 阶段(虚线框内)的工作流程是可以随时被以下原因中断的:

  • 有其他更高优先级的任务需要被处理;
  • 当前的时间切片没有剩余时间了;
  • 发生了其他错误;

主要上面 rendr 阶段的工作是在内存中进行的,不会更新宿主环境 UI,因此这个阶段的工作流程即使反复被中断,用户也不会看到“更新不完整”的 UI。

当调度器 Scheduler 调度完成后,将任务交给协调器 Reconciler,协调器 Reconciler 就会计算出新的 UI,最后由渲染器 Renderer 将新的 UI 同步到宿主环境,也就是进行渲染更新操作。

如下图所示:

2023-02-23-103449

关于调度器 Scheduler,在 React V16 版本之前,采用的是 Stack 架构,所有任务只能同步进行,无法被打断,这就导致浏览器可能会出现丢帧现象,表现出卡顿。 React 为了解决这个问题,从 V16 版本开始从架构上进行了重构,进行了两大更新:

  • 引入 Fiber;
  • 新增了 Scheduler 调度器。

Scheduler 在浏览器原生 API 中实际上有类似的实现的,这个 API 就是 requestIdleCallback,这个 API 的作用是当浏览器有空闲时间的时候,才会执行回调函数。

虽然每一帧的绘制时间是 16.67ms, 但是屏幕没有刷新,那么浏览器会安排时间长度为 50ms 的空闲时间。

这里就会有人问到,为什么是 50ms 呢? 根据研究表明,用户操作之后,100ms 以内的响应给用户的感觉都是瞬间发生的,也就说不会感到低延迟。因此将空闲时间设置为 50ms,浏览器依然还剩下 50ms 的时间可以用来处理用户的操作响应,不会让用户感到延迟。

但是 requestIdleCallback 这个 API 在实际使用中,存在一些问题:

  • 浏览器兼容性问题:目前只有 Chrome 和 Firefox 支持 requestIdleCallback,其他浏览器都不支持;
  • requestIdleCallback 的调用频率不稳定:requestIdleCallback 的调用频率不稳定,可能会出现调用频率太低,导致空闲时间过长,或者调用频率太高,导致空闲时间过短,从而影响性能。

因此 React 团队自己实现了一个 Scheduler 调度器,后期 React 团队打算单独发现这个 Scheduler 调度器,作为一个独立的库,这意味着调度器不仅仅只能在 React 中使用,凡事涉及到任务调度需求的项目都可以使用 Scheduler 调度器。

render 阶段的第二个阶段的工作是 Reconciler 协调器来完成的,类组件或者函数组件本身就是在这个阶段被调用的。根据 Scheduler 调度结果的不同,协调器初始的工作可能是不同的:

  • performSyncWorkOnWork:同步更新流程;
  • performConcurrentWorkOnRoot: 并发更新流程;

这两个流程的源码如下

// performSyncWorkOnWork 会执行该方法
function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

// performConcurrentWorkOnRoot 会执行该方法
function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

function shouldYield() {
  // 当前时间是否大于过期时间
}

新的架构中,使用 Fiber 对象来描述 DOM 结构,最终要形成一颗 Fiber Tree,只不过这棵树是通过链表的形式串联在一起的。 workInProgress 代表的是当前的 FiberNode。 performUnitOfWork 方法会创建下一个 FiberNode,并且还会将已创建的 FiberNode 连接起来(通过 child、sibling、return ),最终形成一颗链表结构的 Fiber Tree。

如果 workInProgress 为 null,说明已经没有下一个 FiberNode,也就是说整个 Fiber Tree 已经构建完成。

上面两个方法唯一的区别在于是否调用了 shouldYield() 方法,该方法表明了是否可以中断当前的任务,如果可以中断,那么就会返回 true,否则返回 false。

对于 performUnitOfWork 方法,在创建下一个 FiberNode 的时候,整体上的工作又可以分为两大块:

  • 递阶段
  • 归阶段

递阶段会从 HostRootFiber 开始向下以深度优先遍历的方式遍历整棵 Fiber Tree,遍历到每一个 FiberNode 执行 beginWork 方法。该方法会根据传入的 FiberNode 创建下一级的 FiberNode, 此时可能存在两种情况:

  • 下一级只有一个元素,beginWork 方法会创建对应的 FiberNode,并与 workInProgress 连接。
<ul>
  <li></li>
</ul>

这里就会创建 li 对应的 FiberNode,做出如下的连接:

LiFiber.return = UlFiber;
  • 下一级有多个元素,此时 beginWork 方法会依次创建所有的子 FiberNode 节点并通过 sibling 连接在一起,每个子 FiberNode 也会和 wokrInProgress 连接。
<ul>
  <li></li>
  <li></li>
  <li></li>
</ul>

这里就会创建三个 li 对应的 FiberNode,连接情况如下:

// 所有的子 Fiber 依次连接
Li0Fiber.sibling = Li1Fiber;
Li1Fiber.sibling = Li2Fiber;

// 所有的子 Fiber 还需要和父 Fiber 连接
Li0Fiber.return = UlFiber;
Li1Fiber.return = UlFiber;
Li2Fiber.return = UlFiber;

由于采用的是深度优先遍历,因此当遍历到无法再往下走的时候,即遍历到叶子节点的时候,就会开始向上返回,也就是执行归阶段。

归阶段会调用 completeWork 方法来处理 FiberNode,做一些副作用的收集工作。当某个 FiberNode 执行完了 completeWork 方法,如果存在兄弟元素,就会进入到兄弟元素的递阶段,对其执行 beginWork 方法;如果不存在兄弟元素,就会执行其父元素的归阶段,也就是执行父元素的 completeWork 方法。

其源码结构如下:

function performUnitOfWork(FiberNode) {
  // 省略 beginWork
  if (FiberNode.child) {
    performUnitOfWork(FiberNode.child);
  }
  // 省略 completeWork
  if (FiberNode.sibling) {
    performUnitOfWork(FiberNode.sibling);
  }
}

最后我们来看一张图,其中标注了 beginWork 和 completeWork 的执行顺序:

2023-02-24-031518

最后对于渲染器 renderer 来说,其工作就是将 Fiber Tree 转换为真实 DOM,并且将真实 DOM 挂载到页面上。主要发生在 Commit 阶段,该阶段会将各种副作用提交到宿主环境的 UI 中。相较于之前的 render 阶段可以被打断, commit 阶段一旦开始就会同步执行,直到完成整个渲染工作。

整个渲染器渲染过程中由可以被分为三个阶段:

  • BeforeMutation 阶段;
  • Mutation 阶段;
  • Layout 阶段。

这个阶段执行的流程图如下:

2023-03-02-090354

最后总结一下,React 的渲染过程主要分为两个大阶段,分别是 render 阶段和 commit 阶段。 其中 render 阶段又可以分为调度阶段和协调阶段, 该过程是在内存中处理的,是异步可中断的。而 commit 阶段会由渲染器进行处理,根据收集到的副作用进行 UI 的更新,该过程是同步不可中断的,否则会导致 UI 和数据显示的不一致。

具体到调度器、协调器和渲染器,其工作流程如下:

调度器的主要工作就是调度任务,让所有的任务具有优先级的概念,这样的话紧急的任务可以优先被执行。Scheduler 实际上在浏览器也有类似的原生 API 可以实现,这个 API 就是 requestIdleCallback,但是这个 API 存在兼容性问题,因此 React 团队放弃了这个 API,而是自己实现了一套这样的机制,并且后期会把 Scheduler 单独抽离出来作为一个独立的库,这意味着 Scheduler 不仅仅在 React 中可以使用,但凡涉及到任务调度需求的项目中都可以使用这个 Scheduler 调度器。

协调器参与的是 render 第二阶段工作,该阶段会采用深度优先遍历的原则遍历并创建一个一个的 FiberNode,并将其串联在一起。在遍历是分为“递”和“归”两个阶段。其中在“递”阶段会执行 beginWork 方法,该方法会根据传入的 FiberNode 创建下一级的 FiberNode。而在“归”阶段会执行 completeWork 方法,该方法会收集副作用。

最后是渲染器的工作,主要是将各种用 flags 表示的副作用提交到宿主环境的 UI 中。整个阶段又分为三个子阶段,分别是 BeforeMutation、Mutation 和 Layout 阶段。