认识 Fiber

195 阅读15分钟

react

今天,我们一起认识 fiber 这位大佬。

在 react v16 版本前,react 的渲染机制是同步进行的,如果渲染的组件比较庞大,那么 js 就会占据主线程太久的时间而导致页面的响应较差从而影响用户体验。

为了解决这个问题,react 的核心团队耗时两年之久,重写了 react diff 算法,并在 react v16 版本中发布了这个特性。为了区别前后两个算法,我们将之前的 reconciler 称为 stack reconciler,重写后的称为 fiber reconciler,简称为 Fiber。

在 stack reconciler 中,就像是函数的调用过程,父组件里面调子组件可以看成是函数的递归(这也许就是 stack 的名字由来)在 setState 后,react 会立即开始 reconciliation 过程,从父节点(Virtual DOM)开始遍历,以找出不同。将所有的 Virtual DOM 遍历完成后,reconciler 才能给出当前需要修改真实 DOM 的信息,并传递给 renderer 进行渲染,然后屏幕上才会显示此次更新内容。对于特别庞大的 vDOM 树来说,reconciliation 过程会很长,在这期间,主线程是被 js 占用的,因此任何交互、布局、渲染都会停止,给用户的感觉就是页面被卡住了。

stack reconciler

对于函数调用来说,这没有问题,因为我们需要拿到函数调用的结果,但是对于 UI 来说,我们应该考虑其他因素:

  • 并不是所有的 state 更新都需要立即显示出来,比如屏幕之外的部分的更新

  • 并不是所有的更新优先级都是一样的,比如用户输入的响应优先级要比通过请求填充内容的响应优先级更高

  • 理想情况下,对于某些高优先级的操作,应该是可以打断低优先级的操作执行的,比如用户输入时,页面的某个评论还在 reconciliation,应该优先响应用户输入

所以理想状况下 reconciliation 的过程应该是像下图所示一样,每次只做一个很小的任务,做完后能够“喘口气儿”,回到主线程看下有没有什么更高优先级的任务需要处理,如果有则先处理更高优先级的任务,没有则继续执行。

fiber reconciler

在 fiber reconciler 下,更新任务可以被拆分成很多小部分,并且可以被中断。所以同步操作 DOM 可能会导致 fiber-tree 与实际 DOM 的不同步。对于每个节点来说,其不光存储了对应元素的基本信息,还要保存一些用于任务调度的信息。因此,fiber 仅仅是一个对象,表征reconciliation 阶段所能拆分的最小工作单元。通过 stateNode 属性管理 Instance 自身的特性。通过 child 和 sibling 表征当前工作单元的下一个工作单元,return 表示处理完成后返回结果所要合并的目标,通常指向父节点。整个结构是一个 ** 链表树 ** 。每个工作单元(fiber)执行完成后,都会查看是否还继续拥有主线程时间片,如果有继续下一个,如果没有则先处理其他高优先级事务,等主线程空闲下来继续执行。

fiber {
  stateNode: {},
  child: {},
  return: {},
  sibling: {},
}

栗子

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

页面渲染完成后,就会初始化生成一个 fiber-tree。初始化 fiber-tree 和初始化 Virtual DOM tree 没什么区别,这里就不再赘述。

fiber tree

同时,react 会维护一份 workInProgress tree,它用于计算更新,完成 reconciliation 过程。

workInProgress tree

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

setState

scheduler 会根据当前主线程的使用情况去处理这次 update。为了实现这种特性,使用了requestIdelCallbackAPI。对于不支持这个 API 的浏览器,react 会加上 pollyfill。

总的来讲,客户端线程执行任务时会以帧的形式划分,大部分设备控制在 30-60 帧是不会影响用户体验;在两个执行帧之间,主线程通常会有一小段空闲时间,requestIdleCallback 可以在这个空闲期(Idle Period)调用空闲期回调(Idle Callback),执行一些任务。

schduler

  • 低优先级任务由 requestIdleCallback 处理;

  • 高优先级任务,如动画相关的由 requestAnimationFrame 处理;

  • requestIdleCallback 可以在多个空闲期调用空闲期回调,执行任务;

  • requestIdleCallback 方法提供 deadline,即任务执行限制时间,以切分任务,避免长时间执行,阻塞 UI 渲染而导致掉帧;

一旦 reconciliation 过程得到时间片,就开始进入 work loop。work loop 机制可以让 react 在计算状态和等待状态之间进行切换。为了达到这个目的,对于每个 loop 而言,需要追踪两个东西:下一个工作单元(下一个待处理的fiber);当前还能占用主线程的时间。第一个 loop,下一个待处理单元为根节点。

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

first loop

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

first loop done

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

接下来进入处理 List 的 work loop,List 中包含更新,因此此时 react 会调用 setState 时传入的 updater funciton 获取最新的 state 值,此时应该是 [1,4,9]。通常我们现在在调用 setState 传入的是一个对象,但在使用 fiber conciler 时,必须传入一个函数,函数的返回值是要更新的 state。

