React 组件生命周期有三个阶段:挂载(组件第一次出现在屏幕上)、更新(因为 state、props 或 context 改变而再次渲染)和卸载(组件被移除)。在函数组件中,你用 useEffect 来响应这些阶段:依赖为 [] 的 effect 在挂载后运行,带依赖的 effect 在依赖发生变化的更新后运行,而 effect 返回的函数在卸载时运行。
预览下方的控制台会实时显示每个阶段。
控制台一开始显示 render, count = 0 和 mounted。每次点击 Count 都会记录一次渲染和一次更新。点击 Unmount 会记录 unmounted;再点击 Mount 会从 count = 0 重新开始,因为 state 已经随组件一起被丢弃了。
挂载
当 React 第一次在组件树的某个位置渲染一个组件时,这个组件就挂载了。React 调用这个函数,根据它返回的 JSX 创建 DOM 节点并插入页面,让浏览器完成绘制,然后运行组件的 effect。
useEffect(fn, []) 是启动那些只要组件还在屏幕上就应该一直存在的东西的地方:订阅、定时器、连接、首次数据请求。它在绘制之后运行,所以永远不会拖慢第一帧。
更新
在以下情况下,组件会更新(再次渲染):
- 它自己的 state 改变了,
- 它的父组件再次渲染(默认情况下,无论 props 是否改变,每个子组件也都会渲染),
- 它读取的某个 context 改变了。
每次更新之后,React 会运行依赖发生变化的那些 effect。在再次运行某个 effect 之前,它会先运行这个 effect 上一次的清理函数,所以旧的订阅会在新的订阅建立之前关闭。渲染一页更详细地介绍了哪些情况会触发渲染。
卸载
当父组件不再渲染某个组件(就像上面的 {show && <Counter />})、它的 key 改变,或者根节点本身被卸载时,这个组件就会卸载。React 会移除它的 DOM 节点,运行所有 effect 的清理函数,并丢弃它的 state。如果这个组件之后再次出现,它就是一个全新的实例。
父组件与子组件
React 自上而下渲染,却自下而上运行 effect。当父组件和子组件一起挂载时,子组件的 effect 先运行,所以父组件的 effect 可以放心地认为子组件已经完全准备好了。
挂载时依次记录 render Parent、render Child、Child effect、Parent effect。点击 Remove,清理函数会按相反的顺序运行:先 Parent cleanup,再 Child cleanup。
渲染阶段与提交阶段
每次更新都会经历两个阶段:
- 渲染阶段。React 调用你的组件来计算新的 JSX,并和上一次的结果进行比较。此时屏幕上还没有任何变化。React 可能会暂停这部分工作,在 StrictMode 下运行两次,或者把它丢弃,这就是为什么渲染必须是纯的:不发请求,不建订阅,不修改外部变量。
- 提交阶段。React 把差异应用到 DOM 上,绑定 ref,并运行
useLayoutEffect回调,这一切都发生在浏览器绘制之前。useEffect回调在那之后运行,通常是在浏览器完成绘制之后。如果更新是由点击或其他离散输入引起的,React 会改为在 layout effect 之后立即运行它们,让结果在下一个事件之前就准备好。
每次点击都会按相同的顺序记录这三行。这些 effect 没有依赖数组,所以每次渲染后都会运行。给两者都加上 [] 作为第二个参数,它们就只会在第一次渲染后运行。
类组件生命周期方法及其对应的 Hook 写法
类组件把生命周期写成有固定名称的方法。你会在较老的代码以及错误边界中遇到它们,错误边界至今仍然需要用类来写。
| 类方法 | 运行时机 | 函数组件中的对应写法 |
|---|---|---|
constructor | 一次,在首次渲染之前 | useState(initial)、useRef |
static getDerivedStateFromProps | 每次渲染之前 | 在渲染期间计算该值,或者用 key 重置 |
render | 每次渲染 | 函数体 |
componentDidMount | 首次渲染的结果进入 DOM 之后 | useEffect(fn, []) |
shouldComponentUpdate | 更新之前,可以跳过更新 | React.memo(它返回的是相反的答案:“props 是否相等”) |
getSnapshotBeforeUpdate | 紧接在 DOM 变化之前 | 没有直接对应的 Hook;最接近的是 useLayoutEffect 配合 ref |
componentDidUpdate | 每次更新之后 | useEffect(fn, [deps])(挂载后也会运行) |
componentWillUnmount | 移除之前 | useEffect 返回的清理函数 |
getDerivedStateFromError、componentDidCatch | 子组件抛出错误时 | 没有:编写一个错误边界类 |
下面这个类会在每个方法运行时记录日志:
挂载时依次记录 constructor、getDerivedStateFromProps、render、componentDidMount。点击一次会记录 getDerivedStateFromProps、shouldComponentUpdate、render、getSnapshotBeforeUpdate、componentDidUpdate。让 shouldComponentUpdate 返回 false,点击就不再更新按钮了:state 改变了,但 React 跳过了渲染。类组件一页展示了如何把这样的类改写成函数组件。
用同步的思路思考,而不是生命周期
上面的对照表有助于阅读老代码,但它可能会让你在写 effect 时照搬类组件的习惯,比如写一个大的“did mount” effect 和一个大的“did update” effect。更好的理解方式是把 effect 读作“让这个外部事物与这些值保持同步”:连接到 roomId,当 roomId 改变时,断开连接并重新连接。每同步一样东西就写一个 effect,把它用到的值列为依赖,然后让 React 决定何时运行它。
常见问题
React 组件生命周期有哪些阶段?
挂载(React 创建组件并把它的输出显示到屏幕上)、更新(因为 state、props 或 context 改变而再次渲染)和卸载(组件被移除并执行清理)。
componentDidMount 对应的 Hook 写法是什么?
useEffect(() => { ... }, [])。空的依赖数组让它只运行一次,在首次渲染的结果显示到屏幕上之后运行。它返回的函数相当于 componentWillUnmount。
componentDidUpdate 对应的 Hook 写法是什么?
在依赖数组中放入你关心的值的 useEffect,例如 [userId]。与 componentDidUpdate 不同,它在首次渲染之后也会运行,所以同时涵盖了挂载和更新。
父组件和子组件的 effect 按什么顺序运行?
React 从父组件往下渲染,但从子组件往上运行 effect:子组件的 effect 先于父组件的运行。卸载时,父组件的清理函数先于子组件的运行。
什么是渲染阶段和提交阶段?
在渲染阶段,React 调用你的组件,算出屏幕应该是什么样子;这部分工作可以暂停,也可以丢弃。在提交阶段,React 把变化应用到 DOM 上并运行 layout effect;普通的 effect 在那之后运行,通常是在浏览器完成绘制之后。