Deep In React 之浅谈 React Fiber 架构(一)

文章首发于我的博客javascript

前言

2016 年都已经透露出来的概念,这都 9102 年了,我才开始写 Fiber 的文章,表示惭愧呀。不过如今好的是关于 Fiber 的资料已经很丰富了,在写文章的时候参考资料比较多,比较容易深入的理解。html

React 做为我最喜欢的框架,没有之一,我愿意花不少时间来好好的学习他,我发现对于学习一门框架会有四种感觉,刚开始没使用过,可能有一种很神奇的感受;而后接触了,遇到了不熟悉的语法,感受这是什么垃圾东西,这不是反人类么;而后当你熟悉了以后,真香,设计得挺好的,这个时候它已经改变了你编程的思惟方式了;再到后来,看过他的源码,理解他的设计以后,设计得确实好,感受本身也能写一个的样子。前端

因此我今年(对,没错,就是一年)就是想彻底的学透 React,因此开了一个 Deep In React 的系列,把一些新手在使用 API 的时候不知道为何的点,以及一些为何有些东西要这么设计写出来,与你们共同探讨 React 的奥秘。java

个人思路是自上而下的介绍,先理解总体的 Fiber 架构,而后再细挖每个点,因此这篇文章主要是谈 Fiber 架构的。react

介绍

在详细介绍 Fiber 以前,先了解一下 Fiber 是什么,以及为何 React 团队要话两年时间重构协调算法。git

React 的核心思想

内存中维护一颗虚拟DOM树,数据变化时(setState),自动更新虚拟 DOM,获得一颗新树,而后 Diff 新老虚拟 DOM 树,找到有变化的部分,获得一个 Change(Patch),将这个 Patch 加入队列,最终批量更新这些 Patch 到 DOM 中github

React 16 以前的不足

首先咱们了解一下 React 的工做过程,当咱们经过render()setState() 进行组件渲染和更新的时候,React 主要有两个阶段:算法

调和阶段(Reconciler):官方解释。React 会自顶向下经过递归,遍历新数据生成新的 Virtual DOM,而后经过 Diff 算法,找到须要变动的元素(Patch),放到更新队列里面去。编程

渲染阶段(Renderer):遍历更新队列,经过调用宿主环境的API,实际更新渲染对应元素。宿主环境,好比 DOM、Native、WebGL 等。浏览器

在协调阶段阶段,因为是采用的递归的遍历方式,这种也被成为 Stack Reconciler,主要是为了区别 Fiber Reconciler 取的一个名字。这种方式有一个特色:一旦任务开始进行,就没法中断,那么 js 将一直占用主线程, 一直要等到整棵 Virtual DOM 树计算完成以后,才能把执行权交给渲染引擎,那么这就会致使一些用户交互、动画等任务没法当即获得处理,就会有卡顿,很是的影响用户体验。

如何解决以前的不足

以前的问题主要的问题是任务一旦执行,就没法中断,js 线程一直占用主线程,致使卡顿。

可能有些接触前端不久的不是特别理解上面为何 js 一直占用主线程就会卡顿,我这里仍是简单的普及一下。

浏览器每一帧都须要完成哪些工做?

页面是一帧一帧绘制出来的,当每秒绘制的帧数(FPS)达到 60 时,页面是流畅的,小于这个值时,用户会感受到卡顿。

1s 60 帧,因此每一帧分到的时间是 1000/60 ≈ 16 ms。因此咱们书写代码时力求不让一帧的工做量超过 16ms。

image-20190603163205451

浏览器一帧内的工做

经过上图可看到,一帧内须要完成以下六个步骤的任务:

  • 处理用户的交互
  • JS 解析执行
  • 帧开始。窗口尺寸变动,页面滚去等的处理
  • rAF(requestAnimationFrame)
  • 布局
  • 绘制

若是这六个步骤中,任意一个步骤所占用的时间过长,总时间超过 16ms 了以后,用户也许就能看到卡顿。

而在上一小节提到的调和阶段花的时间过长,也就是 js 执行的时间过长,那么就有可能在用户有交互的时候,原本应该是渲染下一帧了,可是在当前一帧里还在执行 JS,就致使用户交互不能麻烦获得反馈,从而产生卡顿感。

解决方案

