0%

图解 React Fiber 机制

在我们去了解React Fiber之前,我们需要先了解到React的渲染机制,从而去探求为什么React会选择推出React Fiber来替换以前的模式。

React的渲染机制

React会在内存中维护一棵虚拟的DOM树(V-DOM-TREE)。当数据(state)发生变化时,会自动更新虚拟DOM,生成一棵新的虚拟DOM树。通过 Diff 两棵树来找到变化的部分,从而得到一个更新的集合(Patch),然后将集合加到队列中,最终去批量更新Patch到真实的DOM节点中。

img

虚拟DOM

V-DOM(虚拟DOM)是对真实DOM的抽象,本质上是一个JS对象。

  • 传统的Web App通过和DOM的直接交互来构建和监听事件的发生,但是频繁的变动DOM会导致浏览器的回流或者重绘,影响性能。通过V-DOM,我们可以尽可能一次性将差异更新到DOM中,解决了部分性能问题
  • 通过V-DOM的抽象,我们将真实的渲染和逻辑的构建拆分开,更好的实现跨平台开发

React Fiber之前的不足

在React16之前的版本,调节器(stack reconciler)更新DOM树的时候是采用自顶向下递归的方式,从根组件(首次渲染)或调用setStatus的组件开始去更新子节点。组件树越复杂,递归遍历的成本越高,再加上JS本身只运行在主线程,就会造成持续占用主线程。如果由于执行JS脚本耗费时间较多,就会导致主线程上的布局、动画等周期性任务以及交互任务无法立即处理,就会造成视觉上的卡顿。

浏览器的渲染

耗时操作的参与

正常的屏幕刷新率是60帧,即1秒钟绘制60帧画面。浏览器每一帧可能会做的工作,并且有对应的执行顺序:

  • 处理用户交互事件
  • JS执行
  • 处理浏览器事件,例如resize, scroll
  • 调用requestAnimation
  • 布局
  • 绘制

如果我们的JS代码持续占用主线程,就可能导致渲染下一帧去处理用户的交互事件不能立马处理,用户不能立马得到反馈,造成屏幕卡顿的感觉。

解决方案

如果把渲染过程拆分成多个子任务,每次完成一小部分后,去询问是否还有剩余时间,如果有,则继续下一个任务;如果没有,挂起当前执行的任务,将控制权交给主线程,等待主线程空闲了或者优先级较高的任务执行结束后再继续执行。这种调度式的策略被称为合作式调度(Cooperative Scheduling),也是操作系统常用的调度策略之一。

React Fiber

什么是Fiber

基于上文的解决方案,在React中提出了将任务分解为单元,每一个单元就算作一个Fiber。通过按照优先级来调度子任务,分段来更新,将同步渲染转为异步渲染。即通过将渲染任务分解为单元后,根据优先级来使用API调度,异步执行指定任务。浏览器提供了一个requestIdleCallbackAPI,可以实现在主事件循环上执行后台和低优先级工作。但是其兼容性并不佳,所以React做了一个Polyfill,具体实现可以参考该链接

Fiber架构

Stack-Recocilation(React 16以前):JSX中创建(或更新)一些元素,react会根据这些元素创建(或更新)Virtual DOM,然后根据更新前后virtual DOM的区别,去修改真正的DOM。在stack reconciler下,DOM的更新是同步的,通过递归的方式进行渲染,发现一个或几个instance有更新,会立即执行DOM更新操作。

Fiber-Recocilation:React 16版本提出了一个更先进的调和器,它允许渲染进程分段完成,而不是必须一次性完成,中间可以返回至主进程控制执行其他任务。而这是通过计算部分组件树的变更,并暂停渲染更新,询问主进程是否有更高需求的绘制或者更新任务需要执行,这些高需求的任务完成后才开始渲染。这一切的实现是在代码层引入了一个新的数据结构-Fiber对象,每一个组件实例对应有一个fiber实例,此fiber实例负责管理组件实例的更新,渲染任务及与其他fiber实例的联系。

Fiber的工作流程

在讲解Fiber的工作流程的时候,会涉及到几个关键概念:

  • Fiber Tree
  • WorkInProgress Tree
  • Effects List

