Like us all, React components also take birth, grow, and die!
1. 前言 💬
React 的核心思想是组件化,即按功能封装成一个一个的组件,各个组件维护自己的状态和 UI,当状态发生变化时,会自动重新渲染整个组件,多个组件一起协作共同构成了 React 应用。React 组件分为函数组件和传统的 class 组件(类组件)两种形式,本篇文章要讨论的生命周期是和 class 组件相关的内容,因为只有 class 组件才有生命周期,class 组件会创建对应的实例,而函数组件是无状态,它无法实例化,没有任何的生命周期和方法。
2. 相关概念
什么是生命周期?生命周期的概念其实在各个领域中都广泛存在,广义来说生命周期泛指自然界和人类社会中各种客观事物的阶段性变化及其规律,在 React 框架中生命周期指的是组件的生命周期。
几乎就像地球上任何生物都是从出生 🐣 到最后消亡一样,每个 React 类组件也有从被创建到被销毁的过程,这一过程被称为组件的生命周期。React 通常将组件生命周期分为三个阶段:组件挂载(创建)、更新(存在)、卸载(销毁)。
React 除了将组件的生命周期划分为三个阶段外,还为开发者提供了一组生命周期方法,这些方法会在组件生命周期的不同时刻调用。通过这些方法我们能确定组件进入到了哪个阶段,从而能在 React 生命周期的各个阶段附加功能来完成不同的业务逻辑,控制应用程序中组件的行为。
3. 生命周期新旧版本对比
随着 React 版本的升级,React 组件生命周期函数也有一些渐进式的调整。React 组件生命周期的演变历程通常以 v16.3 为界限,即版本 16.3 前后存在两套生命周期,16.3 之前为旧版,之后则是新版。在本小节中,我们会从旧的版本来展开,然后进入到新的版本,对比新旧两个版本之间的差异,分析为什么需要对生命周期函数进行改进。
旧版生命周期图谱 🆚 新版生命周期图谱
新版生命周期图谱来源于 GitHub 生命周期方法图,单击图中的任何方法名称可以阅读其官方文档。
通过对比不难发现,React 在新的生命周期中废弃了 componentWillMount、componentWillReceiveProps 和 componentWillUpdate 这三个方法,新增了 getDerivedStateFromProps 和 getSnapshotBeforeUpdate 这两个方法。
当然,这个更替是缓慢的。已经废弃的这三个方法并不是说现在直接不能用了,在 React 新版本里我们仍然能无障碍的使用这三个旧的生命周期,但是官方会给出警告并推荐我们在这三个钩子前添加 UNSAFE_ 前缀,比如 UNSAFE_componentWillMount,且官方强调预计在后续版本可能只支持 UNSAFE_ 前缀写法。
为什么要废弃这三个生命周期函数呢?其实官方给出的警告就已经给这个问题作出了提示:它们是不安全的生命周期函数。这时你可能又会疑惑,为啥偏偏是这三个不安全呢?这其实和 React 新的 Fiber 架构有关。
Fiber 架构将 reconciler 执行的过程具体细分为了两个阶段:render / reconciliation phase 与 commit phase。 首先是 render 阶段,这个阶段主要是计算前后 Dom 树的差异,因为这个阶段耗时可能会很长,为了给其他更高优先级的任务提供插队执行的机会,因此该阶段的工作是可以被打断的,能够实现暂停、中止以及重新开始等增量渲染的能力。相反,commit 阶段的工作是不可中断的,这是因为在此阶段会进行真实 DOM 的更新操作,更新真实的 DOM 节点这个操作将导致用户可见的更改,所因此这个过程需要一气呵成不能中断,否则会造成使用者视觉上的不连贯。如果 render 阶段是因为遇到优先级更高的紧急更新任务而中断,那么当所有高优先级任务执行完之后, React 通过 callback 回到之前渲染到一半的组件,为了保证绝对靠谱,会从头开始重新渲染。从头开始重新渲染意味着可能会导致 commit 前的这些生命周期函数多次执行,从而带来不可预测的问题。而 componentWillMount、componentWillReceiveProps 和 componetWillUpdate 刚好不巧属于 commit 前的生命周期函数,如果我们在其中执行一些具有副作用的操作,例如发送网络请求,就有可能导致一个同样的网络请求被执行多次,这显然不是我们想看到的,因此 React 官方把它们标记为了 unsafe,并使用新的生命周期函数 getDerivedStateFromProps 和 getSnapshotBeforeUpdate 进行替换。
除了这三个生命周期函数可能会被多次执行以外,它们被废弃的原因还有一点,那就是这些生命周期函数很容易被误解和滥用。我们以 componentWillMount 为例说明,接触 React 稍微久一点的同学都知道,如果一个组件需要请求数据,那么这个数据请求应该放在 componentDidMout 中,但可能不少同学一开始都有过这样的疑惑,为什么不将数据请求放在componentWillMount 中呢?理论上来说,即将挂载就开始请求,早请求数据早回来,那这样还能减少数据未返回的白屏时间。想法是好的,但并不建议这样做,因为现实的实际情况是 render 在 willMount 之后几乎是马上就被调用,有可能根本等不到数据回来,所以同样需要 render 一次“加载中”的空数据状态,等数据请求完成后再重新 render 一次。而且假设我们有做服务端渲染,componentWillMount 会在服务端以及前端各自执行一次,但如果在 componentDidMout 中请求,则只会在前端请求一次,不会发送多余的数据请求。当然,也有同学会说,那我还是想在 componentWillMount 中初始化定义一些预加载的数据,但别忘了我们还有 constructor,一些数据初始化的操作就应该放在这个生命周期函数中处理。所以这样说下来,我们会发现 componentWillMount 的定义太模糊了,它能干的事另外两个生命周期函数其实都能代劳,那么留一个让开发者疑惑的方法有何意义呢?因此自然被干掉了。
由于可能会被多次执行,所以在使⽤传统的类组件进⾏开发时,React 建议大家不要在以上⼏个已经标记为 unsafe 的⽣命周期函数中做只需要做⼀次的操作。道理说了都明白,但是历史经验告诉我们,不管多么地苦口婆心教导开发者不要做什么不要做什么,都不如直接让他们干脆没办法做,进一步施加约束,防止开发者乱来。所以 React 就新增了一个静态的生命周期函数 getDerivedStateFromProps 来解决这个问题,这样开发者就无法通过 this 获取到组件的实例。它就是强制开发者在 render 之前只做无副作用的操作,而且能做的操作局限在根据 props 和 state 决定新的 state 而已,间接强制我们无法进行这些不合理不规范的操作,从而避免对生命周期的滥用。
完整的生命周期图谱
注:红色为废弃的生命周期函数,绿色为新增的生命周期函数
4. 生命周期运作流程
旧版
挂载执行的流程为:
- constructor
- componentWillMount
- render
- componentDidMount
更新阶段执行流程:
- componentWillReceiveProps
- shouldComponentUpdate
- componentWillUpdate
- render
- componentDidUpdate
更新阶段执行流程:componentWillUnmount
新版
挂载执行的流程为:
- constructor
- getDerivedStateFromProps
- render
- componentDidMount
更新阶段执行流程:
- getDerivedStateFromProps
- shouldComponentUpdate
- render
- getSnapshotBeforeUpdate
- componentDidUpdate
更新阶段执行流程:componentWillUnmount
5. 生命周期方法
React 中的生命周期方法在编写 React 组件时起着至关重要的作用。开发人员经常需要访问生命周期方法来处理各种副作用,例如在挂载时获取数据、在组件更新时更改 props、在组件卸载之前进行清理等。接下来,我们就来具体介绍一下这些生命周期函数及其对应的使用场景。
5.1 contructor
函数声明:constructor(props)
组件的构造函数,第一个被执行,若没有显式定义它,会有一个默认的构造函数,但是若显式定义了构造函数,我们必须在构造函数中执行 super(props),否则无法在构造函数中拿到 this
constructor(props) {
super(props);
// 不要在这里调用 this.setState()
this.state = { counter: 0 };
this.handleClick = this.handleClick.bind(this);
}
使用场景
通常,在 React 中,构造函数仅用于以下两种情况:
- 通过给 this.state 赋值对象来初始化内部 state。( 唯一可以直接修改 state 的地方)
- 为事件处理函数绑定实例
注意事项
不能在 constructor 构造函数内部调用 this.setState(),因为此时第一次 render() 还未执行,也就意味 DOM 节点还未挂载。
5.2 render
函数声明:render()
React 类组件中最核心的方法,一个类组件中必须要有这个方法。render 函数应该为纯函数,它只做一件事,那就是根据状态 state 和属性 props 返回需要渲染的内容,对于相同的 state 和 props,它应该总是返回相同的渲染结果,所以不要在这个函数内做其他业务逻辑。通常调用该方法会返回以下类型之一:
- React 元素:这里包括原生的标签以及 React 组件。通常为 JSX 语法,例如
<div />、<MyComponent>等; - 数组或 Fragment:返回多个元素;
- Portals:可以将子元素渲染到不同的 DOM 子树中;
- 字符串和数字:在 DOM 中会被渲染为文本节点;
- 布尔值或 null:不会渲染任何东西。
使用场景
render 函数在什么情况下会被调用?当且仅当下面三种情况:组件初始化、组件的 state 和 props 发生改变以及组件内调用 forceUpdate 函数。
虽然组件的 render 函数只在上述 3 种情况下才发生调用,但有时 props 的改变却很难察觉到。比如,组件 B 的父级组件A 如果调用了 render 函数,则组件 B 一定也会调用 render 函数,因为组件 B 的 props 一定会发生改变,即使该 props 与之前的 props 相比较而言,属性和值都没有发生变化,但这个 props 对象的地址却发生了变化。
注意事项
不要在 render 里面 setState, 否则会触发死循环导致内存崩溃;
5.3 componentDidMount
函数声明:componentDidMount()
该生命周期方法会在组件挂载之后执行,也就是将组件对应的 DOM 插入 DOM 树中之后立即调用。因此,在这里可以安全操作 DOM 节点。并且它的调用是发生在浏览器更新视图之前,这意味着如果在 componentDidMount 中直接调用this.setState 触发额外的渲染,就算会再一次调用 render 函数,但是浏览器中视图的更新只会执行一次。尽管如此,我们还是应当在开发中避免这样使用,因为这样可能会带来一定的性能问题。
使用场景
依赖于 DOM 的初始化操作应该放在这里,此外,我们一般在这个生命周期方法中发送网络请求、添加订阅等。
5.4 getDerivedStateFromProps 🆕
函数声明:static getDerivedStateFromProps(nextProps, prevState)
返回值:返回一个对象来更新 state,如果返回 null 则不更新任何内容
derived 衍生的,派生的,那么翻译过来,这个生命周期函数的作用其实就是从 props 中获取衍生的 state,具体是什么意思呢?我们通过一个例子了解这个函数的作用:
// 父组件 Echo
class Echo extends Component {
state={
name:'echo'
}
render() {
return (
<div className="parent">
// 调用子组件 B
<B name={this.state.name}/>
</div>
)
}
}
// 子组件 B
class B extends Component {
// 注意,声明此钩子必须添加 static
static getDerivedStateFromProps(props) {
return props;
}
render() {
console.log(this.state)
return (
<div>
我的名字是:{this.state.name}
</div>
)
}
}
这个例子中,我们从父组件将 this.state.name 作为 props 传递给子组件。注意,子组件并没有声明 state,在getDerivedStateFromProps 中我们接受了父组件的 props 同时返回,结果可以看到最终 render 处输出的 state 居然就是传递的 props。
先说结论,getDerivedStateFromProps 中返回一个对象用于更新当前组件的 state,比如上面的例子你没 state,那我直接就将返回的 props 作为 state,那么假设我有自己的 state,且对象的 key 不一致会怎么样?看个例子:
class Echo extends Component {
state={
name:'echo',
age:17
}
render() {
return (
<div className="parent">
<B name={this.state.name} age={this.state.age}/>
</div>
)
}
}
class B extends Component {
state={
color:'red',
}
// 注意,声明此钩子必须添加static
static getDerivedStateFromProps(props) {
return props;
}
render() {
console.log(this.state)
return (
<div>
我的名字是:{this.state.name}
</div>
)
}
}
在上述例子中,我们传递了 name 与 age 给子组件,而子组件也有自己的 state,只是值是 color,在传递后我们发现并不是 props 直接替代了子组件的 state,而是与现有子组件的 state 进行了融合。
所以到这里我们能确定 getDerivedStateFromProps 函数返回的对象确实是更新当前组件的 state,但它并不是直接取代原来的 state ,假设你啥也没有,那直接用我的,如果你有那咱们就融合,同名的 key 我帮你覆盖更新,没有的属性那就直接用我给你的,大概如此。
getDerivedStateFromProps 不仅是在更新阶段会被调用,在挂载阶段也会被调用,这是因为派生 state 的诉求不仅仅在更新时存在,在初始化 state 时也会有需求。通过该方法派生 state 不会引起 render 函数重复执行。以此来看,该方法的出现不是简单的替换逻辑,而是有着承载简化代码的期望。
注意事项
getDerivedStateFromProps 前面要加上 static 保留字,声明其为静态方法,否则会被 react 忽略掉。静态方法无权访问 class 实例的 this,所以 getDerivedStateFromProps 里的 this 是 undefined。
5.5 shouldComponentUpate
函数声明:shouldComponentUpdate(nextProps, nextState)
在讲这个生命周期函数之前,我们先来探讨两个问题:
- setState 函数在任何情况下都会导致组件重新渲染吗?
- 如果没有调用 setState,props 值也没有变化,是不是组件就不会重新渲染?
首先来解答下这两个问题,第一个问题答案是会 ,第二个问题答案为:如果是父组件重新渲染时,不管传入的 props 有没有变化,都会引起子组件的重新渲染。具体原因在介绍 render 函数的时候有相关说明。
也就是说,React 的实际渲染行为并不符合我们的期望:当 state 和 props 值没有变化时组件不重新渲染。那么有没有什么方法能够解决在这样的场景下不让组件重新渲染进而提升性能呢?这个时候 shouldComponentUpdate 这一生命周期函数登场了,该方法会在渲染执行之前被调用,返回值默认为 true。跟它的名字含义一样,它用来判断一个组件是否应该更新。shouldComponentUpdate(nextProps, nextState) 通过比较 this.props 和 nextProps ,this.state 和 nextState 值是否变化,来确认返回 true 或者 false。当返回 true 时当前组件会继续执行更新过程;当返回 false 时,组件的更新过程就会停止,以此来减少组件不必要的渲染。
注意事项
- 它并不会阻止子组件因为 state 改变而导致的更新;
- 首次渲染或使用 forceUpdate方法 时不会调用该方法;
- 添加 shouldComponentUpdate 方法时,不建议使用深度相等检查(如使用 JSON.stringify()),因为深比较的效率很低,可能会比重新渲染组件效率还低,而且该方法维护比较困难,建议使用该方法会产生明显的性能提升时使用;
- 目前,如果 shouldComponentUpdate函数返回 false,则 componentWillUpdate、render 和 componentDidUpdate 不会被调用。但将来 React 可能会将 shouldComponentUpdate 其视为提示而不是严格的指令,并且返回 false 仍可能导致组件的重新渲染。
使用场景
此方法仅作为性能优化的方式而存在。
5.6 getSnapshotBeforeUpdate 🆕
函数声明:getSnapshotBeforeUpdate(prevProps, prevState)
snapshot 快照,顾名思义,这个生命周期函数的意思其实就是在组件更新前获取快照。
该函数会在最近一次的渲染输出被提交至 DOM 树之前调用。即 render 之后,componentDidUpdate 之前,也就是即将对组件进行挂载之前。此时 DOM 树还未改变,因此我们可以在这里获取 DOM 改变前的信息(旧的 DOM 信息)。
从函数声明可知,getSnapshotBeforeUpdate 接收两个参数,分别是:prevProps、prevState,表示更新之前的 props和 state。getSnapshotBeforeUpdate 生命周期函数本身不会起什么作用,它需要与 componentDidUpdate 结合在一起使用:如果组件实现了 getSnapshotBeforeUpdate 生命周期函数,则 getSnapshotBeforeUpdate 的返回值将作为componentDidUpdate 的第三个参数。
使用场景
这个生命周期的使用场景并不常见,但它可能出现在 UI 处理中,如需要以特殊方式处理滚动位置的聊天线程:对于聊天应用程序来说,每当消息数量超过聊天窗口的高度时,预期行为应该是自动向下滚动聊天窗格,以便看到最新的聊天消息。
5.7 componentDidUpdate
函数声明:componentDidUpdate(prevProps, prevState, snapshot)
componentDidUpdate 接收三个参数,分别是 prevProps、prevState、snapshot,即:前一个状态的 props,前一个状态的 state、getSnapshotBeforeUpdate 的返回值。该生命周期函数的执行时机是和 componentDidMount 一致的,只是componentDidMount 是在首次渲染时调用,而 componentDidUpdate 是在后续的组件更新时调用。
使用场景
在这个生命周期方法中,可以访问并操作 DOM 或者进行网络请求,也可以直接调用 setState。
注意事项
如果在该生命周期函数中直接调用 setState,那么 setState 调用必须被包裹在一个条件语句里,否则会导致死循环。因为每当 setState 被调用时,组件会重新渲染。当组件重新渲染时,componentDidMount 会再次被调用,从而再次触发 setState,从而导致无限循环。
5.8 componentWillUnmount
函数声明:componentWillUnmount()
在组件即将被卸载或销毁时进行调用。
使用场景
通常用来执行组件的清理操作,也就是资源释放,例如:清除定时器、取消网络请求、移除监听事件等。
注意事项
不应该在这个生命周期函数中使用 setState,因为组件一旦被卸载,就不会再装载,也就不会重新渲染。
6. 建议用法总结
- 初始化 state:constructor
- 请求异步加载的初始数据:componentDidMount,原因在生命周期新旧版本 🆚 中有提到
- 添加事件监听:componentDidMount,
因为在 React 只能保证 componentDidMount - componentWillUnmount 成对出现,componentWillMount 可以被打断或调用多次,因此无法保证事件监听能在 unmount 的时候被成功卸载,可能会引起内存泄露 - 根据 props 更新 state:getDerivedStateFromProps(nextProps, prevState)
- 触发请求:在生命周期中由于 state 的变化触发请求,在 componentDidUpdate 中进行
- props 更新引起的副作用:只希望触发一次的副作用应该放在保证只触发一次的 componentDidUpdate 中
- 在更新前记录原来的 dom 节点属性:getSnapshotBeforeUpdate(prevProps, prevState)
- 资源释放:在 componentWillUnmount 中清除定时器、移除监听事件等清理操作