React新版本为什么要移除掉一些生命周期?

从上图新版本React的生命周期来看,React废弃了以下三种生命周期钩子:

  • componentWillMount
  • componentWillReceiveProps
  • componentWillUpdate

下面我们来逐一分析它们被废弃的原因:

componentWillReceiveProps

此方法将会被getDerivedStateFromProps这一静态方法取代,通过返回一个对象来表示新的state。

看似并无区别,但使用getDerivedStateFromProps的原因在于对API的进一步解耦。
此方法是静态的,所以无法获取或者执行实例上的其他副作用函数,只专注于根据当前的nextProps来更新组件的state

原来的componentWillReceiveProps函数内,this上的其余副作用函数可以在componentDidUpdate中进行。

一方面,React通过API规范来约束开发者,强调代码书写的规范性
另一方面,通过将状态变化副作用分离到Fiber架构的ReconciliationCommit两个阶段,优化性能(详情见下文)。

componentWillMount

很多开发者喜欢在componentWillMount中获取异步数据,希望可以提早进行异步请求,尽量避免白屏。首先,这一想法不无道理,分两种情况考虑:

  • 立即获取数据,在第一次render之前处理完成,避免白屏
  • 异步获取数据,第二次render进行有效绘制,与componentDidMount相比白屏时间缩短

当然,官方对于这一解释是:componentWillMountrendercomponentDidMount 方法虽然存在调用先后顺序。
但在大多数情况下,几乎都是在很短的时间内先后执行完毕,几乎不会对用户体验产生影响。

看样子在componentWillMount似乎并无不妥之处,反而还可能会优化效率,为什么会被废除呢?主要原因有以下几点:

  • 对于服务器渲染(ssr),在componentWillMount内获取数据可以保证返回的页面是最终页面,但存在一个问题:客户端渲染时会再次请求,会浪费IO资源
  • 对于服务器渲染(ssr),在componentWillMount中绑定事件,但由于并没有后续的生命周期,导致资源无法释放,可能产生内存泄露
  • 客户端渲染也有可能产生上面两个问题,在Fiber架构中,componentWillMount所在的Reconciliation阶段可能被多次打断,可能产生多次网络请求或多次事件监听(详情见下文)。

componentWillUpdate

componentWillUpdate也是如此:

  • componentWillUpdate可能被多次打断,在这个钩子获取更新前的视图情况或执行副作用都不妥
  • getSnapshotBeforeUpdate是真正在视图变更前调用的,获取到组件状态信息更加可靠;
    另一方面getSnapshotBeforeUpdate的返回结果可直接作为参数传入componentDidUpdate中。

Fiber核心架构

从上面三个生命周期的移除都可以看到Fiber架构的身影,下面我们就来深入了解下:

React新版本的到来,与之相应的是核心架构的替换和异步渲染概念的引入。

React框架的视图更新取决于virtual domdiff算法,找到变化之后再将新的virtual dom渲染到不同视图(如android、pc),这一通用的过程称为Reconciler

旧版本的React使用的是Stack Reconciler,新版本采用的是Fiber Reconciler,因为其中任务调度处理的最小单元为Fiber数据结构:

// Fiber 基于链表结构,拥有一个个指针,指向它的父节点子节点和兄弟节点。
// 在 diff 的过程中,依照节点的链接关系进行遍历
Fiber = {
 'tag'       // 标记任务节点类型
 'return'    // 父节点
 'child'     // 子节点
 'sibling'   // 兄弟节点
 'alternate' // 变化记录
 // .....
};

两者的主要区别在于: 相比于Stack Reconciler的递归调用渲染,虽然diff算法被React优化为O(n)复杂度,但对于特别庞大的dom树来说,递归调用依旧会消耗特别长的时间,在这期间任何交互都会被阻塞。

Fiber Reconciler引入了异步渲染的概念,虽然也是根据Fiber数据结构进行链式处理,但可以将其切割为一个个小任务,异步进行处理,避免堵塞高优先级的交互等事件:

Fiber的出现把Reconciler的过程拆分成了一个个的小任务,并在完成了小任务之后暂停执行,检查是否有高优先级需要更新的内容和需要响应的事件,做出相应的处理后再继续执行。

Fiber还会为不同的任务设置不同的优先级:

  • 高优先级任务是需要马上展示到页面上的,如用户交互动画等。
  • 低优先级的任务如网络请求state变更等,可以在后面进行延迟处理。
    当然React会为其指定阈值,避免长期被高优先级打断。
// 一些优先级划分参考如下:   
{     
  Synchronous: 1, // 同步任务,优先级最高           
  Task: 2,        // 当前调度正执行的任务         
  Animation 3,    // 动画         
  High: 4,        // 高优先级         
  Low: 5,         // 低优先级          
  Offscreen: 6,   // 当前屏幕外的更新,优先级最低  
}    

Fiber阶段

前面说了Fiber算法中更新是分阶段的,首先是Reconciliation阶段,这个阶段在diff前后virtual dom树的差异,耗时过长,可以打断;然后是Commit的阶段,这个阶段将一直把更新渲染到页面上。