在获取到最新的 state 值后,react 会更新 List 的 state 和 props 值,然后调用 render,然后得到一组通过更新后的 list 值生成的 elements。react 会根据生成 elements 的类型,来决定 fiber 是否可重用。对于当前情况来说,新生成的 elments 类型并没有变(依然是 Button 和 Item),所以 react 会直接从 fiber-tree 中复制这些 elements 对应的 fiber 到 workInProgress 中。并给 List 打上标签,因为这是一个需要更新的节点。

list loop2

List 节点处理完成,react 仍然会检查当前时间片是否够用。如果够用则处理下一个,也就是 button 。假如这个时候,用户点击了放大字体的按钮。这个放大字体的操作,纯粹由 js 实现,跟 react 无关。但是操作并不能立即生效,因为 react 的时间片还未用完,因此接下来仍然要继续处理 button。

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

button loop

老规矩,处理完一个节点先看时间够不够用。注意这里放大字体的操作已经在等候释放主线程了。

接下来处理第一个 item。通过 shouldComponentUpdate 钩子可以根据传入的 props 判断其是否需要改变。对于第一个 Item 而言,更改前后都是 1,所以不会改变,shouldComponentUpdate 返回 false,复制 div,处理完成,检查时间,如果还有时间进入第二个 Item。

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

second item loop

对于上面这种情况,div 已经是叶子节点,且没有任何兄弟节点,且其值已经更新,这时候,需要将此节点改变产生的 effect 合并到父节点中。此时 react 会维护一个列表,其中记录所有产生 effect 的元素。

effect list

合并后,回到父节点Item,父节点标记完成。

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

主线程接下来进行放大字体的操作。完成后执行 react 接下来的操作,跟上一个 Item 的处理流程几乎一样,处理完成后整个 fiber-tree 和 workInProgress 如下:

child loop done

完成后,Item 向 List 返回并 merge effect,effect List 现在如下所示:

effect list

此时 List 向根节点返回并 merge effect,所有节点都可以标记完成了。此时 react 将 workInProgress 标记为 pendingCommit。意思是可以进入 commit 阶段了。

pending commit

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

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

pending commit

事实上,react 大部分时间都在维持两个树(Double-buffering)。这可以缩减下次更新时,分配内存、垃圾清理的时间。commit 完成后,执行 componentDidMount 函数。

小结

通过将 reconciliation 过程,分解成小的工作单元的方式,可以让页面对于浏览器事件的响应更加及时。但是另外一个问题还是没有解决,就是如果当前在处理的 react 渲染耗时较长,仍然会阻塞后面的 react 渲染。这就是为什么 fiber reconciler 增加了优先级策略。

module.exports = {
  NoWork: 0, // No work is pending.
  SynchronousPriority: 1, // For controlled text inputs. Synchronous side-effects.
  AnimationPriority: 2, // Needs to complete before the next frame.
  HighPriority: 3, // Interaction that needs to complete pretty soon to feel responsive.
  LowPriority: 4, // Data fetching, or result from updating stores.
  OffscreenPriority: 5, // Won't be visible but do the work in case it becomes visible.
};

优先级策略的核心是,在 reconciliation 阶段,低优先级的操作可以被高优先级的操作打断,并让主线程执行高优先级的更新,以时用户可感知的响应更快。值得注意的一点是,当主线程重新分配给低优先级的操作时,并不会从上次工作的状态开始,而是从新开始。

这就可能会产生两个问题:

  • 饿死:正在实验中的方案是重用,也就是说高优先级的操作如果没有修改低优先级操作已经完成的节点,那么这部分工作是可以重用的。

  • 一次渲染可能会调用多次声明周期函数

生命周期

在 react v16.3 之前的生命周期

16.3

在出现了 fiber 算法后,这个生命周期函数的组合在 Fiber 之后就显得不合适了。因为,如果要开启 async rendering,在 render 函数之前的所有函数,都有可能被执行多次。比如说,当一个低优先级的 componentWillUpdate 执行之后,被高优先级的打断,高优先级执行完之后,再回到低优先级的操作中来,componentWillUpdate 可能会再执行一次。对于某些只期望执行一次,或者需要在两个生命周期函数的操作中执行对称操作的情况而言,要考虑这种 case,确保不会让整个 App crash 掉。长期以来,原有的生命周期函数总是会诱惑开发者在 render 之前的生命周期函数做一些动作,现在这些动作还放在这些函数中的话,有可能会被调用多次,这肯定不是你想要的结果。

总有开发者问我,为什么不在 componentWillMount 里写 AJAX 获取数据的功能,他们的观点是,componentWillMount 在 render 之前执行,早一点执行早得到结果。要知道,在 componentWillMount 里发起 AJAX,不管多快,它还是一个异步,得到结果也赶不上首次 render,而且 componentWillMount 在服务器端渲染也会被调用到(当然,也许这是预期的结果),这样的 IO 操作放在 componentDidMount 里更合适。在 Fiber 启用 async render 之后,更没有理由在 componentWillMount 里做 AJAX,因为 componentWillMount 可能会被调用多次,谁也不会希望无谓地多次调用 AJAX 吧。

