本章节以 React 的基本原理为引子,对 React 15、React 16 两个版本的生命周期进行探讨、比对和总结,通过搞清楚一个又一个的“Why”,来建立一个系统而完善的生命周期知识体系。

1、生命周期背后的设计思想:把握 React 中的“大方向”

在了解具体的生命周期之前,我们先来初步理解 React 框架中的一些关键的设计思想,以便为后续的学习提供不可或缺的“加速度”。
在 React 官网或者 React 官方的一些文章,会发现“组件”和“虚拟 DOM”这两个词的出镜率是非常高的,它们是 React 基本原理中极为关键的两个概念,所以我们先来认识这两个关键概念。

1)、虚拟 DOM:核心算法的基石

在上一章节的JSX学习中,我们接触了解了虚拟 DOM 节点的基本形态,接下来我们则需要简单了解下虚拟 DOM 在整个 React 工作流中的作用
2.生命周期(上) - 图1 (1)组件在初始化时,会通过调用生命周期中的 render 方法,生成虚拟 DOM,然后再通过调用 ReactDOM.render 方法,实现虚拟 DOM 到真实 DOM 的转换
(2)当组件更新时,会再次通过调用 render 方法生成新的虚拟 DOM,然后借助 diff(这是一个非常关键的算法)定位出两次虚拟 DOM 的差异,从而针对发生变化的真实 DOM 作定向更新
以上就是 React 框架核心算法的大致流程。对于这套关键的工作流来说,“虚拟 DOM”是所有操作的大前提,是核心算法的基石

2)组件化:工程化思想在框架中的落地

组件化是一种优秀的软件设计思想,也是 React 团队在研发效能方面所做的一个重要的努力。
在一个 React 项目中,几乎所有的可见/不可见的内容都可以被抽离为各种各样的组件,每个组件既是“封闭”的,也是“开放”的。
(1)所谓“封闭”,主要是针对“渲染工作流”(指从组件数据改变到组件实际更新发生的过程)来说的。在组件自身的渲染工作流中,每个组件都只处理它内部的渲染逻辑。在没有数据流交互的情况下,组件与组件之间可以做到“各自为政”。
(2)而所谓“开放”,则是针对组件间通信来说的。React 允许开发者基于“单向数据流”的原则完成组件间的通信。而组件之间的通信又将改变通信双方/某一方内部的数据,进而对渲染结果构成影响。所以说在数据这个“红娘”的牵线搭桥之下,组件之间又是彼此开放的,是可以相互影响的。
这一“开放”与“封闭”兼具的特性,使得 React 组件既专注又灵活,具备高度的可重用性和可维护性

2、生命周期方法的本质:组件的“灵魂”与“躯干”

(1)“render 方法彷佛为 React 组件的灵魂”,一旦对render 方法有所认知,会发现这句话很能让人产生共鸣。但是值得注意的是,这里提到的 render 方法,和之前所说的 ReactDOM.render 可不是一个东西,它指的是 React 组件内部的这个生命周期方法,如下所示:

  1. class Test extends React.Component {
  2. render() {
  3. console.log("render方法执行");
  4. return (
  5. <div className="container">
  6. this is content
  7. </div>
  8. );
  9. }
  10. }

对于前面所了解的“虚拟 DOM、组件化”,倘若把这两块知识整合一下,会发现这两个概念似乎都在围着 render 这个生命周期打转:虚拟 DOM 自然不必多说,它的生成都要仰仗 render;而组件化概念中所提及的“渲染工作流”,这里指的是从组件数据改变到组件实际更新发生的过程,这个过程的实现同样离不开 render。
由此看来,render 方法在整个组件生命周期中确实举足轻重,它担得起“灵魂”这个有分量的比喻。
(2)那么如果将 render 方法比作组件的“灵魂”,render 之外的生命周期方法就完全可以理解为是组件的“躯干”。
“躯干”未必总是会做具体的事情(比如说我们可以选择性地省略对 render 之外的任何生命周期方法内容的编写),而“灵魂”却总是充实的(render 函数却坚决不能省略);倘若“躯干”做了点什么,往往都会直接或间接地影响到“灵魂”(因为即便是 render 之外的生命周期逻辑,也大部分是在为 render 层面的效果服务);“躯干”和“灵魂”一起,共同构成了 React 组件完整而不可分割的“生命时间轴”。

3、拆解 React 生命周期:从 React 15 说起

我们先来认识 React 15 的生命周期流程。

