[转]深入浅出 React -- 生命周期
这里通过对 React15 和 React16 两个版本的生命周期进行对比总结,来建立系统而完善的生命周期知识体系.
生命周期背后的设计思想
React 设计的两个核心概念:“组件” 和 “虚拟 DOM”
虚拟 DOM
当组件初始化时,通过调用生命周期中的 render 方法,生成虚拟 DOM;再通过调用 ReactDOM.render
方法,将虚拟 DOM 转换为真实 DOM。
当组件更新时,会再次调用生命周期中的 render 方法,生成新的虚拟 DOM;然后通过 diff 算法定位两次虚拟 DOM 的差异,对发生变化的真实 DOM 做定向更新。
组件化
在一个 React 项目中,几乎所有的内容都可以抽离为各种各样的组件,每个组件既是 “封闭” 的,也是 “开放” 的。
所谓 “封闭”,是针对组件数据改变到组件实际发生更新的过程。在组件自身的渲染过程中,每个组件都只会处理它自身内部的渲染逻辑。在没有数据交流的情况下,组件之间互不干扰。
所谓 “开放”,是针对组件间通信的。React 允许开发者基于单向数据流的原则来完成组件之间的通信。组件之间的通信可能使通信组件的渲染结果产生影响。所以说组件之间是相互开放的,可以相互影响的。
React 组件的 “开放” 与 “封闭” 特性,使得 React 的组件具备高可重用性和可维护性。
生命周期方法
生命周期的 render 方法将虚拟 DOM和组件两者结合到了一起。
虚拟 DOM 的生成依赖 render,而组件的渲染过程也离不开 render。所以可以将 render 方法比作组件的“灵魂”。
render 之外的生命周期方法可以理解为组件的“躯干”。
我们可以省略 render 之外的任何生命周期方法内容的编写,但是 render 函数不能省略;但是 render 之外的生命周期方法的编写,通常是为 render 服务;“灵魂” 和 “躯干” 共同构成了 React 组件完整的生命时间轴。
React15 生命周期
在 React15 中,需要关注以下生命周期方法:
constructor()
componentWillReceiveProps()
shouldComponentUpdate()
componentWillMount()
componentWillUpdate()
componentDidUpdate()
componentDidMount()
render()
componentWillUnmount()
这些生命周期方法的关系:
下面的示例可以验证:
import React from "react"
import ReactDOM from "react-dom"
// 代码源自 “深入浅出搞定 React -- 修言”
// 定义子组件
class LifeCycle extends React.Component {
constructor(props) {
console.log("进入constructor")
super(props)
// state 可以在 constructor 里初始化
this.state = { text: "子组件的文本" }
}
// 初始化渲染时调用
componentWillMount() {
console.log("componentWillMount方法执行")
}
// 初始化渲染时调用
componentDidMount() {
console.log("componentDidMount方法执行")
}
// 父组件修改组件的props时会调用
componentWillReceiveProps(nextProps) {
console.log("componentWillReceiveProps方法执行")
}
// 组件更新时调用
shouldComponentUpdate(nextProps, nextState) {
console.log("shouldComponentUpdate方法执行")
return true
}
// 组件更新时调用
componentWillUpdate(nextProps, nextState) {
console.log("componentWillUpdate方法执行")
}
// 组件更新后调用
componentDidUpdate(nextProps, nextState) {
console.log("componentDidUpdate方法执行")
}
// 组件卸载时调用
componentWillUnmount() {
console.log("子组件的componentWillUnmount方法执行")
}
// 点击按钮,修改子组件文本内容的方法
changeText = () => {
this.setState({
text: "修改后的子组件文本"
})
}
render() {
console.log("render方法执行")
return (
<div className="container">
<button onClick={this.changeText} className="changeText">
修改子组件文本内容
</button>
<p className="textContent">{this.state.text}</p>
<p className="fatherContent">{this.props.text}</p>
</div>
)
}
}
// 定义 LifeCycle 组件的父组件
class LifeCycleContainer extends React.Component {
// state 也可以像这样用属性声明的形式初始化
state = {
text: "父组件的文本",
hideChild: false
}
// 点击按钮,修改父组件文本的方法
changeText = () => {
this.setState({
text: "修改后的父组件文本"
})
}
// 点击按钮,隐藏(卸载)LifeCycle 组件的方法
hideChild = () => {
this.setState({
hideChild: true
})
}
render() {
return (
<div className="fatherContainer">
<button onClick={this.changeText} className="changeText">
修改父组件文本内容
</button>
<button onClick={this.hideChild} className="hideChild">
隐藏子组件
</button>
{this.state.hideChild ? null : <LifeCycle text={this.state.text} />}
</div>
)
}
}
ReactDOM.render(<LifeCycleContainer />, document.getElementById("root"))
挂载阶段
组件挂载在一个 React 组件的生命周期中只会发生一次,在这个过程中,组件被初始化,最后被渲染到真实 DOM;
挂载阶段,一个 React 组件所经历的生命周期:
-
constructor()
:对 this.state 初始化。 -
componentWillMount()
:在render
方法前被触发。 -
render()
:生成需要渲染的内容并返回,不会操作真实 DOM。真实 DOM 的渲染由 ReactDOM.render 完成。 -
componentDidMount()
:在渲染结束后被触发,此时可以访问真实 DOM 。在这个生命周期中也可以做类似于异步请求、数据初始化的操作。
更新阶段
更新阶段,一个 React 组件所经历的生命周期:
componentWillReceiveProps
从图中可以看出,由父组件触发的更新和由组件自身触发的更新对比,多出了一个生命周期方法:componentWillReceiveProps(nextProps)
。
nextProps
表示新 props
内容,而现有的 props
可以通过 this.props
获取,从而对比 props
的变化。
如果父组件导致组件重新渲染,即使 props 没有更改,也会调用此方法(componentWillReceiveProps)。如果只想处理更改,请确保进行当前值与变更值的比较。
componentWillReceiveProps 并不是由 props 的变化触发的,而是由父组件的更新触发的
shouldComponentUpdate
shouldComponentUpdate(nextProps, nextState)
由于 render
方法会进行虚拟 DOM 的构建和对比,比较耗时。为了避免不必要的 render
调用,React 提供了 shouldComponentUpdate
生命周期方法。
根据 shouldComponentUpdate()
的返回值,判断 React 组件的输出是否受当前 state 或 props 更改的影响。默认行为是 state 每次发生变化组件都会重新渲染。大部分情况下,你应该遵循默认行为。
此方法仅作为性能优化的方式而存在。不要企图依靠此方法来“阻止”渲染,因为这可能会产生 bug。你应该考虑使用内置的 PureComponent 组件,而不是手动编写 shouldComponentUpdate()
。PureComponent
会对 props 和 state 进行浅层比较,并减少了跳过必要更新的可能性。
componentWillUpdate 和 componentDidUpdate
componentWillUpdate
在 render
前触发,和 componentWillMount
类似,可以在里面做一些与真实 DOM 不相关的操作。
componentDidUpdate
在组件更新完成后触发,和 componentDidMount
类似,可以在里面处理 DOM 操作;作为子组件更新完毕通知父组件的标志。
卸载阶段
组件销毁,只有 componentWillUnmount()
生命周期,可以在里面做一些释放内存,清理定时器等操作。
React16 生命周期
React 16.3 生命周期:
示例代码:
import React from "react"
import ReactDOM from "react-dom"
// 代码源自 “深入浅出搞定 React -- 修言”
// 定义子组件
class LifeCycle extends React.Component {
constructor(props) {
console.log("进入constructor")
super(props)
// state 可以在 constructor 里初始化
this.state = { text: "子组件的文本" }
}
// 初始化/更新时调用
static getDerivedStateFromProps(props, state) {
console.log("getDerivedStateFromProps方法执行")
return {
fatherText: props.text
}
}
// 初始化渲染时调用
componentDidMount() {
console.log("componentDidMount方法执行")
}
// 组件更新时调用
shouldComponentUpdate(prevProps, nextState) {
console.log("shouldComponentUpdate方法执行")
return true
}
// 组件更新时调用
getSnapshotBeforeUpdate(prevProps, prevState) {
console.log("getSnapshotBeforeUpdate方法执行")
return "haha"
}
// 组件更新后调用
componentDidUpdate(nextProps, nextState, valueFromSnapshot) {
console.log("componentDidUpdate方法执行")
console.log("从 getSnapshotBeforeUpdate 获取到的值是", valueFromSnapshot)
}
// 组件卸载时调用
componentWillUnmount() {
console.log("子组件的componentWillUnmount方法执行")
}
// 点击按钮,修改子组件文本内容的方法
changeText = () => {
this.setState({
text: "修改后的子组件文本"
})
}
render() {
console.log("render方法执行");
return (
<div className="container">
<button onClick={this.changeText} className="changeText">
修改子组件文本内容
</button>
<p className="textContent">{this.state.text}</p>
<p className="fatherContent">{this.props.text}</p>
</div>
)
}
}
// 定义 LifeCycle 组件的父组件
class LifeCycleContainer extends React.Component {
// state 也可以像这样用属性声明的形式初始化
state = {
text: "父组件的文本",
hideChild: false
}
// 点击按钮,修改父组件文本的方法
changeText = () => {
this.setState({
text: "修改后的父组件文本"
})
}
// 点击按钮,隐藏(卸载)LifeCycle 组件的方法
hideChild = () => {
this.setState({
hideChild: true
})
}
render() {
return (
<div className="fatherContainer">
<button onClick={this.changeText} className="changeText">
修改父组件文本内容
</button>
<button onClick={this.hideChild} className="hideChild">
隐藏子组件
</button>
{this.state.hideChild ? null : <LifeCycle text={this.state.text} />}
</div>
)
}
}
ReactDOM.render(<LifeCycleContainer />, document.getElementById("root"))
挂载阶段
componentWillMount vs getDerivedStateFromProps
对比于 React 15 废弃了 componentWillMount
,新增了 getDerivedStateFromProps
。
componentWillMount
的存在不仅“鸡肋”而且危险,因此它不值得被“替代”,而应该直接废弃。
getDerivedStateFromProps
的设计初衷是替换 componentWillReceiveProps
,它有且仅有一个作用:让组件在 props
变化时派生/更新 state
。
getDerivedStateFromProps
的方法签名:
static getDerivedStateFromProps(props, state)
-
getDerivedStateFromProps
是一个静态方法;不依赖组件实例;在这个方法里不能访问this
。 - 两个参数:
props
和state
,分别表示组件接收的来自父组件的props
和自身的state
。 - 需要一个对象作为返回值;如果没有指定返回值,React 会发出警告;React 需要用这个返回值来更新/派生组件的
stat
;如果不需要,最好直接省略这个方法,否则需要返回null
。 - 对
state
的更新不是“覆盖”,而是针对属性的定向更新。
更新阶段
React 16.4 的挂载和卸载和 React 16.3 保持一致,更新阶段不同:
React 16.4 生命周期:
- 在 React 16.4 中,任何因素触发的组件更新都会触发
getDerivedStateFromProps
。 - 在 React 16.3 中,只有父组件的更新才会触发
getDerivedStateFromProps
。
getDerivedStateFromProps
-
getDerivedStateFromProps
是为了试图替换componentWillReceiveProp
而出现的。 -
getDerivedStateFromProps
不能完全等同于componentWillReceiveProps
。- 代替实现基于 props 派生 state。
- 原则上,它能且只能做这一件事。
为什么要用 getDerivedStateFromProps
替换 componentWillReceiveProps
做 “合理的减法”
getDerivedStateFromProps
直接被定义为 static
方法,使得在其方法内部无法拿到组件实例的 this
,也就不能在里面执行类似不合理的 this.setState
(可能会导致死循环)这类会产生副作用的操作。
确保生命周期函数的行为可控可预测,从源头上帮助开发者避免不合理的编码,同时也是为新的Fiber 架构铺路。
componentWillUpdate vs getSnapshotBeforeUpdate
getSnapshotBeforeUpdate(prevProps, prevState) {
// ...
}
- 执行时机在
render
方法之后,真实 DOM 更新之前 - 可以获得 DOM 更新前后的
state
和props
信息 - 返回值将作为
componentDidUpdate
的第三个参数
在实际编程中很少用到,但也有特殊场景需要。
例如:实现一个内容会发生变化的滚动列表,要求根据滚动列表的内容是否发生变化,来决定是否要记录滚动条的当前位置。
这个例子中要求我们对比更新前后的数据是否发生变化,还需要获取真实的 DOM 位置信息。
与componentDidUpdate
配合编程:
// 组件更新时调用
getSnapshotBeforeUpdate(prevProps, prevState) {
console.log("getSnapshotBeforeUpdate方法执行")
return "haha"
}
// 组件更新后调用
componentDidUpdate(prevProps, prevState, valueFromSnapshot) {
console.log("componentDidUpdate方法执行")
console.log("从 getSnapshotBeforeUpdate 获取到的值是", valueFromSnapshot)
}
getSnapshotBeforeUpdate
的设计初衷是为了 “与 componentDidUpdate
一起,覆盖过时的componentWillUpdate
”。
为什么废除 componentWillUpdate
,是因为它不适合 Fiber 架构。
卸载阶段
与 React 15 完全一致
React 16 为何做出两次改变
Fiber 架构简析
使 Virtual DOM 可以进行增量式渲染
Fiber 会使原本同步的渲染过程变成异步的
在 React 16 之前,每次组件更新,React 都会构建虚拟 DOM,再与旧虚拟 DOM 对比 diff,最后对真实 DOM 定向更新。
同步调用的调用栈非常深,需要等到递归调用都返回后,整个渲染才算结束。
这个“漫长”的同步渲染过程不可被打断,存在巨大风险;同步渲染一旦开始,会占据主线程,直到彻底完成;在这个过程中,浏览器无法处理其他任务包括用户交互,甚至可能出现卡顿至卡死的风险。
React 16 引入的 Fiber 架构,可以解决这个风险:Fiber 会将一个大的更新任务拆解为多个小任务;每次执行完成一个小任务,渲染线程都会交还主线程给浏览器,然后处理优先级更高的工作,进而避免同步渲染导致的卡顿。
React 渲染的过程可以被中断,可以将控制权交回浏览器,让位给高优先级的任务,浏览器空闲后再恢复渲染。
从 Fiber 架构角度看生命周期
Fiber 架构的重要特征就是渲染过程可以被中断。根据这个特征,React 16 的生命周期被划分为 Render 和 Commit 两个阶段,而 Commit 阶段又被细分为 Pre-commit 和 Commit 阶段。
- Render 阶段:纯净且不包含副作用。可能会被 React 暂停,中止或重新启动。
- Pre-commit 阶段:可以读取 DOM。
- Commit 阶段:可以使用 DOM,运行副作用,安排更新。
也就是说在 Render 阶段允许被中断,而 Commit 阶段不能。原因很简单,Render 阶段的操作对于用户不可感知,所以中断、重启对于用户而言是不可见的。而 Commit 阶段的操作是对真实 DOM 的渲染,不能随意中断、重渲染。
React 16 “废旧立新”背后的思考
Fiber 架构下,Render 阶段允许被暂停、终止和重启。当一个任务执行一段后被中断,下一次抢回渲染线程时,这个任务会“重复执行一遍整个任务”而不是接着上一次执行的地方。这导致了 Render 阶段的生命周期方法有可能重复执行。
React 16 废弃的生命周期方法:
- componentWillMount
- componentWillUpdate
- componentWillReceiveProps
这些方法都处于 Render 阶段,而且这些方法常年被滥用,在重复执行的过程中存在很大的风险。
我们的编码中的一些不好的习惯,在 “componentWill” 开头的生命周期里做一些事情:
- setState()
- fetch 异步请求
- 操作真实 DOM
- ...
这些操作的问题:
- 可以转移到其他生命周期(componentDid...)里去做
- Fiber 架构下,可能导致非常严重的 Bug
- 在 React 15 中也有出现过问题(在
componentWillReceiveProps
和componentWillUpdate
里滥用 setState 导致重渲染死循环)
总结
- React 16 改造生命周期的主要原因是为了配合 Fiber 架构带来的异步渲染机制。
- 针对生命周期中长期被滥用的部分推出了具有强制性的最佳实践。
- 确保了 Fiber 架构下的数据和视图的安全,以及确保了生命周期方法的行为更加可控、可预测。