重新学习 React (一) 生命周期,Fiber 调度和更新机制

3,882 阅读8分钟

前几天面试问道 react 的相关知识,对我打击比较大,感觉对 react 认识非常肤浅,所以在这里重新梳理一下,想想之前没有仔细思考过的东西。

另外有说的不对的地方还请帮我指正一下,先谢谢各位啦。

目录索引:

什么是生命周期和调度?

React 有一套合理的运行机制去控制程序在指定的时刻该做什么事,当一个生命周期钩子被触发后,紧接着会有下一个钩子,直到整个生命周期结束。

生命周期

生命周期代表着每个执行阶段,比如组件初始化,更新完成,马上要卸载等等,React 会在指定的时机执行相关的生命周期钩子,使我们可以有机在程序运行中会插入自己的逻辑。

调度

我们写代码的时候往往会有很多组件以及他们的子组件,各自调用不同的生命周期,这时就要解决谁先谁后的问题,在 react v16 之前是采用了递归调用的方式一个一个执行,而在现在 v16 的版本中则采用了与之完全不同的处理(调度)方式,名叫 Fiber,这个东西 facebook 做了有两年时间,实现非常复杂。

具体 Fiber 它是一个什么东西呢?不要着急,我们先从最基本的生命周期钩子看起。

React 生命周期详解

首先看一下 React V16.4 后的生命周期概况(图片来源

  • 从横向看,react 分为三个阶段:
    • 创建时
      • constructor() - 类构造器初始化
      • static getDerivedStateFromProps() - 组件初始化时主动触发
      • render() - 递归生成虚拟 DOM
      • componentDidMount() - 完成首次 DOM 渲染
    • 更新时
      • static getDerivedStateFromProps() - 每次 render() 之前执行
      • shouldComponentUpdate() - 校验是否需要执行更新操作
      • render() - 递归生成虚拟 DOM
      • getSnapshotBeforeUpdate() - 在渲染真实 DOM 之前
      • componentDidUpdate() - 完成 DOM 渲染
    • 卸载时
      • componentWillUnmount() - 组件销毁之前被直接调用

一些干货

  • 有三种方式可以触发 React 更新,props 发生改变,调用 setState() 和调用 forceUpdate()
  • static getDerivedStateFromProps() 这个钩子会在每个更新操作之前(即使props没有改变)执行一次,使用时应该保持谨慎。
  • componentDidMount()componentDidUpdate() 执行的时机是差不多的,都在 render 之后,只不过前者只在首次渲染后执行,后者首次渲染不会执行
  • getSnapshotBeforeUpdate() 执行时可以获得只读的新 DOM 树,此函数的返回值为 componentDidUpdate(prevProps, prevState, snapshot) 的第三个参数

尝试理解 Fiber

关于 Fiber,强烈建议听一下知乎上程墨Morgan的 live 《深入理解React v16 新功能》,这里潜水员的例子和图片也是引用于此 live。

背景

我们知道 React 是通过递归的方式来渲染组件的,在 V16 版本之前的版本里,当一个状态发生变更时,react 会从当前组件开始,依次递归调用所有的子组件生命周期钩子,而且这个过程是同步执行的且无法中断的,一旦有很深很深的组件嵌套,就会造成严重的页面卡顿,影响用户体验。

React 在V16版本之前的版本里引入了 Fiber 这样一个东西,它的英文涵义为纤维,在计算机领域它排在在进程和线程的后面,虽然 React 的 Fiber 和计算机调度里的概念不一样,但是可以方便对比理解,我们大概可以想象到 Fiber 可能是一个比线程还短的时间片段。

Fiber 到底做了什么事

Fiber 把当前需要执行的任务分成一个个微任务,安排优先级,然后依次处理,每过一段时间(非常短,毫秒级)就会暂停当前的任务,查看有没有优先级较高的任务,然后暂停(也可能会完全放弃)掉之前的执行结果,跳出到下一个微任务。同时 Fiber 还做了一些优化,可以保持住之前运行的结果以到达复用目的。

举个潜水员的例子

我们可以把调度当成一个潜水员在海底寻宝,v16 之前是通过组件递归的方式进行寻宝,从父组件开始一层一层深入到最里面的子组件,也就是如下图所示。

而替换成了 Fiber 后,海底变成的狭缝(简单理解为递归变成了遍历),潜水员会每隔一小段时间浮出水面,看看有没有其他寻宝任务。注意此时没有寻到宝藏的话,那么之前潜水的时间就浪费了。就这样潜水员会一直下潜和冒泡,具体如下图所示。

引入 Fiber 后带来的三个阶段

从生命周期那张图片纵向来看,Fiber 将整个生命周期分成了三个阶段:

  • render 阶段
    • 由于 Fiber 会时不时跳出任务,然后重新执行,会导致该阶段的生命周期调用多次的现象,所以 React V16 之前 componentWillMount()componentWillUpdate()componentWillReceiveProps() 的三个生命周期钩子被加上了 UNSAFE 标记
    • 这个阶段效率不一定会比之前同步递归来的快,因为会有任务跳出重做的性能损耗,但是从宏观上看,它不断执行了最高优先级(影响用户使用体验)的任务,所以用户使用起来会比以前更加的流畅
    • 这个阶段的生命周期钩子可能会重复调用,建议只写无副作用的代码
  • pre-commit 阶段
    • 该阶段 DOM 已经形成,但还是只读状态
    • 这个阶段组件状态不会再改变
  • commit 阶段
    • 此时的 DOM 可以进行操作
    • 这个阶段组件已经完成更新,可以写一些有副作用的代码和添加其它更新操作。

简而言之:以 render() 为界,之前执行的生命周期都有可能会打断并多次调用,之后的生命周期是不可被打断的且只会调用一次。所以尽量把副作用的代码放在只会执行一次的 commit 阶段。

其它生命周期钩子

除了上面常用的钩子,React 还提供了如下钩子:

  • static getDerivedStateFromError() 在 render 阶段执行,通过返回 state 更新组件状态
  • componentDidCatch() 在 commit 阶段执行,可以放一些有副作用的代码

更新机制

理解了生命周期和三个执行阶段,就可以比较容易理解组件状态的更新机制了。

setState()

这个方法可以让我们更新组件的 state 状态。第一个参数可以是对象,也可以是 updater 函数,如果是函数,则会接受当前的 state 和 props 作为参数。第二个参数为函数,是在 commit 阶段后执行,准确的说是在 componentDidUpdate() 后执行。

setState() 的更新过程是异步的(除非绑定在 DOM 事件中或写在 setTimeout 里),而且会在最后合并所有的更新,如下:

Object.assign(
  previousState,
  {quantity: state.quantity + 1},
  {quantity: state.quantity + 1},
  ...
)

之所以设计成这样,是为了避免在一次生命周期中出现多次的重渲染,影响页面性能。

forceUpdate()

如果我们想强制刷新一个组件,可以直接调用该方法,调用时会直接执行 render() 这个函数而跳过 shouldComponentUpdate()

举个极端例子

function wait() {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve();
      console.log("wait");
    }, 0);
  });
}

