如果说 JSX 是学习 React 框架必须要了解的一个概念,那么“生命周期”则是紧随其后的第二个需要学习的内容。虽然现在最新的 React 版本都推荐使用函数组件结合 hooks 的方式来组织应用,而且在函数组件中淡化了对生命周期的介绍,但是对于类组件生命周期的学习理解能够帮助我们以追根溯源的方式,更全面的建立对 React 框架的观感,同时从底层认识 React 中两个重要的阶段:render 与 commit ,帮助我们编写正确且高效的代码。
本次分享会从下面的几个问题出发:
生命周期到底是什么?
类组件生命周期函数有哪些,都在什么场景下使用?
生命周期函数演化的原因是什么?
Hooks 与 生命周期函数的对应关系是什么?
生命周期(lifecycle)的概念在各个领域中都广泛存在,广义来说生命周期泛指自然界和人类社会中各种客观事物的阶段性变化及其规律,在 React 框架中则用来描述组件挂载(创建)、更新(存在)、卸载(销毁)三个阶段。
基本上我们每个新同学都会被要求通读 React 官网的资料,通过这样的方式建立起对 React 框架大致的全像。在此过程中应该能察觉到 React 一直在反复的提及两个关键词:虚拟 DOM 与 组件。
PS:最近 React 发布了新版文档网站,有兴趣的同学可以尝尝鲜。地址:https://beta.reactjs.org/
在前面认识 JSX 的过程中,我们知道虚拟 DOM 的本质就是通过编译 JSX 得到的一个以 JavaScript 对象形式存在的 DOM 结构描述。在组件初始化阶段,会通过生命周期方法 render 生成虚拟 DOM节点,然后通过调用 ReactDOM.render 方法,完成虚拟 DOM 节点到真实 DOM 节点的转换。在组件更新阶段,会再次调用 render 方法生成新的虚拟 DOM 节点,然后借助Diffing 算法比对两次虚拟 DOM 节点的差异,从而找出变化的部分实现最小化的 DOM 更新。所以也可以说虚拟 DOM 是 React 核心算法 Diffing 的基石。
组件化是一种优秀的软件设计思想,这在 React 框架中得到了很好的体现。React 项目中基本上所有的基础单元就是组件,通过组合各种组件构建应用。每个组件既是封闭又是开放。封闭体现在组件内部有自己的一套渲染逻辑(state),在没有数据流交互的情况下,组件与组件之间互不干扰。开放则表现在组件间的通信上,基于单向数据流的原则进行通信(props),而数据通信又会对渲染结果造成影响,通过数据这个桥梁,组件之间彼此开放,互相影响。封闭与开放的特性使得 React 组件具备高度的可维护性与可重用性。
虚拟 DOM 节点的生成依赖于 render 方法,而组件化中的渲染工作流(组件数据改变到组件实际更新的过程)也离不开 render 方法,render 方法的重要性可见一斑。总结来说,生命周期就是虚拟 DOM 实现的基石,同时也是 React 组件化软件思想的直接体现,通过生命周期函数支持组件具备封闭与开放的特性。
很多文章及教程介绍类组件生命函数,都是以最新的 React 来进行讲解,这样的好处是可以让初学者最快捷的掌握最新的 API,然后能够马上投入到开发中。但是与此同时也会导致初学者停留在“能用”的阶段,离“会用”还有一段距离。本文我们从最开始的 React v15 来展开,然后进入到 v16.3 与 v16.4,对比几个版本之间的差异,这样的方式不是为了让大家多记几个 API,而是找到差异并提出问题,方便我们在下一节搞清楚为什么需要对生命周期函数进行改进。这样的方式与直接学 API 相比,无非就是多花了几分钟进行思考,带来的收益是帮助我们从“能用”往“会用”迈进。
React v15 中的生命周期函数主要如下图所示:
constructor(props)
componentWillMount()
componentWillReceiveProps(nextProps)
shouldComponentUpdate(nextProps, nextState)
componentDidMount()
componentWillUpdate(nextProps, nextState)
componentDidUpdate(prevProps, prevState, snapshot)
render()
componentWillUnmount()
// 挂载: constructor -> componentWillMount -> render -> componentDidMount
// 更新(父组件触发): componentWillReceiveProps -> shouldComponentUpdate(true) -> componentWillUpdate -> render -> componentDidUpdate
// 更新(组件内部触发): shouldComponentUpdate(true) -> componentWillUpdate -> render -> componentDidUpdate
// 卸载: componentWillUnmount
在 React 早期,还可以使用 React.createClass() 方法创建组件,在此情况下还有 getDefaultProps与 getInitState 两个生命周期函数。ES6 普及后,这种创建组件的方法就不被推荐使用了,所以此处不做过多的补充。
关于生命周期函数,这里有几点需要特别注意:
shouldComponentUpdate 方法可以指定一个布尔类型的返回值,如果该方法返回值为 false,则可以跳过更新,不执行后续的生命周期方法
componentWillReceiveProps 的触发不是因为传递 props 变化,而是父组件只要被 re-render(重渲染),那么子组件的 componentWillReceiveProps 就会被执行
componentDidUpdate 生命周期函数的两个参数区别于其他的生命周期函数,传入的不是nextProps和nextState,而是prevProps与 prevState,当前的props与state需要从this对象上获取
componentWillUnmount 会在组件被销毁时执行,一般情况组件有两种情况下会被销毁:一个是在父组件中被移除,二是组件被设置了 key 值,父组件在 render 的过程发现 key 与上一次的不一致,那么这个组件也会被销毁,然后被重新初始化,重新设置 key 值
这里我们就不对每一个生命周期函数展开说明了,更详细的可以通过点击下方阅读原文,查看 React 官网 自行了解,也可以通过这个例子加强理解:React15 Lifecycle Demo。
PS:如果打开例子无法正常预览,报错:Target container is not a DOM element,不用惊慌,这是 codesandbox 的问题,此时可以打开任意一个左侧的源代码文件,然后保存一下,预览区域即可恢复正常。
React v16 开始,对生命周期函数做了一些更改,且分为两个版本:v16.3 及之前的版本,与 v16.4 及之后的版本
React 生命周期查看在线地址:https://projects.wojtekmaj.pl/react-lifecycle-methods-diagram/
constructor(props)
static getDerivedStateFromProps(props, state)
shouldComponentUpdate(nextProps, nextState)
getSnapshotBeforeUpdate(prevProps, prevState)
componentDidMount()
componentDidUpdate(prevProps, prevState, snapshot)
render()
componentWillUnmount()
// 挂载: constructor -> getDerivedStateFromProps(null) -> render -> componentDidMount
// 更新(父组件触发): getDerivedStateFromProps(null) -> shouldComponentUpdate(true) -> getSnapshotBeforeUpdate -> render -> componentDidUpdate
// 更新(组件内部触发): getDerivedStateFromProps(null) -> shouldComponentUpdate(true) -> getSnapshotBeforeUpdate -> render -> componentDidUpdate
// 卸载: componentWillUnmount
React v16 与 v15 相比变化还是挺大的(16.3-4之间的变化较小),主要集中在以下几个方面:
componentWillMount 与 componentWillUpdate 及 componentWillReceiveProps
新增了getDerivedStateFromProps 与 getSnapshotBeforeUpdate
新增了getDerivedStateFromError 与 componentDidCatch 错误处理函数
虽然新增的两个方法与废弃的方法它们在触发顺序上大致相同,但是不能简单的认为是新方法替代旧方法。
getDerivedStateFromProps 方法的目的不是为了替换 componentWillMount,而是为了替换 componentWillReceiveProps。该方法是一个静态方法(static),静态方法不依赖于组件的实例而存在,所以无法在方法内部读取 this 对象,而且它应该返回一个新的对象,或者一个 null 值,它存在的目的有且仅有一个:使用 props 来派生/更新 state,所有不是以此为目标的使用方式原则上来说都是错误的。
getDerivedStateFromProps 不仅是在更新阶段会被调用,在挂载阶段也会被调用,这是因为派生 state 的诉求不仅仅在更新时存在,在初始化 state 时也会有需求。通过该方法派生 state 不会引起 render 函数重复执行。以此来看,该方法的出现不是简单的替换逻辑,而是有着承载简化代码的期望。
getSnapshotBeforeUpdate 方法提供了一个时机读取当前 DOM 的一些信息,并把返回的值赋值给 componentDidUpdate 方法的 snapshot 参数,主要用来处理 UI 显示,比如某些区域的滚动位置信息等。
可以通过这个例子加强对新生命周期函数的理解:React16 Lifecycle Demo。
我们大致知道废弃 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。
最后我们梳理一下 React 生命周期函数演化背后的逻辑:
为 Fiber 架构落地清除障碍,引入增量渲染的机制解决同步渲染引起的应用卡顿风险
以废弃改进 API 的方式避免开发者滥用生命周期函数,推行强制性的最佳实践(每一个值有且仅有一个明确的来源)
关于生命周期函数滥用可以参考:你可能不需要使用派生state
https://zh-hans.reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html
生命周期函数只存在于类组件,对于没有 Hooks 之前的函数组件而言,没有组件生命周期的概念(函数组件没有 render 之外的过程),但是有了 Hooks 之后,问题就变得有些复杂了。
Hooks 能够让函数组件拥有使用与管理 state 的能力,也就演化出了函数组件生命周期的概念(render 之外新增了其他过程),涉及到的 Hook 主要有几个:useState、useMemo、useEffect。
更全面的 Hooks 介绍可以复制查看:https://zh-hans.reactjs.org/docs/hooks-reference.html
生命周期方法与 Hook 的对应:https://zh-hans.reactjs.org/docs/hooks-faq.html#how-do-lifecycle-methods-correspond-to-hooks
整体来说,大部分生命周期都可以利用 Hook 来模拟实现,而一些难以模拟的,往往也是 React 不推荐的反模式。
至于为什么设计 Hook,为什么要赋予函数组件使用与管理 state 的能力,React 官网也在 Hook 介绍 做了深入而详细的介绍,总结下来有以下几个点:
便于分离与复用组件的状态逻辑(Mixin,高阶组件,渲染回调模式等)
复杂组件变得难以理解(状态与副作用越来越多,生命周期函数滥用)
类组件中难以理解的 this 指向(bind 语法)
类组件难以被进一步优化(组件预编译,不能很好被压缩,热重载不稳定)
至此,文章前面提出的几个问题都得到了比较深入的解答,而且在寻求答案的过程中,也对 React 框架有了更立体的认识。希望能够通过本文能帮助大家从“能用”往“会用”的方向迈进几步,也欢迎大家把自己的心得或疑问写在评论区,方便我们进一步沟通讨论。
后面我们会有一系列关于 React 框架的文章,请大家持续关注。
参考资料
State&生命周期:
https://zh-hans.reactjs.org/docs/state-and-lifecycle.html
组件 & Props:
https://zh-hans.reactjs.org/docs/components-and-props.html
React.Component:
https://zh-hans.reactjs.org/docs/react-component.html
Hook 简介:
https://zh-hans.reactjs.org/docs/hooks-intro.html#motivation
Hook 概览:
https://zh-hans.reactjs.org/docs/hooks-overview.html
Hook API 索引:
https://zh-hans.reactjs.org/docs/hooks-reference.html
Render and Commit(Beta):
https://beta.reactjs.org/learn/render-and-commit
服务电话: 400-678-1800 (周⼀⾄周五 09:00-18:00)
商务合作: 0571-87770835
市场反馈: marketing@woqutech.com
地址: 杭州市滨江区滨安路1190号智汇中⼼A座1101室