所以 react 在 v16.3 之后出现了新的生命周期用来解决上述情况。不管多么地苦口婆心教导开发者不要做什么不要做什么,都不如直接让他们干脆没办法做。

随着 getDerivedStateFromProps 的问世,同时 deprecate 了一组生命周期 API,包括:

  • componentWillReceiveProps

  • componentWillMount

  • componentWillUpdate

可以看到,除了 shouldComponentUpdate 之外,render 之前的所有生命周期函数全灭,就因为太多错用滥用这些生命周期函数的做法,预期追求对称的美学,不如来点实际的,让程序员断了在这些生命周期函数里做些不该做事情的念想。

至于 shouldComponentUpdate,如果谁还想着在里面做 AJAX 操作,那真的是没救了。

按照官方说法,以前需要利用被 deprecate 的所有生命周期函数才能实现的功能,都可以通过 getDerivedStateFromProps 的帮助来实现。

这个 getDerivedStateFromProps 是一个静态函数,所以函数体内不能访问 this,简单说,就是应该一个纯函数,纯函数是一个好东西啊,输出完全由输入决定。

static getDerivedStateFromProps(nextProps, prevState) {
  //根据nextProps和prevState计算出预期的状态改变,返回结果会被送给setState
}

看到这样的函数声明,应该感受到 React 的潜台词:老实做一个运算就行,别在这里搞什么别的动作。

每当父组件引发当前组件的渲染过程时,getDerivedStateFromProps 会被调用,这样我们有一个机会可以根据新的 props 和之前的 state 来调整新的 state,如果放在三个被 deprecate 生命周期函数中实现比较纯,没有副作用的话,基本上搬到 getDerivedStateFromProps 里就行了;如果不幸做了类似 AJAX 之类的操作,首先要反省为什么自己当初这么做,然后搬到 componentDidMount 或者 componentDidUpdate 里面去。

所有被 deprecate 的生命周期函数,目前还凑合着用,但是只要用了,开发模式下会有红色警告,在下一个大版本(也就是React v17)更新时会彻底废弃。

React v16.3 还引入了一个新的声明周期函数 getSnapshotBeforeUpdate,这函数会在 render 之后执行,而执行之时 DOM 元素还没有被更新,给了一个机会去获取 DOM 信息,计算得到一个 snapshot,这个 snapshot 会作为 componentDidUpdate 的第三个参数传入。

getSnapshotBeforeUpdate(prevProps, prevState) {
   console.log('#enter getSnapshotBeforeUpdate');
   return 'foo';
}

componentDidUpdate(prevProps, prevState, snapshot) {
  console.log('#enter componentDidUpdate snapshot = ', snapshot);
}

上面这段代码可以看出来这个 snapshot 怎么个用法,snapshot 咋看还以为是组件级别的某个“快照”,其实可以是任何值,到底怎么用完全看开发者自己,getSnapshotBeforeUpdate 把 snapshot 返回,然后 DOM 改变,然后 snapshot 传递给 componentDidUpdate。

官方给了一个例子,用 getSnapshotBeforeUpdate 来处理 scroll,坦白说,我也想不出其他更常用更好懂的需要 getSnapshotBeforeUpdate 的例子,这个函数应该大部分开发者都用不上(听得懂我的潜台词吗:不要用!)

所以,React v16.3 之后的生命周期函数一览图成了这样。

v16.3

可以注意到,说 getDerivedStateFromProps 取代 componentWillReceiveProps 是不准确的,因为 componentWillReceiveProps 只在 Updating 过程中才被调用,而且只在因为父组件引发的 Updating 过程中才被调用(往上翻看第一个图);而 getDerivedStateFromProps 在 Updating 和 Mounting 过程中都会被调用。

此外,从上面这个也看得出来,同样是 Updating 过程,如果是因为自身 setState 引发或者 forceUpdate 引发,而不是不由父组件引发,那么 getDerivedStateFromProps 也不会被调用。

这其实容易引发一些问题,不用仔细想,光是由此让开发者不得不理解这乱七八糟的差异,就可以知道这是一个大坑!

还好,React 很快意识到这个问题,在 React v16.4 中改正了这一点,改正的结果,就是让 getDerivedStateFromProps 无论是 Mounting 还是 Updating,也无论是因为什么引起的 Updating,全部都会被调用。

这样简单多了!所以,上面的生命周期函数一览图要改一改。

v16.4

静态函数 getDerivedStateFromProps 来取代被 deprecate 的几个生命周期函数,就是强制开发者在 render 之前只做无副作用的操作,而且能做的操作局限在根据 props 和 state 决定新的 state 而已。

Nice to meet you! React Fiber!