//......省略组件创建
async componentDidMount() {
  await wait();
  this.setState({
    name: "new name"
  });
  console.log("componentDidMount");
}

componentDidUpdate() {
  console.log("componentDidUpdate");
}

render() {
  console.log(this.state);
  return null
}
//......省略组件创建

// 输出结果如下
// wait
// {name: "new name"}
// componentDidUpdate
// componentDidMount

// 注意 componentDidUpdate 的输出位置,一般情况下
// componentDidUpdate 都是在componentDidMount 后面
// 执行的,但是这里因为setState 写在了 await 后面
// 所以情况相反。

结语

了解 react 生命周期和更新机制确实有利于编写代码,特别是当代码量越来越大时,错用的 setState 或生命周期钩子都可能埋下越来越多的雷,直到有一天无法维护。。。

我的个人建议如下:

  • 把副作用代码通通放在 commit 阶段,因为这个阶段不会影响页面渲染性能
  • 尽可能不要使用 forceUpdate() 方法,借用 Evan You 的一句话,如果你发现你自己需要在 Vue 中做一次强制更新,99.9% 的情况,是你在某个地方做错了事
  • 只要调用了 setState() 就会进行 render(),无论 state 是否改变
  • 知道 setState() 更新的什么时候是同步的,什么时候是异步的,参见上文
  • 不要把 getDerivedStateFromProps() 当成是 UNSAFE_componentWillReceiveProps() 的替代品,因为 getDerivedStateFromProps() 会在每次 render() 之前执行,即使 props 没有改变