
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 getDerivedStateFromPropsrender 之前调用根据 props 派生 state挂载render必须实现返回 JSX描述 UI挂载componentDidMount首次挂载到 DOM 后发请求、订阅、操作 DOM更新static getDerivedStateFromPropsrender 之前调用同上更新shouldComponentUpdaterender 之前调用性能优化决定是否重渲染更新render必须实现描述 UI更新getSnapshotBeforeUpdateDOM 更新前调用读取更新前的 DOM 信息更新componentDidUpdateDOM 更新后调用根据变化做副作用卸载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负责渲染降级 UIcomponentDidCatch负责记录错误日志两者分工明确。用法上把可能出错的组件包在 ErrorBoundary 里就行。但要注意错误边界只能捕获渲染期间、生命周期方法和构造函数里的错误捕获不了事件处理函数里的错误、异步代码里的错误、以及它自己抛出的错误。事件处理里的错误得用普通的 try/catch 处理。这个限制很多人不知道以为包了 ErrorBoundary 就万事大吉结果事件回调里报错照样崩。6. Hooks 时代生命周期去哪了6.1 useEffect 如何映射三大阶段函数组件没有生命周期方法但useEffect把三大阶段的能力都覆盖了。理解映射关系是函数组件写对副作用的关键。类组件函数组件等价写法componentDidMountuseEffect(() {}, [])componentDidUpdateuseEffect(() {}) 或带依赖componentWillUnmountuseEffect 的清理函数componentDidMount componentDidUpdateuseEffect(() {}) 不带依赖数组useEffect(() { // 挂载时执行等价于 componentDidMount fetchData(); return () { // 卸载时执行等价于 componentWillUnmount cancelRequest(); }; }, []); // 空依赖数组只跑一次依赖数组是 useEffect 的灵魂。空数组表示“只在挂载和卸载时执行”不写数组表示“每次渲染后都执行”写了具体依赖表示“依赖变化时执行”。这个规则看起来简单但实际用起来坑非常多下一节细说。6.2 依赖数组与闭包陷阱useEffect 最容易出问题的地方就是依赖数组和闭包。先看一个经典 bugfunction 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 时会先执行 id1 时的清理函数再执行 id2 的 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 重写边改边验证。全量重构风险太大渐进式替换才是稳妥的做法。我自己经手过的一个项目就是这么干的半年时间慢慢把核心组件换成了函数组件期间线上一直稳定运行没有出现大的回归问题。