1. 从一次线上白屏事故说起为什么生命周期理解不到位会出大问题前阵子帮一个朋友排查线上问题现象很典型页面首屏加载后偶发白屏控制台报错指向某个组件在卸载后仍然调用了状态更新方法。他第一反应是“是不是接口挂了”但日志显示接口正常返回。真正的原因藏在他对 React 生命周期执行顺序的一个想当然假设里——他以为父组件先挂载完子组件才开始挂载于是在父组件的某个生命周期里直接去操作子组件暴露出来的实例方法结果子组件还没准备好代码就执行了。这类问题在真实项目里非常高频。React 生命周期看起来是入门知识但真正把它吃透的人并不多。很多人停留在“componentDidMount 发请求、componentWillUnmount 清定时器”这种口诀层面一旦遇到并发渲染、严格模式双调用、异步数据竞态、副作用清理这些场景就开始凭感觉写代码然后靠试错来修 bug。这篇内容我想系统地把 React 生命周期这件事讲清楚。不是照本宣科地罗列 API而是从“为什么会有生命周期”“每个阶段到底发生了什么”“新旧写法怎么对应”“哪些坑我亲自踩过”这几个角度展开。适合已经写过一段时间 React、但对生命周期只有模糊印象的开发者也适合想从类组件迁移到函数式组件的同学。读完你应该能做到看到一个副作用需求立刻判断它该放在哪个阶段、用什么写法、要注意什么边界。需要先说明一点React 官方从 16.3 开始就在推动生命周期 API 的调整到 18 版本函数式组件加 Hooks 已经是绝对主流。但类组件在存量项目里依然大量存在而且理解类组件的生命周期恰恰是理解 Hooks 中 useEffect 执行时机的钥匙。所以这两条线我会对照着讲而不是只讲其中一条。2. 挂载阶段从元素到真实 DOM 之间到底发生了什么2.1 constructor 不是用来发请求的很多人第一次接触类组件习惯在 constructor 里做各种初始化包括发接口请求。这个做法在早期能跑通但它是错的。constructor 执行的时候组件对应的 DOM 节点根本还不存在你拿不到任何真实的页面元素而且此时 setState 也不会触发额外渲染因为组件还没进入渲染流程。constructor 真正该做的事情只有两件初始化 state以及给事件处理函数绑定 this如果你不用箭头函数写法的话。我见过有人在 constructor 里读取 props 然后存到 state用来做“默认值”这本身可以但要清楚这只在首次挂载时生效后续 props 变化不会同步到 state容易埋下数据不一致的隐患。class UserPanel extends React.Component { constructor(props) { super(props); // 只做纯初始化不碰 DOM不发请求 this.state { expanded: false }; this.handleToggle this.handleToggle.bind(this); } }注意constructor 里必须第一行调用 super(props)否则 this.props 在构造函数内是 undefined。这个坑新手几乎都会踩一次。2.2 getDerivedStateFromProps 的定位被严重误解这个静态方法从 16.3 引入用来替代不安全的 componentWillReceiveProps。它的设计初衷非常窄只在 props 变化需要映射到 state 时使用。但实际项目里它被滥用的程度惊人很多人把它当成“每次渲染前都能跑一遍的钩子”在里面做各种判断和赋值。它的签名是 static getDerivedStateFromProps(props, state)返回一个对象来更新 state或者返回 null 表示不更新。注意它是静态的拿不到 this也就无法访问实例方法或发请求。这个限制是刻意的就是为了防止你在里面做副作用。我个人的经验是绝大多数场景根本不需要它。如果你发现自己在用它做数据同步先停下来想想是不是可以直接从 props 计算或者用受控组件的方式解决。真正需要它的场景通常是“props 传入一个初始值但后续用户可以在组件内修改它”这种受控与非受控混合的情况。2.3 render 必须是纯函数render 是唯一一个类组件必须实现的方法。它的核心约束是不能有副作用不能直接修改 state不能操作 DOM不能发请求。它只负责根据 props 和 state 返回 React 元素。为什么这么强调纯因为 React 在并发模式下可能多次调用 render甚至调用后丢弃结果。如果你的 render 里有副作用比如往数组里 push 数据、修改外部变量就会产生难以追踪的 bug。我见过有人在 render 里做数据格式化并缓存到实例属性上结果在严格模式下因为双调用导致缓存被写两次数据错乱。一个实用的判断标准同样的 props 和 state 输入render 必须返回同样的输出且不改变任何外部状态。满足这个条件你的组件在并发渲染下才是安全的。2.4 componentDidMount 才是副作用的正经入口挂载阶段真正适合做副作用的地方是 componentDidMount。此时组件已经渲染成真实 DOM 并插入页面你可以安全地访问 DOM 节点、发起网络请求、订阅事件、启动定时器。这里有个执行顺序的细节值得记住子组件的 componentDidMount 先于父组件的 componentDidMount 执行。因为 React 是深度优先地完成整棵树的挂载子节点先就绪父节点后就绪。这个顺序解释了我开头提到的那个白屏事故——父组件想在 componentDidMount 里调用子组件实例方法但此时子组件其实已经挂载完了问题出在他用的是 ref 回调的时机判断错误而不是生命周期顺序本身。但很多人会把这两件事混为一谈。componentDidMount() { // 安全DOM 已就绪 this.timer setInterval(() { this.setState({ now: Date.now() }); }, 1000); fetch(/api/user) .then(res res.json()) .then(data this.setState({ user: data })); }提示在 componentDidMount 里 setState 会触发一次额外渲染但它发生在浏览器绘制之前用户通常感知不到闪烁。不过如果数据量大仍要留意性能。3. 更新阶段props、state 与 forceUpdate 触发的差异3.1 shouldComponentUpdate 是性能优化的第一道闸门当 props 或 state 发生变化或者父组件重新渲染时组件进入更新流程。第一个被调用的是 shouldComponentUpdate(nextProps, nextState)它返回布尔值决定是否继续渲染。返回 false 会跳过 render 以及后续的 componentDidUpdate。这个方法是 React 早期性能优化的核心手段。默认情况下它返回 true意味着任何变化都会重新渲染。如果你的组件树很深而大部分子组件的 props 其实没变就会产生大量无谓的渲染。但手动写 shouldComponentUpdate 很容易出错因为你要逐个比较 props 和 state 的每个字段。浅比较漏掉嵌套对象的变化就会导致该更新的没更新界面卡在旧数据上。这也是为什么后来官方推出了 PureComponent它内置了浅比较逻辑。shouldComponentUpdate(nextProps, nextState) { // 只关心特定字段避免无谓渲染 return nextProps.userId ! this.props.userId || nextState.expanded ! this.state.expanded; }我踩过的一个坑是在 shouldComponentUpdate 里比较数组时用了引用比较但父组件每次渲染都新建了数组导致引用永远不同优化完全失效。后来改成比较数组长度加关键字段才真正生效。所以做这类优化前一定要确认数据来源是否稳定。3.2 getSnapshotBeforeUpdate 与 componentDidUpdate 的配合getSnapshotBeforeUpdate(prevProps, prevState) 在 render 之后、DOM 更新之前调用它的返回值会作为第三个参数传给 componentDidUpdate。这个 API 的存在是为了解决一个具体问题在 DOM 更新前捕获一些信息比如滚动位置然后在更新后恢复它。典型场景是聊天窗口。新消息插入后如果用户本来就在底部应该自动滚到底如果用户正在往上翻历史记录就不该打断他。这个判断需要在 DOM 更新前记录当前滚动位置更新后再决定是否滚动。getSnapshotBeforeUpdate(prevProps, prevState) { if (prevProps.messages.length this.props.messages.length) { const list this.listRef.current; // 判断更新前是否贴近底部 return list.scrollHeight - list.scrollTop - list.clientHeight 50; } return null; } componentDidUpdate(prevProps, prevState, snapshot) { if (snapshot true) { const list this.listRef.current; list.scrollTop list.scrollHeight; } }componentDidUpdate 是更新阶段做副作用的主要位置。在这里你可以根据前后 props 的差异发请求、操作 DOM、更新外部状态。但一定要加条件判断否则每次更新都发请求会造成无限循环。最常见的错误就是在 componentDidUpdate 里无条件 setState导致更新触发 setStatesetState 又触发更新。3.3 forceUpdate 绕过 shouldComponentUpdate 的代价forceUpdate 会跳过 shouldComponentUpdate强制组件重新渲染。它通常用于组件依赖了 React 无法追踪的外部数据源比如某个全局对象的状态变了但组件不知道。我的建议是尽量别用它。一旦用了 forceUpdate就意味着你放弃了 React 的更新优化机制而且代码的可预测性会下降。更好的做法是把外部数据纳入 state 或 props 管理让更新走正常流程。如果实在要用至少确保它不会在高频事件里被反复调用。3.4 更新阶段的执行顺序梳理把更新阶段的顺序理清楚对排查问题很有帮助。当父组件重新渲染导致子组件更新时顺序大致是父组件 shouldComponentUpdate、父组件 render、子组件 shouldComponentUpdate、子组件 render、子组件 getSnapshotBeforeUpdate、子组件 componentDidUpdate、父组件 getSnapshotBeforeUpdate、父组件 componentDidUpdate。注意 render 是父先子后而 componentDidUpdate 是子先父后。这个“渲染向下、副作用向上”的规律和挂载阶段是一致的。理解了这一点你就能预判副作用执行的先后避免在父组件的 componentDidUpdate 里依赖子组件已经完成的副作用。4. 卸载与错误处理清理不干净就是内存泄漏4.1 componentWillUnmount 里必须做的事组件从 DOM 中移除前componentWillUnmount 会被调用。这是你清理副作用的最后机会。没清理干净的典型后果包括定时器继续跑、事件监听器残留、网络请求回来后 setState 报错、订阅没取消导致内存泄漏。需要清理的东西我列一个清单setInterval 和 setTimeout、addEventListener 注册的监听、WebSocket 或事件订阅、在 componentDidMount 里发起的、可能晚于卸载返回的请求。最后这一项最容易被忽略因为请求本身不是“订阅”但它回来时组件已经没了。componentWillUnmount() { clearInterval(this.timer); window.removeEventListener(resize, this.handleResize); this.unsubscribe(); // 标记请求已取消避免回来时 setState this.cancelled true; }对于网络请求可以在 then 回调里判断 this.cancelled或者用 AbortController 主动取消。后者更彻底能真正中断请求节省带宽。注意componentWillUnmount 里不要调用 setState因为组件即将销毁setState 不会触发渲染还会产生警告。4.2 componentDidCatch 与错误边界的边界componentDidCatch(error, info) 让组件能捕获子树中抛出的错误避免整个应用崩溃。它配合 getDerivedStateFromError 使用后者负责更新 state 显示降级 UI前者负责记录错误日志。但错误边界有明确的捕获范围它只能捕获渲染期间、生命周期方法和构造函数里抛出的错误不能捕获事件处理函数里的错误也不能捕获异步代码里的错误。事件处理里的错误需要你自己用 try/catch 处理。class ErrorBoundary extends React.Component { state { hasError: false }; static getDerivedStateFromError(error) { return { hasError: true }; } componentDidCatch(error, info) { // 上报日志注意这里不要抛错 reportError(error, info.componentStack); } render() { if (this.state.hasError) { return FallbackUI /; } return this.props.children; } }我实际用下来错误边界最适合放在路由层级和关键模块外层而不是每个小组件都包一层。包太多会让降级 UI 碎片化用户体验反而更差。5. 函数式组件时代useEffect 如何映射旧生命周期5.1 useEffect 的执行时机与清理机制函数式组件没有生命周期方法但 useEffect 提供了等价能力。它的执行时机是渲染提交到 DOM 之后。默认情况下每次渲染后都会执行。通过第二个参数依赖数组可以控制执行频率。关键要理解清理函数的机制。useEffect 的回调可以返回一个清理函数它会在下一次 effect 执行前、以及组件卸载时被调用。这个设计比 componentWillUnmount 更精细因为它能在每次重新执行前先清理上一次的副作用。useEffect(() { const timer setInterval(() setNow(Date.now()), 1000); // 清理函数下次执行前或卸载时调用 return () clearInterval(timer); }, []); // 空数组只在挂载时执行一次依赖数组的规则要记牢不传第二个参数每次渲染都执行传空数组只在挂载和卸载时执行传具体依赖依赖变化时执行。漏写依赖是最高频的 bug 来源会导致闭包捕获旧值。5.2 用 useEffect 模拟各个生命周期把旧生命周期映射到 useEffect是迁移类组件时的实用技巧。挂载时执行一次用空依赖数组。更新时执行把相关 props 或 state 放进依赖数组。卸载时清理用返回的清理函数。componentDidUpdate 的等价写法要特别注意它需要在依赖变化时执行同时能拿到旧值。可以用 useRef 保存上一次的值在 effect 里对比。const prevCountRef useRef(); useEffect(() { if (prevCountRef.current ! undefined prevCountRef.current ! count) { // 等价于 componentDidUpdate 中对比 prevProps console.log(count 从, prevCountRef.current, 变为, count); } prevCountRef.current count; }, [count]);需要强调的是这种映射只是帮助理解不是让你把类组件思维硬套到 Hooks 上。Hooks 的心智模型是“每次渲染都有独立的 props 和 state”而不是“一个实例贯穿始终”。理解这个差异才能写出正确的 Hooks 代码。5.3 严格模式下的双调用与并发渲染的影响React 18 的严格模式在开发环境下会故意双调用某些函数包括组件的渲染函数、useState 的初始化函数、useEffect 的挂载和清理。目的是帮你发现副作用没清理干净的问题。这意味着你的 effect 必须能承受“执行、清理、再执行”的过程。如果清理函数写得不对双调用就会暴露问题。比如你在 effect 里往全局数组 push 数据清理时没 pop双调用后数据就多了一份。并发渲染带来的另一个变化是render 可能被中断和重启所以 render 必须是纯的。而 effect 在并发模式下依然保证在提交后执行不会被打断。这也是为什么副作用要放在 effect 里而不是 render 里。6. 那些文档不会写、但实战中一定会遇到的坑6.1 异步请求的竞态问题这是我最想强调的一个坑。在 componentDidMount 或 useEffect 里发请求如果依赖项变化快或者用户快速切换页面就会出现旧请求晚于新请求返回的情况导致界面显示过期数据。假设你有一个搜索框用户输入“a”发一个请求又输入“ab”发第二个请求。如果第一个请求返回得慢它可能覆盖第二个请求的结果界面就显示了错误的内容。解决办法是标记当前请求的有效性或者用 AbortController 取消旧请求。useEffect(() { let active true; fetch(/api/search?q${query}) .then(res res.json()) .then(data { if (active) setResults(data); }); return () { active false; }; }, [query]);这个模式我几乎在每个涉及异步数据的组件里都会用。它简单、可靠能挡住绝大多数竞态问题。6.2 setState 的异步性与批量更新setState 不是同步的React 会把多次调用合并成一次更新。这意味着你不能在 setState 之后立刻读取 this.state 拿到新值。如果需要基于旧值计算新值要用函数式写法。// 错误可能拿到旧值 this.setState({ count: this.state.count 1 }); // 正确基于前一个状态计算 this.setState(prev ({ count: prev.count 1 }));在 React 18 之前只有 React 事件处理里的 setState 会批量更新异步回调里的不会。React 18 引入了自动批量更新所有场景下的 setState 都会批量处理。这个变化会影响那些依赖“setState 后立即渲染”的代码迁移时要留意。6.3 闭包陷阱与依赖数组的取舍Hooks 里的闭包陷阱让很多人头疼。effect 捕获的是当次渲染的 props 和 state如果依赖数组没写全effect 里拿到的就是旧值。但把依赖写全又可能导致 effect 频繁执行。我的经验是先保证正确性把该写的依赖都写上再看性能。如果确实因为依赖频繁变化导致性能问题再考虑用 useRef 保存稳定引用或者用 useCallback、useMemo 稳定函数和对象。不要为了减少执行次数而故意漏写依赖那是在给自己埋雷。6.4 生命周期与 ref 的时机配合ref 的赋值时机和生命周期紧密相关。在类组件里ref 在 componentDidMount 之前就已经赋值所以你能在 componentDidMount 里访问到 DOM。在函数式组件里useRef 返回的对象在每次渲染时都是同一个但 current 属性的赋值发生在提交阶段。如果你在 render 期间读取 ref.current可能拿到上一次的值。需要访问最新 DOM 时要在 useEffect 或 useLayoutEffect 里读。useLayoutEffect 在 DOM 更新后同步执行适合需要读取布局信息并同步修改 DOM 的场景比如测量元素尺寸后立即调整位置。7. 我个人的生命周期使用原则写了这么多年 React我总结出几条自己的原则分享出来供参考。第一副作用永远放在生命周期方法或 effect 里render 保持纯净。这条看似基础但违反它带来的 bug 往往最难查。第二清理函数和副作用成对出现。你订阅了什么就取消什么你启动了什么就停止什么。写 effect 的时候顺手把清理写了别等到出问题再补。第三依赖数组宁多勿少。漏写依赖带来的闭包 bug 比多执行几次 effect 严重得多。性能问题可以后面优化数据错误会直接伤害用户。第四能用函数式组件就用函数式组件。Hooks 的心智模型更贴近 React 的设计理念代码也更简洁。类组件只在维护存量代码时使用。第五遇到生命周期相关的诡异问题先打印执行顺序。把各个阶段的日志打出来对照本文梳理的顺序往往一眼就能看出问题在哪。这比盯着代码猜要高效得多。生命周期这件事说到底就是理解 React 在什么时间点、以什么顺序、调用了你的哪些代码。把这个时间线在脑子里建起来大部分“莫名其妙”的 bug 都会变得有迹可循。