把渲染更新过程拆分红多个子任务,每次只作一小部分,作完看是否还有剩余时间,若是有继续下一个任务;若是没有,挂起当前任务,将时间控制权交给主线程,等主线程不忙的时候在继续执行。 这种策略叫作 Cooperative Scheduling(合做式调度),操做系统经常使用任务调度策略之一。

补充知识,操做系统经常使用任务调度策略:先来先服务(FCFS)调度算法、短做业(进程)优先调度算法(SJ/PF)、最高优先权优先调度算法(FPF)、高响应比优先调度算法(HRN)、时间片轮转法(RR)、多级队列反馈法。

合做式调度主要就是用来分配任务的,当有更新任务来的时候,不会立刻去作 Diff 操做,而是先把当前的更新送入一个 Update Queue 中,而后交给 Scheduler 去处理,Scheduler 会根据当前主线程的使用状况去处理此次 Update。为了实现这种特性,使用了requestIdelCallbackAPI。对于不支持这个API 的浏览器,React 会加上 pollyfill。

在上面咱们已经知道浏览器是一帧一帧执行的,在两个执行帧之间,主线程一般会有一小段空闲时间,requestIdleCallback能够在这个空闲期(Idle Period)调用空闲期回调(Idle Callback),执行一些任务。

image-20190625225130226

  • 低优先级任务由requestIdleCallback处理;
  • 高优先级任务,如动画相关的由requestAnimationFrame处理;
  • requestIdleCallback能够在多个空闲期调用空闲期回调,执行任务;
  • requestIdleCallback方法提供 deadline,即任务执行限制时间,以切分任务,避免长时间执行,阻塞UI渲染而致使掉帧;

这个方案看似确实不错,可是怎么实现可能会遇到几个问题:

  • 如何拆分红子任务?
  • 一个子任务多大合适?
  • 怎么判断是否还有剩余时间?
  • 有剩余时间怎么去调度应该执行哪个任务?
  • 没有剩余时间以前的任务怎么办?

接下里整个 Fiber 架构就是来解决这些问题的。

什么是 Fiber

为了解决以前提到解决方案遇到的问题,提出了如下几个目标:

  • 暂停工做,稍后再回来。
  • 为不一样类型的工做分配优先权。
  • 重用之前完成的工做。
  • 若是再也不须要,则停止工做。

为了作到这些,咱们首先须要一种方法将任务分解为单元。从某种意义上说,这就是 Fiber,Fiber 表明一种工做单元

可是仅仅是分解为单元也没法作到中断任务,由于函数调用栈就是这样,每一个函数为一个工做,每一个工做被称为堆栈帧,它会一直工做,直到堆栈为空,没法中断。

因此咱们须要一种增量渲染的调度,那么就须要从新实现一个堆栈帧的调度,这个堆栈帧能够按照本身的调度算法执行他们。另外因为这些堆栈是能够本身控制的,因此能够加入并发或者错误边界等功能。

所以 Fiber 就是从新实现的堆栈帧,本质上 Fiber 也能够理解为是一个虚拟的堆栈帧,将可中断的任务拆分红多个子任务,经过按照优先级来自由调度子任务,分段更新,从而将以前的同步渲染改成异步渲染。

因此咱们能够说 Fiber 是一种数据结构(堆栈帧),也能够说是一种解决可中断的调用任务的一种解决方案,它的特性就是时间分片(time slicing)暂停(supense)

若是了解协程的可能会以为 Fiber 的这种解决方案,跟协程有点像(区别仍是很大的),是能够中断的,能够控制执行顺序。在 JS 里的 generator 其实就是一种协程的使用方式,不过颗粒度更小,能够控制函数里面的代码调用的顺序,也能够中断。

Fiber 是如何工做的

  1. ReactDOM.render()setState 的时候开始建立更新。
  2. 将建立的更新加入任务队列,等待调度。
  3. 在 requestIdleCallback 空闲时执行任务。
  4. 从根节点开始遍历 Fiber Node,而且构建 WokeInProgress Tree。
  5. 生成 effectList。
  6. 根据 EffectList 更新 DOM。

