« 回到博客列表

探索 React 组件之间的生命周期

May 2nd, 2019阅读本文大约需要 7 分钟

0)写在前面

React 组件的生命周期,相信大家都非常熟悉了,无非那么几个函数,官方文档已经写得非常清楚了。(那还有什么好说的?浪费感情!合上!)

一般我们所讨论的,都是单个组件的生命周期。如果是多个组件之间呢?比如父子组件?兄弟组件?各个周期又是什么样的?异步路由的情况呢?前阵子新出的 Hooks 呢?有几个人敢站出来说我全知道的?(反正我是不敢)

刚好也是最近遇到一些关于生命周期的问题,项目中涉及到大量的异步操作,需要清楚地知道各部分的执行顺序,借此机会整理一下。

1)在你继续之前

这篇文章并不是入门教学,如果你对 React 一点不了解的话,或许这篇文章并不适合你。

我假定你已经掌握 React 的基本知识,例如:组件的生命周期、Hooks 的基本概念、类组件和函数组件的区别 等,并用 React 开发过有一定复杂度的应用。

这里我们不讨论 shouldComponentUpdate()React.memo() 等优化手段,只考虑最原始的情况。

本文以浏览器作为目标环境,React Native 和 Electron 在基本概念上是一样的,细节上的不同不作为本文的讨论重点,

2)关于 Hooks 的生命周期

确切地说,Hooks 并不是一种新的组件类型,它只是一种代码复用的方式,并且总是伴随着函数组件一起出现。

在 Hooks 之前,函数组件是没有 state 的概念的,因而也就不存在生命周期一说,就只是一个 render 函数。Hooks 的出现,让函数组件也可以拥有 state,相应的也就引入了生命周期的概念,具体来说也就是 useEffect()useLayoutEffect() 具体何时执行的问题。

函数组件的本质是函数,而函数本身是没有生命周期的,Hooks 的出现也没有改变这一点。这里我们讨论的对象是「组件」,组件是可以有生命周期的。因此当我在后面的文字中提到 Hooks 时,我其实是在表示「使用了 Hooks 的函数组件」(虽然这个说法不是很严谨,但是这不重要,你懂我意思就好)。

3)那么我们就来做个实验吧

为了一探究竟,我写了一个 Demo 来模拟一些常见的用例:父子组件、兄弟组件、同步/异步路由、类组件和 Hooks、组件初始化时的异步操作(如访问 API)等。

如果你有遇到 Demo 没覆盖到的使用场景,欢迎提 Issue。

3.1)TL,DR;

我知道大家的时间都很宝贵,赶时间的朋友可以直接看结论;时间宽裕的朋友,我们从下一节开始细聊:

  1. 同步路由,父组件在 render 阶段创建子组件。
  2. 异步路由,父组件在自身挂载完成之后才开始创建子组件。
  3. 挂载完成之后,在更新时,同步组件和异步组件是一样的。
  4. 无论是挂载还是更新,以 render 完成为界,之前父组件先执行,之后子组件先执行。
  5. 兄弟组件大体上按照在父组件中的出场顺序执行。
  6. useEffect 会在挂载/更新完成之后,延迟执行。
  7. 异步请求(如访问 API)何时得到响应与组件的生命周期无关,即父组件中发起的异步请求不保证在子组件挂载完成前得到响应。

3.2)挂载过程

父子组件的挂载分为三个阶段。

第一阶段,父组件执行到自身的 render,解析其下有哪些子组件需要渲染,并对其中同步的子组件进行创建,挨个执行各组件到 render,生成到目前为止的 Virtual DOM 树,并 commit 到 DOM。

第二阶段,此时 DOM 节点已经生成完毕,组件挂载完成,开始后续流程。先依次触发同步子组件各自的 componentDidMount / useLayoutEffect,最后触发父组件的。

第三阶段,如果组件使用了 useEffect,则会在第二阶段之后触发 useEffect。如果父子组件都使用了 useEffect,那么子组件先触发,然后是父组件。

如果父组件中包含异步子组件,则会在父组件挂载完成后被创建。

对于兄弟组件,如果是同步路由,它们的创建顺序和在父组件中定义的出场顺序是一致的。

对于「异步的兄弟组件」,最终的加载顺序是按照 JSX 中定义的顺序,还是按照 js 文件下载完成的顺序,我暂时还不能确定。

按照我对“异步”的理解,我更倾向于认为是按照下载完成的顺序,这更符合“按需加载”的概念。

