React Class 中即将消失的生命周期

这个主题是早前在公司分享的,主要讲的是 React Class Component 中的生命周期的变迁。但是一直没有时间整理,近来工作节奏逐渐恢复稳定,因此腾出时间来整理一下。这个期间看到 React Conf 2021 上黄玄还分享了 React Forget,有一些想法可能和当时分享的时候有所出入,但基本上还是一致的。

分享这个主题的背景是因为当时有不少新来的小伙伴是从 vue 刚转入 react 的,一上来就接触的是 hooks,反而对 react 传统的 Class Components 不熟悉,遑论生命周期的变更历史,因此就当做一个简单的科普。

引子

function App() {
	const [state, setState] = useState(false)
  const otherState = (() => {
    // ....some compute
  })()
  return <button onClick={() => setState(true)}>
    click {state} {otherState}
  </button>
}

这里点击按钮,想要避免 otherState 重新计算,大家都知道可以通过 useMemo 来包裹一下即可:

const otherState = useMemo(() => {
    // ....some compute
  }, []);

当时在分享的时候抛出的一个问题。那么为什么 react 不将 memo 的行为作为默认的行为?

那时候我说的是因为 memo 实际上是有成本的,不应滥用(不过后来 React Forget 表明似乎官方也想将 memo 作为默认的行为,打脸。。。)不过 memo 实际上是有成本的这个确实是没问题的,我们需要遍历 memo 的依赖,判断是否有变更等等。

按黄玄的说法就是开发体验和用户体验在当前有时候不能兼顾。

但其实一般情况下,某些语句的重复执行开销并不会很大,我们的页面发生严重卡顿,通常是 dom 层面上的原因。我将通过 React 生命周期的变更来论证这一点,那么开始我今天的分享。

简单回顾生命周期

目前我们使用的 React 只要是 17,过于远古的版本暂且不谈,我们以 16.3 这个重要的分水岭讲起。

我尝试画了张流程图,实在画的不好看,就直接在掘金上找了张图,在 16.3 之前

a27fy-pvcrd.jpeg

下面是一个简单的例子 https://codesandbox.io/s/v16-1-tik5l?file=/src/App.js

在这个例子中,挂载期间的生命周期输出 log 如下

parent will mount 
parent rendering 
child will mount 
child rendering 
child did mount 
parent did mount

随后我们点击按钮改变父组件状态,log 输出如下

parent should update 
parent will update 
parent rendering 
child will receive props 
child should update 
child will update 
child rendering 
child did update 
parent did update 

在 16.3 时, 如果我们开启了严格模式,依然执行上述代码 https://codesandbox.io/s/v16-3-1-2ixbx?file=/src/index.js,你会发现现在输出的日志中多了一些警告

Warning: Unsafe lifecycle methods were found within a strict-mode tree:


componentWillMount: Please update the following components to use componentDidMount instead: Child, Parent


componentWillReceiveProps: Please update the following components to use static getDerivedStateFromProps instead: Child, Parent


componentWillUpdate: Please update the following components to use componentDidUpdate instead: Child, Parent


Learn more about this warning here:

https://fb.me/react-strict-mode-warnings

意思就是说 componentWillMount / componentWillReceiveProps / componentWillUpdate 这三个生命周期是不安全的,要替换成别的生命周期。

为什么说这些生命周期是不安全的呢?同时有的同学可能发现 parent redering 和 child rendering 输出了数次,这是什么?这里暂且按下不表。

那么 16.3 的生命周期是怎么样的呢?生命周期2.jpg

这里注意 getDerivedStateFromProps 是静态的方法,这么设计的用意是什么?这里同样按下不表。

例子 https://codesandbox.io/s/v16-3-2-48ire?file=/src/Child.js log 输出如下

parent get derived state from props and this= null
parent rendering 
child get derived state from props and this= null
child rendering 
child did mount 
parent did mount 

点击变更父组件状态后输出

parent should update 
parent rendering 
child get derived state from props and this= null
child should update
child rendering 
child get snapshot before update 
parent get snapshot before update 
child did update 
parent did update 

同样,我们发现 getDerivedStateFromProps 以及 rendering 可能输出多次

那么 16.3 之后的生命周期是怎么样的呢?

生命周期3.jpg

可以发现无非是现在 getDerivedStateFromProps 也可以通过 setState 和 forceUpdate 触发了,这里就不赘述。

整理一下,至此我们一共有会有以下几个疑问:

  1. 为什么要移除 componentWillMount / componentWillReceiveProps /componentWillUpdate?为什么这些生命周期是不安全的?
  2. 为什么 redering / getDerivedStateFromProps 会输出多次
  3. 为什么 getDerivedStateFromProps 要设计成静态的方法,而 getSnapshotBeforeUpdate 则不是静态的方法

其实答案都在上面的生命周期图中。

Fiber

首先让我们来认识一下React 中的 Fiber

从 16.3 之前的生命周期,我们不难想到 react 的更新机制。

React 更新顺序.jpg

这是一个类似深度遍历的过程,当这颗树越发庞大,则这个调用栈就越深。只有最底层的调用返回,整个渲染的过程才逐步返回。

这个过程不可被打断,即大量的同步计算阻塞了浏览器,导致浏览器无法处理别的任何事务,比如用户的交互将无法及时响应,给用户的体验是页面卡顿或卡死。

这种更新机制,被称为 Stack Reconciler

Stack.png

为了解决这个问题,React 提出了新的架构,

  1. 将更新切分成多个任务每次执行完一次小任务就将控制权交给浏览器,在浏览器空闲的时间里执行之前未完成的任务。
  2. 任务有优先级的区分,高优先级的任务可以打断低优先级的任务,如用户键盘输入这样的任务就会打断 Diff 这样的任务,具体的分级方式就不在这里展开。

Fiber.png

打断的任务重新执行一边还是继续上次的断点呢?实际上 React 是让这些被中断的任务重新执行

那么是否所有的任务都可以被打断?React 认为可以被用户感知的任务是不可被打断的,而用户感知不到的任务则可被打断。

让我们回到上面生命周期的这张图

React 将生命周期分为 Render 与 Commit 阶段,Render 阶段的任务均可打断,而 Commit 阶段则不可以打断。

由此我们来回答上述几个问题

  1. 为什么移除 componentWillMount / componentWillReceiveProps /componentWillUpdate,为什么不安全?

    因为这些生命周期属于 render 阶段,但他们是不纯的,打断后重新执行,会造成一些 bug,举个例子:在 componentWillMount 发送请求,那么有可能就会导致请求两次。

  2. 为什么 redering / getDerivedStateFromProps 会输出多次?

    因为这些生命周期可被打断,会被重新执行

  3. 为什么 getDerivedStateFromProps 要设计成静态的方法,而 getSnapshotBeforeUpdate 则不是静态的方法

    因为 getDerivedStateFromProps 在 render 阶段,需要尽量保证它是纯的,静态方法没有 this,也就不会有如 this.setState 这样的操作了,而 getSnapshotBeforeUpdate 则是在 commit 阶段,不会被打断重启,且需要提供一些可以获取到 dom 的方法,如 this.ref.xxx,所以无需设计成静态方法。

####结尾

至此通过 react fiber 的设计验证了开篇提出的观点,React 其实也不认为重新执行代码会浪费很多性能,甚至允许多次打断重启,对与前端而言,更多的瓶颈其实是在 dom 的渲染上,而这个环节 React 也确实不允许多次执行。