下面是一个详细的执行过程图:

  1. 第一部分从 ReactDOM.render() 方法开始,把接收的 React Element 转换为 Fiber 节点,并为其设置优先级,建立 Update,加入到更新队列,这部分主要是作一些初始数据的准备。
  2. 第二部分主要是三个函数:scheduleWorkrequestWorkperformWork,即安排工做、申请工做、正式工做三部曲,React 16 新增的异步调用的功能则在这部分实现,这部分就是 Schedule 阶段,前面介绍的 Cooperative Scheduling 就是在这个阶段,只有在这个解决获取到可执行的时间片,第三部分才会继续执行。具体是如何调度的,后面文章再介绍,这是 React 调度的关键过程。
  3. 第三部分是一个大循环,遍历全部的 Fiber 节点,经过 Diff 算法计算全部更新工做,产出 EffectList 给到 commit 阶段使用,这部分的核心是 beginWork 函数,这部分基本就是 Fiber Reconciler ,包括 reconciliation 和 commit 阶段

Fiber Node

FIber Node,承载了很是关键的上下文信息,能够说是贯彻整个建立和更新的流程,下来分组列了一些重要的 Fiber 字段。

{
  ...
  // 跟当前Fiber相关本地状态(好比浏览器环境就是DOM节点)
  stateNode: any,
    
    // 单链表树结构
  return: Fiber | null,// 指向他在Fiber节点树中的`parent`,用来在处理完这个节点以后向上返回
  child: Fiber | null,// 指向本身的第一个子节点
  sibling: Fiber | null,  // 指向本身的兄弟结构,兄弟节点的return指向同一个父节点

  // 更新相关
  pendingProps: any,  // 新的变更带来的新的props
  memoizedProps: any,  // 上一次渲染完成以后的props
  updateQueue: UpdateQueue<any> | null,  // 该Fiber对应的组件产生的Update会存放在这个队列里面
  memoizedState: any, // 上一次渲染的时候的state
    
  // Scheduler 相关
  expirationTime: ExpirationTime,  // 表明任务在将来的哪一个时间点应该被完成,不包括他的子树产生的任务
  // 快速肯定子树中是否有不在等待的变化
  childExpirationTime: ExpirationTime,
    
 // 在Fiber树更新的过程当中,每一个Fiber都会有一个跟其对应的Fiber
  // 咱们称他为`current <==> workInProgress`
  // 在渲染完成以后他们会交换位置
  alternate: Fiber | null,

  // Effect 相关的
  effectTag: SideEffectTag, // 用来记录Side Effect
  nextEffect: Fiber | null, // 单链表用来快速查找下一个side effect
  firstEffect: Fiber | null,  // 子树中第一个side effect
  lastEffect: Fiber | null, // 子树中最后一个side effect
  ....
};
复制代码

Fiber Reconciler

在第二部分,进行 Schedule 完,获取到时间片以后,就开始进行 reconcile。

Fiber Reconciler 是 React 里的调和器,这也是任务调度完成以后,如何去执行每一个任务,如何去更新每个节点的过程,对应上面的第三部分。

reconcile 过程分为2个阶段(phase):

  1. (可中断)render/reconciliation 经过构造 WorkInProgress Tree 得出 Change。
  2. (不可中断)commit 应用这些DOM change。

reconciliation 阶段

在 reconciliation 阶段的每一个工做循环中,每次处理一个 Fiber,处理完能够中断/挂起整个工做循环。经过每一个节点更新结束时向上归并 Effect List 来收集任务结果,reconciliation 结束后,根节点的 Effect List里记录了包括 DOM change 在内的全部 Side Effect

render 阶段能够理解为就是 Diff 的过程,得出 Change(Effect List),会执行声明以下的声明周期方法:

  • [UNSAFE_]componentWillMount(弃用)
  • [UNSAFE_]componentWillReceiveProps(弃用)
  • getDerivedStateFromProps
  • shouldComponentUpdate
  • [UNSAFE_]componentWillUpdate(弃用)
  • render

因为 reconciliation 阶段是可中断的,一旦中断以后恢复的时候又会从新执行,因此极可能 reconciliation 阶段的生命周期方法会被屡次调用,因此在 reconciliation 阶段的生命周期的方法是不稳定的,我想这也是 React 为何要废弃 componentWillMountcomponentWillReceiveProps方法而改成静态方法 getDerivedStateFromProps 的缘由吧。

commit 阶段

commit 阶段能够理解为就是将 Diff 的结果反映到真实 DOM 的过程。