(1)在 React 15 中,我们需要关注以下几个生命周期方法:

  1. constructor()
  2. componentWillReceiveProps()
  3. shouldComponentUpdate()
  4. componentWillMount()
  5. componentWillUpdate()
  6. componentDidUpdate()
  7. componentDidMount()
  8. render()
  9. componentWillUnmount()

如果你接触 React 足够早,或许会记得还有 getDefaultProps 和 getInitState 这两个方法,它们都是 React.createClass() 模式下初始化数据的方法。由于这种写法在 ES6 普及后已经不常见,这里不再详细展开。

(2)这些生命周期方法是如何彼此串联、相互依存的呢?总结为以下这一张大图:

2.生命周期(上) - 图2
接下来,我们就围绕这张大图,分阶段探讨组件生命周期的运作规律。在学习的过程中,下面这个 Demo 可以帮助我们具体地验证每个阶段的工作流程:
ChildComponents

  1. import React from "react";
  2. // 定义子组件
  3. class ChildComponents extends React.Component {
  4. constructor(props) {
  5. console.log("进入constructor");
  6. super(props);
  7. // state 可以在 constructor 里初始化
  8. this.state = { text: "子组件的文本" };
  9. }
  10. // 初始化渲染时调用
  11. componentWillMount() {
  12. console.log("componentWillMount方法执行");
  13. }
  14. // 初始化渲染时调用
  15. componentDidMount() {
  16. console.log("componentDidMount方法执行");
  17. }
  18. // 父组件修改组件的props时会调用
  19. componentWillReceiveProps(nextProps) {
  20. console.log("componentWillReceiveProps方法执行");
  21. }
  22. // 组件更新时调用
  23. shouldComponentUpdate(nextProps, nextState) {
  24. console.log("shouldComponentUpdate方法执行");
  25. return true;
  26. }
  27. // 组件更新时调用
  28. componentWillUpdate(nextProps, nextState) {
  29. console.log("componentWillUpdate方法执行");
  30. }
  31. // 组件更新后调用
  32. componentDidUpdate(nextProps, nextState) {
  33. console.log("componentDidUpdate方法执行");
  34. }
  35. // 组件卸载时调用
  36. componentWillUnmount() {
  37. console.log("子组件的componentWillUnmount方法执行");
  38. }
  39. // 点击按钮,修改子组件文本内容的方法
  40. changeText = () => {
  41. this.setState({
  42. text: "修改后的子组件文本"
  43. });
  44. };
  45. render() {
  46. console.log("render方法执行");
  47. return (
  48. <div className="container">
  49. <button onClick={this.changeText} className="changeText">
  50. 修改子组件文本内容
  51. </button>
  52. <p className="textContent">{this.state.text}</p>
  53. <p className="fatherContent">{this.props.text}</p>
  54. </div>
  55. );
  56. }
  57. }
  58. export default ChildComponents

ParentComponent

  1. import React from "react";
  2. import ChildComponents from './childComponents'
  3. // 定义 ChildComponents 组件的父组件
  4. export default class ParentComponent extends React.Component {
  5. // state 也可以像这样用属性声明的形式初始化
  6. state = {
  7. text: "父组件的文本",
  8. hideChild: false
  9. };
  10. // 点击按钮,修改父组件文本的方法
  11. changeText = () => {
  12. this.setState({
  13. text: "修改后的父组件文本"
  14. });
  15. };
  16. // 点击按钮,隐藏(卸载)ChildComponents 组件的方法
  17. hideChild = () => {
  18. this.setState({
  19. hideChild: true
  20. });
  21. };
  22. render() {
  23. return (
  24. <div className="fatherContainer">
  25. <button onClick={this.changeText} className="changeText">
  26. 修改父组件文本内容
  27. </button>
  28. <button onClick={this.hideChild} className="hideChild">
  29. 隐藏子组件
  30. </button>
  31. {this.state.hideChild ? null : <ChildComponents text={this.state.text} />}
  32. </div>
  33. );
  34. }
  35. }

这个 Demo(此处强调的是对生命周期执行规律的验证,故样式从简) 渲染到浏览器上大概是这样的: 2.生命周期(上) - 图3

(3)接下来我们结合这个 Demo 和开头的生命周期大图,一起来看看挂载、更新、卸载这 3 个阶段,React 组件都经历了哪些事情。

①Mounting 阶段:组件的初始化渲染(挂载)

挂载过程在组件的一生中仅会发生一次,在这个过程中,组件被初始化,然后会被渲染到真实 DOM 里,完成所谓的“首次渲染”。
在挂载阶段,一个 React 组件会按照顺序经历如下图所示的生命周期: 2.生命周期(上) - 图4
挂载阶段详解:

(i)首先我们来看 constructor 方法。

该方法仅仅在挂载的时候被调用一次,我们可以在该方法中对 this.state 进行初始化:

  1. constructor(props) {
  2. console.log("进入constructor");
  3. super(props);
  4. // state 可以在 constructor 里初始化
  5. this.state = { text: "子组件的文本" };
  6. }

(ii)componentWillMount、componentDidMount 方法同样只会在挂载阶段被调用一次

其中 componentWillMount 会在执行 render 方法前被触发,一些人习惯在这个方法里做一些初始化的操作,但这些操作往往会伴随一些风险或者说不必要性(对于这一点可先建立认知,具体原因将在下一章节展开讲解)。

(iii)接下来 render 方法被触发。

注意 render 在执行过程中并不会去操作真实 DOM(也就是说不会渲染),它的职能是把需要渲染的内容返回出来。真实 DOM 的渲染工作,在挂载阶段是由 ReactDOM.render 来承接的。

(iv)componentDidMount 方法在渲染结束后被触发。

此时因为真实 DOM 已经挂载到了页面上,我们可以在这个生命周期里执行真实 DOM 相关的操作。此外,类似于异步请求、数据初始化这样的操作也大可以放在这个生命周期来做(侧面印证了 componentWillMount 真的很鸡肋)。
这一整个流程对应的其实就是 Demo 页面刚刚打开时,组件完成初始化渲染的过程。下图是 Demo 中的 ChildComponents 组件在挂载过程中控制台的输出,可以用它来验证挂载过程中生命周期顺序的正确性:
2.生命周期(上) - 图5

②Updating 阶段:组件的更新

组件的更新分为两种:一种是由父组件更新触发的更新;另一种是组件自身调用自己的 setState 触发的更新。这两种更新对应的生命周期流程如下图所示:
2.生命周期(上) - 图6

(i)父组件触发的更新

componentWillReceiProps 到底是由什么触发的? 从图中可以明显看出,父组件触发的更新和组件自身的更新相比,多出了这样一个生命周期方法:

  1. componentWillReceiveProps(nextProps)

在这个生命周期方法里,nextProps 表示的是接收到新 props 内容,而现有的 props (相对于 nextProps 的“旧 props”)我们可以通过 this.props 拿到,由此便能够感知到 props 的变化。而对于“变化”这个动作,我们则需深挖一下。在一些社区文章里,包括一些候选人面试时的回答里,都不约而同地见过/听过这样一种说法:componentWillReceiveProps 是在组件的 props 内容发生了变化时被触发的。其实这种说法不够严谨。远的不说,就拿以上的Demo 开刀,该界面的控制台输出在初始化完成后是这样的:
2.生命周期(上) - 图7
注意,在demo代码里面,ParentComponent 这个父组件传递给子组件 ChildComponents 的 props 只有一个 text:

  1. <ChildComponents text={this.state.text} />

假如点击“修改父组件文本内容”这个按钮,父组件的 this.state.text 会发生改变,进而带动子组件的 this.props.text 发生改变。此时一定会触发 componentWillReceiveProps 这个生命周期,这是毋庸置疑的: 2.生命周期(上) - 图8
但如果现在对父组件的结构进行一个小小的修改,给它一个和子组件完全无关的 state(this.state.ownText),同时相应地给到一个修改这个 state 的方法(this.changeOwnText),并用一个新的 button 按钮来承接这个触发的动作。
改变后的 ParentComponent 如下所示:

  1. // 定义 ChildComponents 组件的父组件
  2. class ParentComponent extends React.Component {
  3. // state 也可以像这样用属性声明的形式初始化
  4. state = {
  5. text: "父组件的文本",
  6. // 新增的只与父组件有关的 state
  7. ownText: "仅仅和父组件有关的文本",
  8. hideChild: false
  9. };
  10. changeText = () => {
  11. this.setState({
  12. text: "修改后的父组件文本"
  13. });
  14. };
  15. // 修改 ownText 的方法
  16. changeOwnText = () => {
  17. this.setState({
  18. ownText: "修改后的父组件自有文本"
  19. });
  20. };
  21. hideChild = () => {
  22. this.setState({
  23. hideChild: true
  24. });
  25. };
  26. render() {
  27. return (
  28. <div className="fatherContainer">
  29. {/* 新的button按钮 */}
  30. <button onClick={this.changeOwnText} className="changeText">
  31. 修改父组件自有文本内容
  32. </button>
  33. <button onClick={this.changeText} className="changeText">
  34. 修改父组件文本内容
  35. </button>
  36. <button onClick={this.hideChild} className="hideChild">
  37. 隐藏子组件
  38. </button>
  39. <p> {this.state.ownText} </p>
  40. {this.state.hideChild ? null : <ChildComponents text={this.state.text} />}
  41. </div>
  42. );
  43. }
  44. }

