【React】Fiber 实现可中断的渲染

2,806 阅读6分钟

《Concurrent 的奥秘》中,我们已经了解到,React 利用并发特性可以为应用构建更好的用户体验。而 Concurrent 的背后是:可被中断的 “渲染”区分优先级的更新 两项核心设计思路。

那么今天就来探究一下 React 是如何实现可中断 “渲染” 的。

什么是可中断的“渲染”

参照我们在《Concurrent 的奥秘》中的同步渲染并发渲染的例子:

上图是同步渲染过程。

上图是并发渲染过程。

我们可以看到明显的区别:

  • 同步渲染:就是完整地执行了一个很耗时的渲染

  • 并发渲染:将原本耗时的 “渲染”,拆解成了很多个 “微任务” 去执行,同时可以在期间穿插执行其他更高优先级的 “渲染”

其实 React 的设计思路为:当我们更新应用状态的时候,可以赋予他们不同的优先级,React 就可以依据优先级顺序来进行 “渲染” 工作,而实现一种 “局部优先渲染” 的效果。

那要实现这个思路,很关键的一点是:高优先级的 “渲染” 就要被优先保证。

其中就包括:即便已经在进行低优先级的 “渲染” 了,也要可以中断它,来进行高优先级的 “渲染”

时间切片

再回看我们《Concurrent 的奥秘》并发渲染的例子:

低优先级的 “渲染” (绿色)是被拆成了很多 “微任务” 的方式在执行。期间,还会被不断穿插执行高优先级的 “渲染” (红色)。

其实对于低优先级的 “渲染” ,React 对其进行了时间限额(5毫秒)。“渲染” 时就需要不断地检查此次执行是否超时,如果一旦发现执行已经超过时间限额,则需要暂停,将剩余的 “渲染” 下次执行。这就被叫做 “时间切片”。

有关 “时间切片” 的内容,可以翻阅《调度系统 - Scheduler》

我们说到 “渲染” 任务需要不断检查自身的执行是否超时,那这个又是如何做到的呢?

下面,就从 “渲染” 的基本单元 - Fiber ,来探秘 “渲染” 过程到底是怎样的。

“渲染” 的基本单元 - Fiber

终于轮到 **Fiber** 登场了!想必大家看到过很多关于 **Fiber** 的解读了,这里再来简单赘述一下,**Fiber** 在我们 React 应用的生命周期中,到底扮演一个什么样的角色。

Fiber 就是 VDOM

通常,我们都使用 **JSX** 来进行编码,通过编译它被转化成了 React.createElement 这样可执行的JS代码。

在程序运行的时候,根据 ReactElement ,在 React 内部构建了 **Fiber树** 这样的结构,用来描述我们 UI ,最终 React 会将它们转化为浏览器中的真实 DOM 结构。

整个流程如下图:

大家经常提到的 **VDOM** ,在 React 18 中其实指的就是 **Fiber树**

下图举例说明我们的代码生成的 **Fiber树** 是什么样子的:

“渲染” 的两个阶段

所谓 “渲染” ,就是 React 根据应用产生的更新,重新遍历整颗 Fiber树,将其更新为最新,最后映射为实际的 DOM 节点。

整个过程,可以分为两个阶段:**Reconcile****Commit**

**Reconcile** 阶段:遍历 **Fiber树**,并将其更新。在源码中,大家可以去查看 **workLoopConcurrent** 这个函数。

**Commit** 阶段:将新的 Fiber树 映射为实际 DOM 的过程。在源码中,大家可以去查看 **commitRoot** 函数。

很明显的,如果 “渲染” 被 “中断” 的话,一定不能在 **Commit** 阶段被中断

因为这个阶段已经产生了实际 DOM 的更新,一旦产生 “中断” ,意味着会将一个 “半成品” 的 UI 呈现给用户,这是无法接受的。

所以,如果要 “中断” 我们的 “渲染” 的话,那就只能选择在 **Reconcile** 阶段,即在 Fiber树 更新的阶段中。

那 React 到底如何做呢,我们看下一部分的内容。

“渲染” 中断

在基本了解了 **Fiber** 结构后,我们来进一步探索:“渲染” 是如何依靠Fiber实现可中断的

Reconcile 阶段

在上文我们得出结论,“渲染” 过程如果要中断,只能选择在 Reconcile 阶段,因为这个阶段只涉及 Fiber 的变更,而没有产生实际 DOM 的变更。

Reconcile 又可以划分为两个阶段:beginWorkcompleteUnitOfWork:

beginWork

处理每个 Fiber 上更新,将结果同步在 **Fiber**memoizedPropsmemoziedState 上,并使用 Flags 来标记每个节点需要进行何种处理。

这个阶段是从顶到下地遍历 **Fiber** 结构,先处理自身,然后子节点 **child**,没有子节点处理兄弟节点 **sibling**

completeUnitOfWork

将每个 **Fiber** 的全部子节点的 **flags****subtreeFlags** 都标记在自己的 **subtreeFlags** 上。

这个阶段是从下至上进行回溯的,因为要读取子节点标记的原因,需要保证子节点先处理完成。

如上图可见 **Reconcile** 的整体流程,在每处理完成一个 Fiber 节点时,会检查时间片是否到时,如果到了,则会中断此次 “渲染”

等到下一次进来的时候,可以直接从 **workInProgress** 上次中断的 Fiber 节点开始处理即可

“渲染” 中断时的回退

“渲染” 过程被中断的话,还面临着另外一个问题:已经更新过的一部分 Fiber 节点该怎么办?

我们需要将它们回退到本次更新前的状态,才能保证新一次更新是正确的。

那你会怎么处理这个 “撤销” 的操作呢?

Fiber 的 “撤销处理” 如上图所示。

**current** 指向的是当前被实际渲染为 DOM 的树,当进入 **Reconcile** 阶段的时候,会拷贝一颗新的 Fiber树,我们称之为 **workInProgress**

如果整个过程顺利完成,会 **current** 指向新生成的这颗 Fiber树

如果出现 “渲染” 要被 “中断” 的时候,它会直接放弃 **workInProgress** 的处理

下一次更新依然依靠 **current** 即可,因为刚才的更新并没有更改任何 **current** 上的信息。

为什么会有 Fiber

之前一直在先入为主的聊 Fiber 这个东西,但是我们一直没有说 Fiber 从何而来?

在 Fiber 结构之前,当 React 开始 “渲染” 时,也是从顶层节点开始,调用节点的更新方法。

只不过遍历全部节点是依靠在每个节点的更新方法中,触发下层节点的更新。就这样层层嵌套,形成了所谓的 Stack Reconcile

这样的 Function Call Stack 使得 React 无法在中间某个步骤暂停后,再接着从暂停地方开始继续执行。

关于这一点,在 React Fiber Architecture 中也有所提及:

It follows that rendering a React app is akin to calling a function whose body contains calls to other functions, and so on

至于 Fiber 的出现,就是为了解决这个问题:实现一种可控制的 Call Stack

Wouldn't it be great if we could customize the behavior of the call stack to optimize for rendering UIs? Wouldn't it be great if we could interrupt the call stack at will and manipulate stack frames manually?

That's the purpose of React Fiber. Fiber is reimplementation of the stack, specialized for React components. You can think of a single fiber as a virtual stack frame.

所以,这也就是原有的设计不支持中断的原因,从而诞生了 Fiber