之所以会造成困扰,是因为据我目前所观察到的情况,两种顺序是一致的,我还没有遇到过后定义但先加载的情况。

大部分时候我们会以页面为单位去划分异步组件,单个页面需要加载多个异步组件的场景比较少;即便在这些少数场景中,单次需要请求的文件数量也不会很多,不至于超过浏览器的并发上限;即便超过,也会按照在父组件中定义的出场顺序去分批发起请求。考虑到单个异步组件的文件尺寸通常都很小,加载速度非常快,同一批发起的请求基本上也都是同时到达,因此大部分时候下载完成的顺序和定义的顺序是一致的。

但没遇到不代表不存在,该问题我会进一步验证,已经有结果的小伙伴也可以分享一下。

如果组件的初始化过程包含异步操作(通常在 componentDidMount()useEffect(fn, []) 中进行),这些操作何时得到响应与组件的生命周期无关,完全看异步操作本身花了多少时间。

3.3)更新过程

React 的设计遵循单向数据流模型,兄弟节点之间的通信也会经过父组件(Redux 和 Context 也是通过改变父组件传递下来的 props 实现的),因此任何两个组件之间的通信,本质上都可以归结为父组件更新导致子组件更新的情况。

父子组件的更新同样分为三个阶段。

第一、三阶段,和挂载过程基本一样,无非是第一阶段多了一个 Reconciliation 的过程,第三阶段需要先执行 useEffect 的 Cleanup 函数。

第二阶段,和挂载过程也很类似,都是子组件先于父组件,但更新比挂载涉及的函数要多一些:

  1. getSnapshotBeforeUpdate()
  2. useLayoutEffect() 的 Cleanup
  3. useLayoutEffect() / componentDidUpdate()

React 会按照上面的顺序依次执行这些函数,每个函数都是各个子组件的先执行,然后才是父组件的执行。具体说来,就是先执行各个子组件的 getSnapshotBeforeUpdate(),然后是父组件的 getSnapshotBeforeUpdate(),再然后是各个子组件的 componentDidUpdate(),父组件的 componentDidUpdate(),以此类推。

这里我们把类组件和 Hooks 的生命周期函数放在了一起,因为父子组件可以是这两种组件类型的任意排列组合。实际渲染时不一定每一个函数都有用到,只会调用组件实际拥有的函数。

3.4)卸载过程

卸载过程涉及到 componentWillUnmount()useEffect() 的 Cleanup、useLayoutEffect() 的 Cleanup 这三种函数,顺序固定为父组件的先执行,子组件按照在 JSX 中定义的顺序依次执行各自的方法。

注意,此时的 Cleanup 函数会按照在代码中定义的顺序先后执行,与函数本身的特性无关。

如果卸载旧组件的同时伴随有新组件的创建,新组件会先被创建并执行完 render,然后卸载不需要的旧组件,最后新组件执行挂载完成的回调。

4)Hooks 的特别之处

根据 React 的官方文档,useEffect()useLayoutEffect() 都是等效于 componentDidUpdate() / componentDidMount() 的存在,但实际上两者在一些细节上还是有所不同:

4.1)先来未必先走

useLayoutEffect() 永远比 useEffect() 先执行,即便在你的代码中 useEffect() 是写在前面的。所以 useLayoutEffect() 才是事实上和 componentDidUpdate() / componentDidMount() 平起平坐的存在。

useEffect() 会在父子组件的 componentDidUpdate() / componentDidMount() 都触发之后才被触发。当父子组件都用到 useEffect() 时,子组件中的会比父组件中的先触发。

4.2)不团结的 Cleanup

同样都拥有 Cleanup 函数,useLayoutEffect() 和它的 Cleanup 未必是挨着的。

当父组件是 Hooks、子组件是 Class 时,能够很明显看出,useLayoutEffect() 的 Cleanup 会在 getSnapshotBeforeUpdate()componentDidUpdate() 之间被调用,而 useLayoutEffect() 则是和 componentDidUpdate() 同级,按照更新过程的顺序被调用。

Hooks 作为子组件时也是这么个过程,只是没有了子组件,看上去不那么明显罢了。

useEffect() 就不一样,它和它的 Cleanup 紧密团结在一起,每次执行都是前后脚一起的,从不分离。

5)小结

无论是类组件还是 Hooks,单拎出来大家肯定都很熟悉它们的生命周期,但当把它们混在一起,就没那么简单了。撰写这篇博客的过程,帮助我理清了这通乱麻,但愿也能够帮到坚持看到这里的你。