1. 为什么到了今天还要聊 React 生命周期
如果你是在 2019 年之前入行前端,React 生命周期大概率是你面试前背得最熟的一块内容。componentDidMount、shouldComponentUpdate、componentWillReceiveProps这些名字,当年几乎是条件反射级别的存在。后来 Hooks 出来了,函数组件成了主流,很多新同学的第一反应是:生命周期是不是已经过时了,还用得着学吗?
我的答案是:不但要学,而且必须搞懂。原因很直接——Hooks 并没有消灭生命周期,它只是换了一种写法。useEffect的依赖数组、清理函数、执行时机,本质上还是在回答同一个问题:这段代码应该在组件的哪个阶段跑、跑几次、什么时候该清理。你如果不懂生命周期这套心智模型,写useEffect基本就是靠试,试到不报错为止,一旦遇到闭包陷阱、重复请求、内存泄漏,就只能干瞪眼。
这篇文章我打算把 React 生命周期从头到尾捋一遍,既讲清楚类组件时代那套经典生命周期为什么这么设计,也讲清楚 Hooks 时代这些阶段是怎么被映射和替代的。中间会穿插我自己踩过的坑、排查问题的思路,以及一些文档里不会明说但实际项目里很关键的细节。不管你是刚接触 React 的新手,还是写了几年函数组件、对生命周期只剩模糊印象的老手,应该都能从里面捞到点东西。
先把结论摆前面:生命周期不是一套 API,而是一套“组件从出生到死亡,每个阶段该干什么”的思维框架。API 会变,框架不会。你把这个框架吃透,React 怎么改版本你都不慌。
2. 生命周期到底在解决什么问题
2.1 从“组件的一生”这个类比说起
要理解生命周期,最好的方式不是去背方法名,而是把组件想象成一个人。一个人有出生、成长、衰老、死亡,每个阶段有每个阶段该做的事。组件也一样:它被创建出来、挂到页面上、因为数据变化重新渲染、最后被移除。React 把这一整个过程切成了几个明确的阶段,并在每个阶段给你留了“钩子”,让你能在合适的时机插入自己的逻辑。
为什么需要这些钩子?因为有些事情天然就有时机要求。比如你要发网络请求拿数据,肯定得等组件挂载之后再做,总不能组件还没出生就去请求吧;比如你注册了一个定时器或者订阅了一个事件,组件销毁时必须取消掉,不然就内存泄漏了。这些“必须在对的时间做对的事”的需求,就是生命周期存在的根本理由。
我见过不少新手把网络请求直接写在 render 里,结果组件每渲染一次就发一次请求,页面直接卡死。这就是典型的没搞懂生命周期——render 阶段是纯计算,不该有副作用。理解生命周期,本质上就是理解哪些事该在哪个阶段做。
2.2 挂载、更新、卸载三大阶段
经典生命周期可以粗分成三大阶段,这个划分到今天依然成立:
- 挂载阶段(Mounting):组件实例被创建并插入 DOM,一生只有一次。
- 更新阶段(Updating):组件的 props 或 state 发生变化,触发重新渲染,可能发生零次到无数次。
- 卸载阶段(Unmounting):组件从 DOM 中被移除,一生也只有一次。
除了这三大阶段,还有一个容易被忽略但很重要的概念叫错误处理阶段,对应componentDidCatch和getDerivedStateFromError,用来捕获子组件树里抛出的错误,防止整个页面白屏。这个后面会单独讲。
把这三个阶段记牢,后面所有的钩子方法都能对号入座。挂载阶段关心“初始化”,更新阶段关心“响应变化”,卸载阶段关心“善后清理”。你只要在写代码时问自己一句:我现在这段逻辑,属于哪个阶段?很多 bug 在写之前就能避免。
2.3 一张表看清各阶段对应的钩子
为了让你有个全局视角,我先把类组件生命周期的主要钩子和它们所属的阶段、执行时机整理成一张表。这张表建议你收藏,后面看具体内容时可以随时对照。
| 阶段 | 钩子方法 | 执行时机 | 常见用途 |
|---|---|---|---|
| 挂载 | constructor | 实例化时最先调用 | 初始化 state、绑定 this |
| 挂载 | static getDerivedStateFromProps | render 之前调用 | 根据 props 派生 state |
| 挂载 | render | 必须实现,返回 JSX | 描述 UI |
| 挂载 | componentDidMount | 首次挂载到 DOM 后 | 发请求、订阅、操作 DOM |
| 更新 | static getDerivedStateFromProps | render 之前调用 | 同上 |
| 更新 | shouldComponentUpdate | render 之前调用 | 性能优化,决定是否重渲染 |
| 更新 | render | 必须实现 | 描述 UI |
| 更新 | getSnapshotBeforeUpdate | DOM 更新前调用 | 读取更新前的 DOM 信息 |
| 更新 | componentDidUpdate | DOM 更新后调用 | 根据变化做副作用 |
| 卸载 | componentWillUnmount | 卸载前调用 | 清理定时器、取消订阅 |
| 错误 | static getDerivedStateFromError | 子组件抛错后 | 渲染降级 UI |
| 错误 | componentDidCatch | 子组件抛错后 | 记录错误日志 |
这张表里有些方法你可能没见过,比如getSnapshotBeforeUpdate,它出现得比较晚,用得也少,但在某些特定场景下是唯一解。后面会专门讲。
3. 挂载阶段:组件出生的那一次
3.1 constructor:别在这里做多余的事
constructor是组件实例化时第一个被调用的方法,也是很多人最容易用错的地方。它的标准职责只有两件事:初始化state,以及给事件处理函数绑定this。
class Demo extends React.Component { constructor(props) { super(props); this.state = { count: 0 }; this.handleClick = this.handleClick.bind(this); } }这里有个新手常犯的错误:忘记调用super(props)。一旦忘了,this.props在 constructor 里就是 undefined,后面所有依赖 props 的逻辑都会出问题。React 官方文档明确要求 constructor 里第一行必须是super(props),这不是可选项。
另一个常见误区是在 constructor 里发网络请求或者做订阅。我见过有人这么写,理由是“想尽早拿到数据”。但 constructor 执行时组件还没挂载到 DOM,这时候做副作用不仅时机不对,而且在某些渲染模式下可能被调用多次。constructor 只做纯初始化,副作用一律放到 componentDidMount,这是铁律。
还有一点,如果你不需要初始化 state,也不需要在事件处理里用this,那 constructor 完全可以不写。React 会自动帮你处理。很多教程上来就让你写 constructor,其实没必要,能省则省。
3.2 render:必须是纯函数
render是类组件里唯一必须实现的方法,它的职责非常单一:根据当前的 props 和 state,返回要渲染的 JSX。它应该是一个纯函数——同样的输入永远得到同样的输出,不产生任何副作用。
什么叫副作用?发请求、改全局变量、操作 DOM、打印日志(严格来说也算),这些都不该出现在 render 里。原因在于 React 的渲染机制:render 可能被调用多次,可能被中断、可能被丢弃重来。如果你在 render 里发了请求,那请求次数就完全不可控了。
我踩过的一个坑是这样的:早期写代码时在 render 里根据 props 计算了一个派生值,然后顺手把这个值setState回去了,结果直接死循环。render 里绝对不能调用setState,这是硬性禁忌。派生值应该用计算属性(在 render 里直接算)或者getDerivedStateFromProps来处理。
提示:判断一段逻辑能不能放 render,就问自己一句——这段逻辑重复执行十次,结果会不会不一样?会,就别放。
3.3 componentDidMount:副作用的主战场
componentDidMount是挂载阶段最重要的钩子,组件第一次渲染到 DOM 之后立即调用,而且只调用一次。所有需要“组件已经存在”才能做的事,都放这里:发网络请求、注册事件监听、初始化第三方库(比如图表、地图)、操作 DOM 节点。
componentDidMount() { this.timer = setInterval(() => { this.setState({ now: Date.now() }); }, 1000); fetch('/api/data').then(res => res.json()).then(data => { this.setState({ data }); }); }为什么请求放这里而不是 constructor?因为 componentDidMount 执行时,组件已经挂载,你可以安全地setState触发重新渲染,用户能看到加载状态到数据展示的完整过程。而且这个时机是确定的,不会因为渲染模式的变化而乱套。
这里有个细节值得说:在 componentDidMount 里setState会触发一次额外的渲染,但它发生在浏览器绘制之前,用户不会看到中间态闪烁。所以“挂载后立即更新一次状态”这种操作是安全的,不用担心性能问题。
3.4 getDerivedStateFromProps:一个容易用错的静态方法
getDerivedStateFromProps是个静态方法,在 render 之前调用,挂载和更新阶段都会触发。它的设计初衷是:当 props 变化需要反映到 state 上时,提供一个统一的入口。
static getDerivedStateFromProps(props, state) { if (props.value !== state.lastValue) { return { lastValue: props.value }; } return null; }注意它是static的,意味着你不能在里面访问this,只能通过参数拿到 props 和 state,返回一个对象来更新 state,或者返回 null 表示不更新。这个限制是故意的,就是为了防止你在里面做副作用。
我的建议是:能不用就不用。这个方法的命名和用法都容易让人误用,很多场景其实用“完全受控组件”或者“给组件加 key 强制重建”会更清晰。React 官方文档自己也说,这个方法应该谨慎使用。如果你发现自己在里面写复杂逻辑,大概率是组件设计出了问题。
4. 更新阶段:变化发生时该做什么
4.1 shouldComponentUpdate:性能优化的第一道闸门
shouldComponentUpdate是更新阶段的第一道关卡,它在 render 之前调用,返回 true 表示继续渲染,返回 false 表示跳过本次更新。这是类组件时代最常用的性能优化手段。
shouldComponentUpdate(nextProps, nextState) { return nextProps.id !== this.props.id || nextState.count !== this.state.count; }默认情况下它返回 true,也就是任何 props 或 state 变化都会触发重渲染。当组件树很大、渲染成本很高时,你可以在这里做浅比较,跳过不必要的更新。
但这里有个大坑:如果你手动实现了 shouldComponentUpdate,就必须保证比较逻辑覆盖所有会影响渲染的字段。漏掉任何一个,就会出现“数据变了但界面没更新”的诡异 bug。我见过有人只比较了 props 忘了比较 state,结果点击按钮数字不涨,排查了半天。
正因为手写比较容易出错,React 后来提供了React.PureComponent,它内置了浅比较逻辑。如果你的 props 和 state 都是简单类型,直接继承 PureComponent 就行,省心。但要注意,浅比较对嵌套对象无效——如果 props 里是个对象,只要引用没变,PureComponent 就认为没变化,哪怕对象内部字段改了。这时候要么保证数据不可变(每次返回新对象),要么老老实实手写比较。
4.2 getSnapshotBeforeUpdate:抓住更新前的最后一瞬
getSnapshotBeforeUpdate是个比较冷门的钩子,在 DOM 更新之前调用,返回值会作为第三个参数传给componentDidUpdate。它的唯一使用场景是:你需要在 DOM 变化之前读取一些信息,然后在变化之后用这些信息做点什么。
最经典的例子是聊天窗口的滚动位置保持。当新消息插入时,如果用户本来就在底部,我们希望滚动条继续停在底部;如果用户正在往上翻历史消息,就不该被打断。这个判断需要在 DOM 更新前记录当前的滚动位置,更新后再决定怎么调整。
getSnapshotBeforeUpdate(prevProps, prevState) { if (prevProps.messages.length < this.props.messages.length) { const list = this.listRef.current; return list.scrollHeight - list.scrollTop; } return null; } componentDidUpdate(prevProps, prevState, snapshot) { if (snapshot !== null) { const list = this.listRef.current; list.scrollTop = list.scrollHeight - snapshot; } }这个钩子用得少,但一旦遇到“需要在 DOM 变化前后做对比”的场景,它是唯一解。记住它的返回值会传给 componentDidUpdate 就行。
4.3 componentDidUpdate:更新后的副作用处理
componentDidUpdate在 DOM 更新完成后调用,更新阶段每次渲染后都会触发(首次挂载不触发,那是 componentDidMount 的活)。它接收两个参数:更新前的 props 和 state,方便你对比变化。
componentDidUpdate(prevProps) { if (prevProps.userId !== this.props.userId) { this.fetchUserData(this.props.userId); } }这里最关键的一点是:在 componentDidUpdate 里调用 setState 必须加条件判断,否则会陷入无限循环。因为 setState 触发更新,更新又调用 componentDidUpdate,再 setState……死循环就这么来的。上面代码里的if判断就是防止这个的标准写法。
另一个常见用途是:当某个 prop 变化时重新发请求。比如用户 ID 变了,要重新拉取该用户的数据。这种“响应 prop 变化”的逻辑放这里最合适,配合条件判断,既准确又不会重复请求。
4.4 关于 componentWillReceiveProps 等旧钩子
如果你看的是老教程,可能会遇到componentWillReceiveProps、componentWillMount、componentWillUpdate这几个钩子。它们现在都被标记为不安全(UNSAFE_),在新代码里不应该再使用。
为什么被废弃?核心原因是它们在 render 之前执行,且可能被多次调用。React 未来的并发渲染模式下,渲染过程可能被中断、暂停、重来,这些在 render 之前执行的钩子就会变得不可预测。React 团队为了给并发模式铺路,把它们逐步淘汰了。
componentWillReceiveProps的功能基本被getDerivedStateFromProps和componentDidUpdate取代。如果你在维护老项目遇到它们,能重构就重构,实在动不了就加上UNSAFE_前缀让它先跑着,但心里要清楚这是技术债。
5. 卸载阶段与错误处理
5.1 componentWillUnmount:善后工作不能省
componentWillUnmount在组件被移除之前调用,是清理工作的最后机会。所有在 componentDidMount 里建立的“长期资源”,都必须在这里释放:定时器、事件监听、订阅、网络请求的取消、第三方库的实例销毁。
componentDidMount() { this.timer = setInterval(this.tick, 1000); window.addEventListener('resize', this.handleResize); } componentWillUnmount() { clearInterval(this.timer); window.removeEventListener('resize', this.handleResize); }不清理的后果是什么?最典型的是内存泄漏和“对已卸载组件 setState”的警告。定时器还在跑,回调里还在 setState,但组件已经没了,React 会警告你。更严重的是,如果订阅没取消,回调持续执行,内存占用越来越高,页面越来越卡。
我排查过一个线上问题:某个列表页反复进出几十次后越来越卡。最后定位到是某个子组件在 componentDidMount 里注册了全局事件监听,但 componentWillUnmount 里忘了移除。每次进入页面就多注册一个监听,几十次下来就是几十个重复监听同时响应,性能直接崩掉。清理逻辑和初始化逻辑必须成对出现,这是写组件的基本纪律。
5.2 错误边界:别让一个组件崩掉整个页面
错误边界是 React 16 引入的概念,用来捕获子组件树中抛出的 JavaScript 错误,防止整个应用白屏。它依赖两个生命周期方法:static getDerivedStateFromError和componentDidCatch。
class ErrorBoundary extends React.Component { state = { hasError: false }; static getDerivedStateFromError(error) { return { hasError: true }; } componentDidCatch(error, info) { console.error('组件出错:', error, info.componentStack); } render() { if (this.state.hasError) { return <div>出错了,请稍后重试</div>; } return this.props.children; } }getDerivedStateFromError负责渲染降级 UI,componentDidCatch负责记录错误日志,两者分工明确。用法上,把可能出错的组件包在 ErrorBoundary 里就行。
但要注意,错误边界只能捕获渲染期间、生命周期方法和构造函数里的错误,捕获不了事件处理函数里的错误、异步代码里的错误、以及它自己抛出的错误。事件处理里的错误得用普通的 try/catch 处理。这个限制很多人不知道,以为包了 ErrorBoundary 就万事大吉,结果事件回调里报错照样崩。
6. Hooks 时代:生命周期去哪了
6.1 useEffect 如何映射三大阶段
函数组件没有生命周期方法,但useEffect把三大阶段的能力都覆盖了。理解映射关系,是函数组件写对副作用的关键。
| 类组件 | 函数组件等价写法 |
|---|---|
| componentDidMount | useEffect(() => {}, []) |
| componentDidUpdate | useEffect(() => {}) 或带依赖 |
| componentWillUnmount | useEffect 的清理函数 |
| componentDidMount + componentDidUpdate | useEffect(() => {}) 不带依赖数组 |
useEffect(() => { // 挂载时执行,等价于 componentDidMount fetchData(); return () => { // 卸载时执行,等价于 componentWillUnmount cancelRequest(); }; }, []); // 空依赖数组,只跑一次依赖数组是 useEffect 的灵魂。空数组表示“只在挂载和卸载时执行”,不写数组表示“每次渲染后都执行”,写了具体依赖表示“依赖变化时执行”。这个规则看起来简单,但实际用起来坑非常多,下一节细说。
6.2 依赖数组与闭包陷阱
useEffect 最容易出问题的地方就是依赖数组和闭包。先看一个经典 bug:
function Counter() { const [count, setCount] = useState(0); useEffect(() => { const timer = setInterval(() => { setCount(count + 1); // 这里的 count 永远是 0 }, 1000); return () => clearInterval(timer); }, []); // 空依赖,闭包捕获了初始的 count return <div>{count}</div>; }这段代码的本意是每秒加一,但实际结果是永远停在 1。原因是空依赖数组让 effect 只执行一次,回调里捕获的是初始渲染时的count(值为 0),之后每次执行都是0 + 1。这就是闭包陷阱。
解决办法有两种。一是把 count 加进依赖数组,但这样每次 count 变化都会重建定时器,不是最优。二是用函数式更新:
setCount(prev => prev + 1);函数式更新不依赖外部闭包里的值,永远基于最新的 state 计算,是解决这类问题的标准姿势。
依赖数组的另一个坑是对象和函数的引用问题。如果你把一个每次渲染都新建的对象放进依赖数组,effect 就会每次都执行,因为引用每次都变。解决办法是用useMemo缓存对象,或者用useCallback缓存函数,或者干脆把依赖拆成基本类型字段。
提示:ESLint 的 exhaustive-deps 规则一定要开,它会帮你检查依赖数组是否完整。虽然有时候它的建议会让你觉得烦,但绝大多数情况下它是对的,忽略它迟早出 bug。
6.3 useLayoutEffect 与 useEffect 的区别
useLayoutEffect和useEffect用法几乎一样,区别在于执行时机。useEffect 是异步的,在浏览器绘制之后执行;useLayoutEffect 是同步的,在 DOM 更新之后、浏览器绘制之前执行。
这个区别什么时候重要?当你需要在用户看到画面之前同步修改 DOM 时。比如测量一个元素的高度然后立即调整布局,如果用 useEffect,用户会先看到旧布局闪一下再变,用 useLayoutEffect 就能避免闪烁。
useLayoutEffect(() => { const height = ref.current.offsetHeight; setHeight(height); // 在绘制前完成,用户看不到闪烁 }, []);但 useLayoutEffect 会阻塞绘制,用多了会拖慢页面。默认用 useEffect,只有确实需要同步操作 DOM 时才用 useLayoutEffect,这是基本原则。
6.4 用 useRef 模拟实例变量
类组件里你可以用this.xxx存一些不参与渲染的实例变量,函数组件里对应的工具是useRef。它返回一个可变的 ref 对象,.current属性可以存任何值,而且修改它不会触发重新渲染。
function Timer() { const timerRef = useRef(null); useEffect(() => { timerRef.current = setInterval(() => {}, 1000); return () => clearInterval(timerRef.current); }, []); }useRef 最常见的用途就是存定时器 ID、DOM 引用、上一次的值等。它和 useState 的区别是:useState 变化会触发渲染,useRef 不会。所以那些“需要跨渲染保持、但不需要驱动 UI”的值,都放 useRef。
7. 实操中踩过的坑与排查技巧
7.1 常见问题速查表
我把这些年遇到的生命周期相关问题整理成一张表,方便你对照排查。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 组件卸载后仍报 setState 警告 | 异步回调未取消 | 检查 componentWillUnmount / useEffect 清理函数 |
| 数据变了界面不更新 | shouldComponentUpdate 比较不全 | 检查比较逻辑是否覆盖所有字段 |
| useEffect 无限执行 | 依赖数组含每次新建的对象/函数 | 用 useMemo/useCallback 缓存 |
| 定时器数值不增长 | 闭包陷阱 | 改用函数式更新 setState |
| 页面反复进出越来越卡 | 事件监听/订阅未清理 | 检查挂载与卸载是否成对 |
| 子组件报错整个页面白屏 | 缺少错误边界 | 用 ErrorBoundary 包裹 |
7.2 几个反直觉的细节
有几个细节,文档里不一定强调,但实际项目里很关键。
第一,componentDidMount 里的 setState 不会造成性能问题。前面提过,它发生在绘制前,用户看不到中间态。所以“挂载后立即更新一次”是安全的,不用刻意避免。
第二,useEffect 的清理函数在每次依赖变化时都会执行,不只是卸载时。比如依赖是[id],那么 id 从 1 变到 2 时,会先执行 id=1 时的清理函数,再执行 id=2 的 effect。理解这一点,才能正确写“取消上一次请求”的逻辑。
第三,StrictMode 下开发环境的 effect 会执行两次。这是 React 故意设计的,用来帮你发现清理逻辑的缺失。很多人第一次遇到会以为代码有 bug,其实不是。如果你的 effect 没有正确清理,执行两次就会暴露问题。生产环境不会这样,所以别为了绕过它而删掉 StrictMode。
7.3 我的排查思路
遇到生命周期相关的 bug,我的排查顺序是这样的:先确认是哪个阶段的问题(挂载、更新还是卸载),再看对应的钩子或 effect 有没有写对,最后检查依赖和清理。大部分问题都出在“该清理的没清理”和“依赖数组写错”这两类上。
具体操作上,我会在关键钩子里打日志,观察执行顺序和次数。比如在 componentDidMount、componentDidUpdate、componentWillUnmount 里各打一条,看看实际调用了几次、顺序对不对。函数组件就在 useEffect 里打,配合依赖数组观察触发频率。这个方法土,但极其有效,比盯着代码空想快得多。
8. 生命周期思维在函数组件里的延续
聊到这里,你应该能感觉到:生命周期这套东西,形式变了,内核没变。类组件用方法名告诉你“现在是什么阶段”,函数组件用依赖数组和清理函数让你自己表达“什么时候该做什么”。后者更灵活,但也更依赖你对生命周期的理解。
我个人的体会是,写函数组件时,脑子里最好还是保留“挂载、更新、卸载”这三个抽屉。每写一个 useEffect,先问自己:这个副作用属于哪个抽屉?它该在什么时候跑?跑完要不要清理?把这三个问题回答清楚,代码基本就不会出大问题。
还有一个建议:不要为了用 Hooks 而用 Hooks。有些场景类组件反而更清晰,比如需要精确控制 shouldComponentUpdate 的复杂性能优化,或者需要 getSnapshotBeforeUpdate 的 DOM 对比。React 官方也说了,类组件不会消失,两者可以共存。选哪个,看哪个让代码更好懂、更好维护。
最后分享一个小技巧:如果你在维护一个老项目,里面全是类组件,别急着全量重构成 Hooks。先把那些有 bug 的、逻辑混乱的组件挑出来,用 Hooks 重写,边改边验证。全量重构风险太大,渐进式替换才是稳妥的做法。我自己经手过的一个项目就是这么干的,半年时间慢慢把核心组件换成了函数组件,期间线上一直稳定运行,没有出现大的回归问题。