我们将会在接下来的示例讲解中来了解这三个关键概念。

  1. 当前页面包含一个列表,通过该列表渲染出一个button和一组Item,Item中包含一个div,其中的内容为数字。通过点击button,可以使列表中的所有数字进行平方。另外有一个按钮,点击可以调节字体大小。

  2. 页面渲染完成后,就会初始化生成一个Fiber Tree(下图中current)。还会维护一个workInProgressTree。workInProgressTree用于计算更新,完成reconciliation过程。

  3. 用户点击平方按钮后,利用各个元素平方后的list调用setState,react会把当前的更新送入list组件对应的update queue中。但是react并不会立即执行对比并修改DOM的操作。而是交给scheduler去处理。

  4. Scheduler会调用reqeustIdelCallback去请求空闲期的时间来处理任务

  5. 因为根节点上的更新队列为空,所以直接从fiber-tree上将根节点复制到workInProgressTree中去。根节点中包含指向子节点(List)的指针。

  6. 根节点没有什么更新操作,根据其child指针,接下来把List节点及其对应的update queue也复制到workinprogress中。List插入后,向其父节点返回,标志根节点的处理完成。

  7. 根节点处理完成后,react此时检查时间片是否用完。如果没有用完,根据其保存的下个工作单元的信息开始处理下一个节点List。

  8. 在获取到最新的state值后,react会更新List的state和props值,调用render,然后得到一组通过更新后的list值生成的elements

  9. button没有任何子节点,所以此时可以返回,并标志button处理完成。如果button有改变,需要打上tag,但是当前情况没有,只需要标记完成即可。

  10. 第二个Item shouldComponentUpdate返回true,所以需要打上tag,标志需要更新,复制div,调用render,讲div中的内容从2更新为4,因为div有更新,所以标记div。当前节点处理完成。

  11. 下一个工作单元是Item,在进入Item之前,检查时间。但这个时候时间用完了。此时react必须交换主线程,并告诉主线程以后要为其分配时间以完成剩下的操作。

  12. 当主线程任务结束,处于空闲状态,则开始执行react剩下的操作,跟上一个Item的处理流程几乎一样,完成整个fiber-tree和workInProgress的更新

  13. 完成后,Item向List返回并merge effect。

  14. 此时List向根节点返回并merge effect,所有节点都可以标记完成了。此时react将workInProgress标记为pendingCommit。

  15. 此时,要做的是还是检查时间够不够用,如果没有时间,会等到时间再去提交修改到DOM。进入到阶段2后,reacDOM会根据阶段1计算出来的effect-list来更新DOM。

  16. 更新完DOM之后,workInProgress就完全和DOM保持一致了,为了让当前的fiber-tree和DOM保持一直,react交换了current和workinProgress两个指针

至此,整个更新操作就结束了。刚才的三个概念 Fiber Tree,WorkInProgress Tree,Effects List 在其中发挥的作用是否清楚了。我们再来归纳一下:

  • Fiber Tree: 用来描述增量更新所需的上下文信息
  • WorkInProgress Tree: workInProgress tree是reconcile过程中从fiber tree建立的当前进度快照,用于断点恢复。
  • Effects List: 每个workInProgress tree节点上都有一个effect list用来存放diff结果,当前节点更新完毕会queue收集diff结果,向上merge effect list,用于最终的DOM更新。

任务分片的优先级

任务分片,或者叫工作单元(work unit),是怎么拆分的呢。因为在Reconciliation阶段任务分片可以被打断,用来执行优先级高的任务。如何拆分一个任务就很重要了。

为了达到任务分片的效果,就需要有一个调度器 (Scheduler) 来进行任务分配。任务的优先级有六种:

1
2
3
4
5
6
7
8
9
export type PriorityLevel = 0 | 1 | 2 | 3 | 4 | 5;
module.exports = {
synchronous,//0,synchronous首屏(首次渲染)用,要求尽量快,不管会不会阻塞UI线程
task,//1,在next tick之前执行
animation,//2,animation通过requestAnimationFrame来调度,这样在下一帧就能立即开始动画过程
high,//3,在不久的将来立即执行
low,//4,稍微延迟执行也没关系
offscreen,//5,下一次render时或scroll时才执行
}

双缓冲原理

通过刚才的流程我们可以了解到,在更新渲染过程中有两棵树,Fiber Tree和WIP Tree。WIP Tree用于反映要刷新到屏幕的节点状态,当WIP Tree构建结束后,会丢掉以前的Fiber Tree,将WIP作为Fiber Tree用于下次更新。

这样做的好处:

  • 复用内部对象,如果某棵子树不需要变动,则可以整个克隆整棵子树,节省内存非配和GC的时间开销
  • 即使WIP的构建中出现问题,也不影响Fiber Tree的结构,仍然可以沿用旧树的节点,从而不影响View

Dan在React 16的演讲中用了一个比喻,你可以将 WIP 树想象成从旧树中 Fork 出来的功能分支,你在这新分支中添加或移除特性,即使是操作失误也不会影响旧的分支。当你这个分支经过了测试和完善,就可以合并到旧分支,将其替换掉. 这或许也是命令为提交(Commite)阶段的原因。

总结

React Fiber提供的功能主要是:

  • 可切分,可中断任务。
  • 可重用各分阶段任务,且可以设置优先级。
  • 可以在父子组件任务间前进/后退切换任务。
  • render方法可以返回多元素(即可以返回数组)(本文暂未涉及)。
  • 支持异常边界处理异常(本文暂未涉及)。