新的界面如下图所示:
2.生命周期(上) - 图9
可以看到,this.state.ownText 这个状态和子组件完全无关。但是当点击“修改父组件自有文本内容”这个按钮的时候,componentReceiveProps 仍然被触发了,效果如下图所示: 2.生命周期(上) - 图10 耳听为虚,眼见为实。面对这样的运行结果,我们还是来回顾一下 React 官方文档中的这句话: 2.生命周期(上) - 图11componentReceiveProps 并不是由 props 的变化触发的,而是由”父组件的更新触发”的,这个结论,要谨记。

(ii)组件自身 setState 触发的更新

this.setState() 调用后导致的更新流程,前面大图中已经有体现,这里直接沿用上一个 Demo 来做演示。若我们点击上一个 Demo 中的“修改子组件文本内容”这个按钮: 2.生命周期(上) - 图12
这个动作将会触发子组件 ChildComponents 自身的更新流程,随之被触发的生命周期函数如下图增加的 console 内容所示: 2.生命周期(上) - 图13

先来看看componentWillUpdate 和 componentDidUpdate 这一对好基友。

componentWillUpdate 会在 render 前被触发,它和 componentWillMount 类似,允许你在里面做一些不涉及真实 DOM 操作的准备工作;而 componentDidUpdate 则在组件更新完毕后被触发,和 componentDidMount 类似,这个生命周期也经常被用来处理 DOM 操作。此外,我们也常常将 componentDidUpdate 的执行作为子组件更新完毕的标志通知到父组件。

render 与性能:初识 shouldComponentUpdate

这里需要重点提一下 shouldComponentUpdate 这个生命周期方法,它的调用形式如下所示:
shouldComponentUpdate(nextProps, nextState) 复制代码
render 方法由于伴随着对虚拟 DOM 的构建和对比,过程可以说相当耗时。而在 React 当中,很多时候我们会不经意间就频繁地调用了 render。为了避免不必要的 render 操作带来的性能开销,React 为我们提供了 shouldComponentUpdate 这个口子。
React 组件会根据 shouldComponentUpdate 的返回值,来决定是否执行该方法之后的生命周期,进而决定是否对组件进行re-render(重渲染)。shouldComponentUpdate 的默认值为 true,也就是说“无条件 re-render”。在实际的开发中,我们往往通过手动往 shouldComponentUpdate 中填充判定逻辑,或者直接在项目中引入 PureComponent 等最佳实践,来实现“有条件的 re-render”。
关于 shouldComponentUpdate 及 PureComponent 对 React 的优化,后续的性能小节中会详细展开。所以这里只需要认识到 shouldComponentUpdate 的基本使用及其与 React 性能之间的关联关系即可。

③Unmounting 阶段:组件的卸载

组件的销毁阶段本身是比较简单的,只涉及一个生命周期,如下图所示: 2.生命周期(上) - 图14
对应上文的 Demo 来看,我们点击“隐藏子组件”后就可以把 ChildComponents 从父组件中移除掉,进而实现卸载的效果。整个过程如下图所示:
2.生命周期(上) - 图15
这个生命周期本身不难理解,重点是怎么触发它组件销毁的常见原因有以下两个。

(i)组件在父组件中被移除了:这种情况相对比较直观,对应的就是我们上图描述的这个过程。

(ii)组件中设置了 key 属性,父组件在 render 的过程中,发现 key 值和上一次不一致,那么这个组件就会被干掉。

在这一模块中,理解到第一个原因就可。对于原因二,只需要先记住存在这样一种现象即可。至于组件里面为什么要设置 key,为什么 key 改变后组件就必须被干掉?以上两个问题的解答需要先去理解 React 的“调和过程”,而“调和过程”将是接下来的其他模块中重点讲解的内容。

4、总结

在本课时,对 React 设计思想中的“虚拟 DOM”和“组件化”这两个关键概念形成了初步的理解,同时也对 React 15 中的生命周期进行了系统的学习和总结。到这里,可以了解到了React 生命周期在“过去”很长一段时间里的形态。
而在 React 16 中,组件的生命周期其实已经发生了一系列的变化。