Reconciliation阶段有那些生命周期呢?没错,废除的三个生命周期赫然在列:

  • componentWillMount
  • componentWillReceiveProps
  • componentWillUpdate
  • shouldComponentUpdate,纯函数不会移除

推荐阅读

零代码深入浅出React并发模式,带你理解React Fiber架构

事件循环 – Fiber架构的实现原理

Fiber架构的异步渲染依赖的是浏览器底层的事件循环

我们知道浏览器的持续渲染页面依赖的就是事件循环机制,当页面文件解析后、脚本执行,会形成各种队列,之后就开始了页面的事件循环:

  • 各种宏任务队列(取一任务) => 微任务队列(全部执行) => 渲染(可能) => 计算空闲时间

上述循环基本完成在一帧(和浏览器刷新率有关,一般为60HZ或者更高)内,渲染阶段根据是否有足够时间选择是否执行。

为了保证页面的流畅度,渲染帧数要保证在每秒60左右(和刷新率无关,当然高刷新率的渲染帧数一般会更高)

由上述可知,这个渲染阶段是不可控的。而之前使用setTimeInterval来绘制动画:

  • 一方面可能被其他任务堵塞,造成延迟,或者在浏览器下次重绘之前调用多次,导致掉帧
  • 另一方面固定的间隔在不同刷新率设备的适配上也有一定问题。

于是浏览器暴露了一些接口来细粒化地控制事件循环的绘制:requestAnimationFramerequestIdleCallback

frame

requestAnimationFrame

简单来说,requestAnimationFrame内的回调函数会在浏览器下一次重绘之前执行,完美地解决了上述setTimeInterval的问题,而且如果标签页被隐藏,回调也会被暂停调用以提升性能和电池寿命。

具体应用时需要注意两点:

  • requestAnimationFrame只会要求浏览器在下一次重绘之前调用指定的回调函数,连续需要递归
  • 在同一个帧中的多个requestAnimationFrame,它们的时间戳相同

requestIdleCallback

由上述事件循环可知,每完成一次循环浏览器都会进行一次空闲时间的计算,而requestIdleCallback中的回调就将在这段时间内执行。

这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。函数一般会按先进先调用的顺序执行,然而,如果回调函数指定了执行超时时间timeout,则有可能为了在超时前执行函数而打乱执行顺序。

强烈建议使用timeout选项进行必要的工作,否则可能会在触发回调之前经过几秒钟。 摘自MDN

注意:当页面无其他任务时,requestIdleCallback执行的周期会被适当拉长,但最长只能为50ms,以防出现不可预测的任务(如用户输入)来临时无法及时响应可能会引起用户感知到的延迟

requestIdleCallback

推荐阅读

Web 动画帧率(FPS)计算
浏览器帧原理剖析

Fiber 架构渲染流程

reconciler

由此我们可以推断Fiber Reconciler大致的工作原理:

  • 首次渲染执行,维护一个virtual dom,节点为Fiber数据结构,指向其他节点。
  • 每次事件循环进入更新,在Reconciliation阶段,逐节点遍历,进行Diff、更新节点后,递归生成下一节点。
  • 如果有其他优先级更高的任务,中断执行将控制权交由主线程,继续事件循环,之后再重新构建该节点,直到所有节点更新完毕。
  • 进入Commit阶段,将新生成的virtual dom一次绘制到页面上。

推荐阅读

[译] 深入了解 React Fiber 内部实现
这可能是最通俗的 React Fiber(时间分片) 打开方式

关于Fiber具体实现的一些问题与思考

  • requestIdleCallback的执行次数是可变的。如果页面正常60帧运行,其执行次数最多为60,可以保证正常绘制;
    但如果页面空闲呢?页面在1秒内只会低帧率运行,而requestIdleCallback的执行周期也被延长到最大50ms,1秒内只执行20次。
    于是React对requestIdleCallback进行了hack,源码解析见:

  • 为什么要先ReconciliationCommit?换句话说,为什么要先diff再patch?看似这里一次循环就可以执行,没必要分开进行两次。
    其实在旧树的基础上新生成一颗WIP树,可以将其类比为git的分支,只有分支的功能完全实现且没有错误时,才会合并到主分支。如果有节点抛出异常,还可以复用旧节点

  • Fiber真的有用吗?Fiber的意义在哪里?
    我们回想一下Fiber架构诞生的原因是什么,为了避免在Reconciliation(diff)的时候,调用栈同步执行消耗大量CPU执行时间,导致堵塞。这里存在两个问题:

    • Reconciliation会消耗16ms以上是一个不常见的场景,甚至除非是在进行动画,否则100ms内的延迟用户都是无感知的;
    • Fiber只是保证diff过程异步进行,但进行渲染消耗的时间任然是一次性的、不变的。
    • 对于React中或许在大应用会有一些性能优化,但大部分场景无影响,反而开发这样一个架构的工作量很大。
    • Vue通过模板编译依赖变更策略在前期优化了很多性能,这也是为什么Vue3中移除了time slicing
  • Fiber的异步思想我们应该很熟悉了,相比于自己开发这样一个架构,为什么不采用类似WebWorker之类的多线程进行diff呢?这样优化不仅适应于React,对其他类似框架也同样适用。详情见:

参考