浅谈 React 生命周期
作为一个合格的React ,它的生命周期是我们必须得了解的,本文将会以下几个方面介绍React 生命周期:
- 新旧生命周期函数的对比
- 详解各个生命周期函数
- 生命周期函数的执行顺序
- Hooks 与 生命周期函数的对应关系
旧版的生命周期
如图所示,我们可以看到,在组件第一次挂载时会经历:
constructor -> componentWillMount -> render -> componentDidMount
组件更新时会经历:
componentWillReceiveProps -> shouldComponentUpdate -> componentWillUpdate -> render -> componentDidUpdate
组件卸载时执行:componentWillUnmount
新版的生命周期
如图所示,我们可以看到,在组件第一次挂载时会经历:
constructor -> getDerivedStateFromProps -> render -> componentDidMount
组件更新时会经历:
getDerivedStateFromProps -> shouldComponentUpdate -> render -> getSnapshotBeforeUpdate -> componentDidUpdate
组件卸载时执行:componentWillUnmount
从以上生命周期的对比,我们不难看出,React废弃 componentWillMount componentWillReceiveProps componentWillUpdate 三个钩子函数,接下来我们先分别介绍各个生命周期函数。
详解各个生命周期函数
constructor
constructor(props)
如果不初始化 state 或不进行方法绑定,则不需要为 React 组件实现构造函数。
在 React 组件挂载之前,会调用它的构造函数。在为 React.Component 子类实现构造函数时,应在其他语句之前调用 super(props) 。否则,this.props 在构造函数中可能会出现未定义的 bug。
通常,在 React 中,构造函数仅用于以下两种情况:
- 通过给
this.state 赋值对象来初始化内部 state 。 - 为事件处理函数绑定实例
在 constructor() 函数中不要调用 setState() 方法。如果你的组件需要使用内部 state ,请直接在构造函数中为 this.state 赋值初始 state:
constructor(props) {
super(props);
this.state = { counter: 0 };
this.handleClick = this.handleClick.bind(this);
}
getDerivedStateFromProps
static getDerivedStateFromProps(props, state)
getDerivedStateFromProps 会在调用 render 方法之前调用,并且在初始挂载及后续更新时都会被调用。它应返回一个对象来更新 state ,如果返回 null 则不更新任何内容。
此方法适用于罕见的用例,即 state 的值在任何时候都取决于 props 。例如,实现 <Transition> 组件可能很方便,该组件会比较当前组件与下一组件,以决定针对哪些组件进行转场动画。
派生状态会导致代码冗余,并使组件难以维护。 确保你已熟悉这些简单的替代方案:
此方法无权访问组件实例。如果你需要,可以通过提取组件 props 的纯函数及 class 之外的状态,在getDerivedStateFromProps() 和其他 class 方法之间重用代码。
render
render() 方法是 class 组件中唯一必须实现的方法。
当 render 被调用时,它会检查 this.props 和 this.state 的变化并返回以下类型之一:
- React 元素。通常通过 JSX 创建。例如,
<div /> 会被 React 渲染为 DOM 节点,<MyComponent /> 会被 React 渲染为自定义组件,无论是 <div /> 还是 <MyComponent /> 均为 React 元素。 - 数组或 fragments。 使得 render 方法可以返回多个元素。欲了解更多详细信息,请参阅 fragments 文档。
- Portals。可以渲染子节点到不同的 DOM 子树中。欲了解更多详细信息,请参阅有关 portals 的文档
- 字符串或数值类型。它们在 DOM 中会被渲染为文本节点
- 布尔类型或
null 。什么都不渲染。(主要用于支持返回 test && <Child /> 的模式,其中 test 为布尔类型。)
render() 函数应该为纯函数,这意味着在不修改组件 state 的情况下,每次调用时都返回相同的结果,并且它不会直接与浏览器交互。
注意
如果 shouldComponentUpdate() 返回 false,则不会调用 render() 。
不要在 render 里面 setState , 否则会触发死循环导致内存崩溃
componentDidMount
componentDidMount() 会在组件挂载后(插入 DOM 树中)立即调用。依赖于 DOM 节点的初始化应该放在这里。如需通过网络请求获取数据,此处是实例化请求的好地方。
这个方法是比较适合添加订阅的地方。如果添加了订阅,请不要忘记在 componentWillUnmount() 里取消订阅。
你可以在 componentDidMount() 里直接调用 setState() 。它将触发额外渲染,但此渲染会发生在浏览器更新屏幕之前。如此保证了即使在 render() 两次调用的情况下,用户也不会看到中间状态。请谨慎使用该模式,因为它会导致性能问题。通常,你应该在 constructor() 中初始化 state。如果你的渲染依赖于 DOM 节点的大小或位置,比如实现 modals 和 tooltips 等情况下,你可以使用此方式处理。
shouldComponentUpdate
shouldComponentUpdate(nextProps, nextState)
根据 shouldComponentUpdate() 的返回值,判断 React 组件的输出是否受当前 state 或 props 更改的影响。默认行为是 state 每次发生变化组件都会重新渲染。大部分情况下,你应该遵循默认行为。
当 props 或 state 发生变化时,shouldComponentUpdate() 会在渲染执行之前被调用。返回值默认为 true 。首次渲染或使用 forceUpdate() 时不会调用该方法。
此方法仅作为**性能优化的方式而存在。不要企图依靠此方法来“阻止”渲染,因为这可能会产生 bug。你应该考虑使用内置的 PureComponent 组件**,而不是手动编写 shouldComponentUpdate() 。PureComponent 会对 props 和 state 进行浅层比较,并减少了跳过必要更新的可能性。
如果你一定要手动编写此函数,可以将 this.props 与 nextProps 以及 this.state 与nextState 进行比较,并返回 false 以告知 React 可以跳过更新。请注意,返回 false 并不会阻止子组件在 state 更改时重新渲染。
不建议在 shouldComponentUpdate() 中进行深层比较或使用 JSON.stringify() 。这样非常影响效率,且会损害性能。
getSnapshotBeforeUpdate
getSnapshotBeforeUpdate(prevProps, prevState)
getSnapshotBeforeUpdate() 在最近一次渲染输出(提交到 DOM 节点)之前调用。它使得组件能在发生更改之前从 DOM 中捕获一些信息(例如,滚动位置)。此生命周期方法的任何返回值将作为参数传递给 componentDidUpdate() 。
此用法并不常见,但它可能出现在 UI 处理中,如需要以特殊方式处理滚动位置的聊天线程等。
应返回 snapshot 的值(或 null )。
例如:
class ScrollingList extends React.Component {
constructor(props) {
super(props);
this.listRef = React.createRef();
}
getSnapshotBeforeUpdate(prevProps, prevState) {
if (prevProps.list.length < this.props.list.length) {
const list = this.listRef.current;
return list.scrollHeight - list.scrollTop;
}
return null;
}
componentDidUpdate(prevProps, prevState, snapshot) {
if (snapshot !== null) {
const list = this.listRef.current;
list.scrollTop = list.scrollHeight - snapshot;
}
}
render() {
return (
<div ref={this.listRef}>{}</div>
);
}
}
在上述示例中,重点是从 getSnapshotBeforeUpdate 读取 scrollHeight 属性,因为 “render” 阶段生命周期(如 render )和 “commit” 阶段生命周期(如 getSnapshotBeforeUpdate 和 componentDidUpdate )之间可能存在延迟。
componentDidUpdate
componentDidUpdate(prevProps, prevState, snapshot)
componentDidUpdate() 会在更新后会被立即调用。首次渲染不会执行此方法。
当组件更新后,可以在此处对 DOM 进行操作。如果你对更新前后的 props 进行了比较,也可以选择在此处进行网络请求。(例如,当 props 未发生变化时,则不会执行网络请求)。
componentDidUpdate(prevProps) {
if (this.props.userID !== prevProps.userID) {
this.fetchData(this.props.userID);
}
}
你也可以在 componentDidUpdate() 中直接调用 setState() ,但请注意它必须被包裹在一个条件语句里,正如上述的例子那样进行处理,否则会导致死循环。它还会导致额外的重新渲染,虽然用户不可见,但会影响组件性能。
如果组件实现了 getSnapshotBeforeUpdate() 生命周期(不常用),则它的返回值将作为 componentDidUpdate() 的第三个参数 “snapshot” 参数传递。否则此参数将为 undefined。
注意
如果 shouldComponentUpdate() 返回值为 false,则不会调用 componentDidUpdate() 。
componentWillUnmount
componentWillUnmount() 会在组件卸载及销毁之前直接调用。在此方法中执行必要的清理操作,例如,清除 timer,取消网络请求或清除在 componentDidMount() 中创建的订阅等。
componentWillUnmount() 中不应调用 setState() ,因为该组件将永远不会重新渲染。组件实例卸载后,将永远不会再挂载它。
过时的生命周期方法
以下生命周期方法标记为“过时”。这些方法仍然有效,但不建议在新代码中使用它们。
UNSAFE_componentWillMount
UNSAFE_componentWillMount()
注意
此生命周期之前名为 componentWillMount 。该名称将继续使用至 React 17。
UNSAFE_componentWillMount() 在挂载之前被调用。它在 render() 之前调用,因此在此方法中同步调用 setState() 不会触发额外渲染。通常,我们建议使用 constructor() 来初始化 state。
避免在此方法中引入任何副作用或订阅。如遇此种情况,请改用 componentDidMount() 。
此方法是服务端渲染唯一会调用的生命周期函数。
UNSAFE_componentWillReceiveProps
UNSAFE_componentWillReceiveProps(nextProps)
注意
此生命周期之前名为 componentWillReceiveProps 。该名称将继续使用至 React 17。
使用此生命周期方法通常会出现 bug 和不一致性:
UNSAFE_componentWillReceiveProps() 会在已挂载的组件接收新的 props 之前被调用。如果你需要更新状态以响应 prop 更改(例如,重置它),你可以比较 this.props 和 nextProps 并在此方法中使用 this.setState() 执行 state 转换。
请注意,如果父组件导致组件重新渲染,即使 props 没有更改,也会调用此方法。如果只想处理更改,请确保进行当前值与变更值的比较。
在挂载过程中,React 不会针对初始 props 调用 UNSAFE_componentWillReceiveProps() 。组件只会在组件的 props 更新时调用此方法。调用 this.setState() 通常不会触发 UNSAFE_componentWillReceiveProps() 。
UNSAFE_componentWillUpdate
UNSAFE_componentWillUpdate(nextProps, nextState)
注意
此生命周期之前名为 componentWillUpdate 。该名称将继续使用至 React 17。
当组件收到新的 props 或 state 时,会在渲染之前调用 UNSAFE_componentWillUpdate() 。使用此作为在更新发生之前执行准备更新的机会。初始渲染不会调用此方法。
注意,你不能此方法中调用 this.setState() ;在 UNSAFE_componentWillUpdate() 返回之前,你也不应该执行任何其他操作(例如,dispatch Redux 的 action)触发对 React 组件的更新
通常,此方法可以替换为 componentDidUpdate() 。如果你在此方法中读取 DOM 信息(例如,为了保存滚动位置),则可以将此逻辑移至 getSnapshotBeforeUpdate() 中。
那么为什么要弃用它们呢?
原因
弃用 componentWillMount 方法的原因,因为这个方法实在是没什么用。但是为什么要用getDerivedStateFromProps代替 componentWillReceiveProps 呢,除了简化派生 state 的代码,是否还有别的原因?
原来的 componentWillReceiveProps 方法仅仅在更新阶段才会被调用,而且在此函数中调用 setState 方法更新 state 会引起额外的 re-render,如果处理不当可能会造成大量无用的 re-render。getDerivedStateFromProps 相较于 componentWillReceiveProps 来说不是做加法,而是做减法,是 React 在推行只用 getDerivedStateFromProps 来完成 props 到 state 的映射这一最佳实践,确保生命周期函数的行为更加可控可预测,从根源上帮助开发者避免不合理的编程方式,同时也是在为新的 Fiber 架构 铺路。
getSnapshotBeforeUpdate 配合 componentDidUpdate 方法可以涵盖所有 componentWillUpdate使用场景,那废弃 componentWillUpdate 的原因就是换另外一种方式吗?其实根本原因还是在于 componentWillUpdate 方法是 Fiber 架构落地的一块绊脚石,不得不废弃掉。
Fiber 是 React v16 对 React 核心算法的一次重写,简单的理解就是 Fiber 会使原本同步的渲染过程变成增量渲染模式。
在 React v16 之前,每触发一次组件的更新,都会构建一棵新的虚拟 DOM 树,通过与上一次的虚拟 DOM 树进行 Diff 比较,实现对真实 DOM 的定向更新。这一整个过程是递归进行的(想想 React 应用的组织形式),而同步渲染的递归调用栈层次非常深(代码写得不好的情况下非常容易导致栈溢出),只有最底层的调用返回,整个渲染过程才会逐层返回。这个漫长的更新过程是不可中断的,同步渲染一旦开始,主线程(JavaScript 解析与执行)会一直被占用,直到递归彻底完成,在此期间浏览器没有办法处理任何渲染之外的事情(比如说响应用户事件)。这个问题对于大型的 React 应用来说是没办法接受的。
在 React v16 中的 Fiber 架构正是为了解决这个问题而提出的:Fiber 会将一个大的更新任务拆解为许多个小任务。每一个小任务执行完成后,渲染进程会把主线程交回去(释放),看看有没有其它优先级更高的任务(用户事件响应等)需要处理,如果有就执行高优先级任务,如果没有就继续执行其余的小任务。通过这样的方式,避免主线程被长时间的独占,从而避免应用卡顿的问题。这种可以被打断的渲染过程就是所谓的异步渲染。
Fiber 带来了两个重要的特性:任务拆解 与 渲染过程可打断。关于可打断并不是说任意环节都能打断重新执行,可打断的时机也是有所区分的。根据能否被打断这一标准,React v16 的生命周期被划分为了 render 和 commit两个阶段(commit 又被细分为 pre-commit 和 commit)。
- render 阶段:纯净且没有副作用,可以被 React 暂停,终止或重新启动
- pre-commit 阶段:可以读取 DOM
- commit 阶段:可以使用 DOM,运行副作用,安排更新
总体来说就是,render 阶段在执行过程中允许被打断,commit 阶段则总是同步执行。之所以确定这样的标准也是有深入考虑的,在 render 阶段的所有操作一般都是不可见的,所以被重复打断与重新执行,对用户来说是无感知的,在 commit 阶段会涉及到真实 DOM 的操作,如果该阶段也被反复打断重新执行,会导致 UI 界面多次更改渲染,这是绝对要避免的问题。
在了解了 Fiber 架构的执行机制之后,再回过头去看一下被废弃的生命周期函数:
- componentWillMount
- componentWillUpdate
- componentWillReceiveProps
这些生命周期的共性就是它们都处于 render 阶段,都可能被暂停,终止和重新执行。而如果开发者在这些函数中运行了副作用(或者操作 DOM),那么副作用函数就有可能会被多次重复执行,会带来意料之外的严重 bug。
生命周期函数的执行顺序
如图所示,我们可以看到,在组件第一次挂载时会经历:
constructor -> getDerivedStateFromProps -> render -> componentDidMount
组件更新时会经历:
getDerivedStateFromProps -> shouldComponentUpdate -> render -> getSnapshotBeforeUpdate -> componentDidUpdate
组件卸载时执行:componentWillUnmount
然而在实际开发中,不是只有一个组件的,可能还涉及到多个组件以及父子关系的组件,那么它们各自的生命周期函数的执行顺序又如何呢?
父子组件生命周期执行顺序总结:
-
当子组件自身状态改变时,不会对父组件产生副作用的情况下,父组件不会进行更新,即不会触发父组件的生命周期 -
当父组件中状态发生变化(包括子组件的挂载以及卸载)时,会触发自身对应的生命周期以及子组件的更新
render 以及 render 之前的生命周期,则 父组件先执行render 以及 render 之后的声明周期,则子组件先执行,并且是与父组件交替执行 当子组件进行卸载时,只会执行自身的 componentWillUnmount 生命周期,不会再触发别的生命周期
接下来我们来看一个实际案例来理解一下:
父组件:Parent.js
import React, { Component } from 'react';
import { Button } from 'antd';
import Child from './child';
export default class Parent extends Component {
constructor() {
super();
console.log('Parent 组件:', 'constructor');
this.state = {
count: 0,
mountChild: true,
};
}
static getDerivedStateFromProps(nextProps, prevState) {
console.log('Parent 组件:', 'getDerivedStateFromProps');
return null;
}
componentDidMount() {
console.log('Parent 组件:', 'componentDidMount');
}
shouldComponentUpdate(nextProps, nextState) {
console.log('Parent 组件:', 'shouldComponentUpdate');
return true;
}
getSnapshotBeforeUpdate(prevProps, prevState) {
console.log('Parent 组件:', 'getSnapshotBeforeUpdate');
return null;
}
componentDidUpdate(prevProps, prevState, snapshot) {
console.log('Parent 组件:', 'componentDidUpdate');
}
componentWillUnmount() {
console.log('Parent 组件:', 'componentWillUnmount');
}
changeNum = () => {
let { count } = this.state;
this.setState({
count: ++count,
});
};
toggleMountChild = () => {
const { mountChild } = this.state;
this.setState({
mountChild: !mountChild,
});
};
render() {
console.log('Parent 组件:', 'render');
const { count, mountChild } = this.state;
return (
<div>
<div>
<h3>父组件</h3>
<Button onClick={this.changeNum}>改变传给子组件的属性 count</Button>
<br />
<br />
<Button onClick={this.toggleMountChild}>卸载 / 挂载子组件</Button>
</div>
{mountChild ? <Child count={count} /> : null}
</div>
);
}
}
子组件: Child.js
import React, { Component } from 'react';
import { Button } from 'antd';
const childStyle = {
padding: 20,
margin: 20,
backgroundColor: 'LightSkyBlue',
};
export default class Child extends Component {
constructor() {
super();
console.log('Child 组件:', 'constructor');
this.state = {
counter: 0,
};
}
static getDerivedStateFromProps(nextProps, prevState) {
console.log('Child 组件:', 'getDerivedStateFromProps');
return null;
}
componentDidMount() {
console.log('Child 组件:', 'componentDidMount');
}
shouldComponentUpdate(nextProps, nextState) {
console.log('Child 组件:', 'shouldComponentUpdate');
return true;
}
getSnapshotBeforeUpdate(prevProps, prevState) {
console.log('Child 组件:', 'getSnapshotBeforeUpdate');
return null;
}
componentDidUpdate(prevProps, prevState, snapshot) {
console.log('Child 组件:', 'componentDidUpdate');
}
componentWillUnmount() {
console.log('Child 组件:', 'componentWillUnmount');
}
changeCounter = () => {
let { counter } = this.state;
this.setState({
counter: ++counter,
});
};
render() {
console.log('Child 组件:', 'render');
const { count } = this.props;
const { counter } = this.state;
return (
<div style={childStyle}>
<h3>子组件</h3>
<p>父组件传过来的属性 count : {count}</p>
<p>子组件自身状态 counter : {counter}</p>
<Button onClick={this.changeCounter}>改变自身状态 counter</Button>
</div>
);
}
}
接下来我们从五种组件状态改变的时机来验证生命周期的执行顺序
一、 父子组件初始化
父子组件第一次进行渲染加载时:
控制台的打印顺序为:
- Parent 组件: constructor
- Parent 组件: getDerivedStateFromProps
- Parent 组件: render
- Child 组件: constructor
- Child 组件: getDerivedStateFromProps
- Child 组件: render
- Child 组件: componentDidMount
- Parent 组件: componentDidMount
二、子组件修改自身状态 state
点击子组件 [改变自身状态counter] 按钮,其 [自身状态counter] 值会 +1, 此时控制台的打印顺序为:
- Child 组件: getDerivedStateFromProps
- Child 组件: shouldComponentUpdate
- Child 组件: render
- Child 组件: getSnapshotBeforeUpdate
- Child 组件: componentDidUpdate
三、修改父组件中传入子组件的 props
点击父组件中的 [改变传给子组件的属性 count] 按钮,则界面上 [父组件传过来的属性 count] 的值会 + 1,控制台的打印顺序为:
- Parent 组件: getDerivedStateFromProps
- Parent 组件: shouldComponentUpdate
- Parent 组件: render
- Child 组件: getDerivedStateFromProps
- Child 组件: shouldComponentUpdate
- Child 组件: render
- Child 组件: getSnapshotBeforeUpdate
- Parent 组件: getSnapshotBeforeUpdate
- Child 组件: componentDidUpdate
- Parent 组件: componentDidUpdate
四、卸载子组件
点击父组件中的 [卸载 / 挂载子组件] 按钮,则界面上子组件会消失,控制台的打印顺序为:
- Parent 组件: getDerivedStateFromProps
- Parent 组件: shouldComponentUpdate
- Parent 组件: render
- Parent 组件: getSnapshotBeforeUpdate
- Child 组件: componentWillUnmount
- Parent 组件: componentDidUpdate
五、重新挂载子组件
再次点击父组件中的 [卸载 / 挂载子组件] 按钮,则界面上子组件会重新渲染出来,控制台的打印顺序为:
- Parent 组件: getDerivedStateFromProps
- Parent 组件: shouldComponentUpdate
- Parent 组件: render
- Child 组件: constructor
- Child 组件: getDerivedStateFromProps
- Child 组件: render
- Parent 组件: getSnapshotBeforeUpdate
- Child 组件: componentDidMount
- Parent 组件: componentDidUpdate
Hooks 与 生命周期函数
生命周期函数只存在于类组件,对于没有 Hooks 之前的函数组件而言,没有组件生命周期的概念(函数组件没有 render 之外的过程),但是有了 Hooks 之后,问题就变得有些复杂了。
Hooks 能够让函数组件拥有使用与管理 state 的能力,也就演化出了函数组件生命周期的概念(render 之外新增了其他过程),涉及到的 Hook 主要有几个:useState、useMemo、useEffect。
如果想更全面的了解 Hooks,可以看快速上手 React Hook_夏安 的博客-CSDN博客
整体来说,大部分生命周期都可以利用 Hook 来模拟实现,而一些难以模拟的,往往也是 React 不推荐的反模式。
至于为什么设计 Hook,为什么要赋予函数组件使用与管理 state 的能力,React 官网也在 Hook 介绍 做了深入而详细的介绍,总结下来有以下几个点:
- 便于分离与复用组件的状态逻辑(Mixin,高阶组件,渲染回调模式等)
- 复杂组件变得难以理解(状态与副作用越来越多,生命周期函数滥用)
- 类组件中难以理解的 this 指向(bind 语法)
- 类组件难以被进一步优化(组件预编译,不能很好被压缩,热重载不稳定)
|