在 commit 阶段,在 commitRoot 里会根据 effecteffectTag,具体 effectTag 见源码 ,进行对应的插入、更新、删除操做,根据 tag 不一样,调用不一样的更新方法。

commit 阶段会执行以下的声明周期方法:

  • getSnapshotBeforeUpdate
  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

P.S:注意区别 reconciler、reconcile 和 reconciliation,reconciler 是调和器,是一个名词,能够说是 React 工做的一个模块,协调模块;reconcile 是调和器调和的动做,是一个动词;而 reconciliation 只是 reconcile 过程的第一个阶段。

Fiber Tree 和 WorkInProgress Tree

React 在 render 第一次渲染时,会经过 React.createElement 建立一颗 Element 树,能够称之为 Virtual DOM Tree,因为要记录上下文信息,加入了 Fiber,每个 Element 会对应一个 Fiber Node,将 Fiber Node 连接起来的结构成为 Fiber Tree。它反映了用于渲染 UI 的应用程序的状态。这棵树一般被称为 current 树(当前树,记录当前页面的状态)。

在后续的更新过程当中(setState),每次从新渲染都会从新建立 Element, 可是 Fiber 不会,Fiber 只会使用对应的 Element 中的数据来更新本身必要的属性,

Fiber Tree 一个重要的特色是链表结构,将递归遍历编程循环遍历,而后配合 requestIdleCallback API, 实现任务拆分、中断与恢复。

这个连接的结构是怎么构成的呢,这就要主要到以前 Fiber Node 的节点的这几个字段:

// 单链表树结构
{
   return: Fiber | null, // 指向父节点
   child: Fiber | null,// 指向本身的第一个子节点
   sibling: Fiber | null,// 指向本身的兄弟结构,兄弟节点的return指向同一个父节点
}
复制代码

每个 Fiber Node 节点与 Virtual Dom 一一对应,全部 Fiber Node 链接起来造成 Fiber tree, 是个单链表树结构,以下图所示:

对照图来看,是否是能够知道 Fiber Node 是如何联系起来的呢,Fiber Tree 就是这样一个单链表。

当 render 的时候有了这么一条单链表,当调用 setState 的时候又是如何 Diff 获得 change 的呢?

采用的是一种叫双缓冲技术(double buffering),这个时候就须要另一颗树:WorkInProgress Tree,它反映了要刷新到屏幕的将来状态。

WorkInProgress Tree 构造完毕,获得的就是新的 Fiber Tree,而后喜新厌旧(把 current 指针指向WorkInProgress Tree,丢掉旧的 Fiber Tree)就行了。

这样作的好处:

  • 可以复用内部对象(fiber)
  • 节省内存分配、GC的时间开销
  • 就算运行中有错误,也不会影响 View 上的数据

每一个 Fiber上都有个alternate属性,也指向一个 Fiber,建立 WorkInProgress 节点时优先取alternate,没有的话就建立一个。

建立 WorkInProgress Tree 的过程也是一个 Diff 的过程,Diff 完成以后会生成一个 Effect List,这个 Effect List 就是最终 Commit 阶段用来处理反作用的阶段。

后记

本开始想一篇文章把 Fiber 讲透的,可是写着写着发现确实太多了,想写详细,估计要写几万字,因此我这篇文章的目的仅仅是在没有涉及到源码的状况下梳理了大体 React 的工做流程,对于细节,好比如何调度异步任务、如何去作 Diff 等等细节将以小节的方式一个个的结合源码进行分析。

说实话,本身不是特别满意这篇,感受头重脚轻,在讲协调以前写得还挺好的,可是在讲协调这块文字反而变少了,由于我是专门想写一篇文章讲协调的,因此这篇仅仅用来梳理整个流程。

可是梳理整个流程又发现 Schedule 这块基本没什么体现,哎,不想写了,这篇文章拖过久了,请继续后续的文章。

能够关注个人 github:Deep In React

一些问题

接下来留一些思考题。

  • 如何去划分任务优先级?
  • 在 reconcile 过程的 render 阶段是如何去遍历链表,如何去构建 workInProgress 的?
  • 当任务被打断,如何恢复?
  • 如何去收集 EffectList?
  • 针对不一样的组件类型如何进行更新?

参考

我是桃翁,一个爱思考的前端er,想了解关于更多的前端相关的,请关注个人公号:「前端桃园」

相关文章
相关标签/搜索