1. 为什么今天还要聊 React 生命周期你可能会有疑问都什么年代了函数组件加 Hooks 早就成了主流写法componentDidMount、componentDidUpdate这些老面孔还有必要翻出来讲吗我一开始也这么想直到前阵子接手一个维护了四年多的老项目里面清一色是 class 组件生命周期函数写得密密麻麻componentWillReceiveProps和componentWillMount混着用才意识到这块知识根本绕不开。你不光要能读懂它还得能在不推翻重写的前提下把逻辑一点点迁移到 Hooks 上去。React 生命周期说白了就是组件从“出生”到“死亡”这一整段过程里框架在特定时间点主动调用的那些钩子函数。它解决的核心问题是让你能在组件渲染的不同阶段插入自己的逻辑比如挂载后拉数据、更新前做对比、卸载时清定时器。这套机制在 class 组件时代是绝对的核心即便到了 Hooks 时代useEffect的依赖数组、清理函数本质上还是在模拟生命周期的行为。所以理解生命周期不是为了怀旧而是为了真正搞懂 React 的渲染心智模型。这篇文章适合三类人一是刚入门 React、还在被 class 和函数组件绕晕的新手二是需要维护老项目、不得不和生命周期打交道的开发者三是想把 Hooks 用得更扎实、理解useEffect背后到底在干什么的进阶选手。我会从整体设计思路讲到每个钩子的实操细节再给出迁移方案和踩坑记录尽量让你看完就能直接上手改代码。2. 生命周期整体设计与阶段拆解2.1 三个阶段的核心划分逻辑React 把组件的生命周期分成三大阶段挂载Mounting、更新Updating、卸载Unmounting。这个划分不是随便定的它对应的是组件在页面上的真实存在状态。挂载就是组件第一次被创建并插入 DOM更新是组件的 props 或 state 发生变化后重新渲染卸载则是组件从 DOM 中被移除。为什么非要分这么细因为不同阶段你能做的事情完全不同。挂载阶段你还没拿到真实的 DOM 节点所以不能做依赖 DOM 的操作更新阶段你要小心别陷入无限循环卸载阶段你必须把之前申请的资源释放掉否则就是内存泄漏。React 把这些时机明确切开就是逼着你在正确的时机做正确的事。我见过不少新手把所有逻辑一股脑塞进componentDidMount结果更新时数据不刷新或者卸载后定时器还在跑。根源就是没搞清楚这三个阶段的边界。你可以把组件想象成一个员工入职挂载、在职期间岗位调整更新、离职卸载。每个节点该办的手续不一样混着来肯定出乱子。2.2 类组件生命周期全景图在 class 组件里完整的生命周期钩子按执行顺序大致是这样的挂载阶段constructor→static getDerivedStateFromProps→render→componentDidMount更新阶段static getDerivedStateFromProps→shouldComponentUpdate→render→getSnapshotBeforeUpdate→componentDidUpdate卸载阶段componentWillUnmount错误处理static getDerivedStateFromError→componentDidCatch这里要特别提醒一句componentWillMount、componentWillReceiveProps、componentWillUpdate这三个带Will前缀的钩子已经被标记为不安全UNSAFE_在新代码里不应该再使用。它们之所以被废弃核心原因是 React 引入了异步渲染和 Fiber 架构渲染过程可能被中断、暂停甚至重启而这些钩子可能在一次更新中被调用多次导致副作用重复执行。这是个非常典型的“设计演进倒逼 API 淘汰”的案例。2.3 为什么废弃 Will 系列钩子我拿componentWillReceiveProps举例说明。它的设计初衷是让父组件传入新 props 时子组件有机会根据新 props 更新自己的 state。听起来合理但问题在于只要父组件重新渲染哪怕 props 根本没变这个钩子也会被触发。很多人在里面写setState结果就是无谓的重复渲染性能直接崩掉。更麻烦的是在异步渲染模式下一次更新可能被 React 拆成好几段执行componentWillReceiveProps可能被调用多次你在里面做的副作用比如发请求就会重复触发。React 团队给出的替代方案是getDerivedStateFromProps它是一个静态方法拿不到this也就没法在里面搞副作用只能纯粹地根据 props 和 state 计算新的 state。这个设计约束看似别扭实则是强制你把“派生状态”和“副作用”分开处理。注意getDerivedStateFromProps必须返回一个对象来更新 state或者返回null表示不更新。它会在挂载和每次更新时都触发所以里面的判断逻辑一定要写严谨否则容易出现 state 被意外覆盖的情况。3. 核心钩子逐个拆解与实操要点3.1 constructor 里到底该写什么constructor是组件实例化时第一个执行的方法。很多人习惯在这里初始化 state、绑定事件处理函数的this。这两件事确实该在这里做但有个前提如果你不需要初始化 state也不需要在事件处理函数里用this那完全可以不写 constructor。我见过有人在 constructor 里发请求拉数据这是典型的错误用法。constructor 执行时组件还没挂载到 DOM此时发请求不仅时机不对而且在服务端渲染场景下会直接报错。正确的做法是把数据请求放到componentDidMount里。class UserPanel extends React.Component { constructor(props) { super(props); // 只有需要初始化 state 或绑定 this 时才写 constructor this.state { userInfo: null }; this.handleRefresh this.handleRefresh.bind(this); } handleRefresh() { // 事件处理逻辑 } render() { return button onClick{this.handleRefresh}刷新/button; } }提示super(props)这行不能省否则this.props在 constructor 里是 undefined。这个坑我踩过当时排查了半天才发现是漏了 super。3.2 componentDidMount 的黄金使用场景componentDidMount是挂载阶段最后一个钩子此时组件已经渲染成真实 DOM你可以安全地访问this.refs、发起网络请求、初始化第三方库比如图表库、地图 SDK。这是整个生命周期里最常用的钩子没有之一。它的执行时机是在浏览器完成 DOM 绘制之后所以在这里做 DOM 测量是可靠的。比如你要获取某个元素的实际高度就必须等到componentDidMount。另外订阅事件、启动定时器也适合放在这里但切记要在componentWillUnmount里对应地取消订阅和清除定时器。componentDidMount() { // 拉取初始数据 fetch(/api/user/list) .then(res res.json()) .then(data this.setState({ list: data })); // 启动定时器 this.timer setInterval(() { this.refreshData(); }, 5000); // 订阅事件 window.addEventListener(resize, this.handleResize); } componentWillUnmount() { // 必须清理否则内存泄漏 clearInterval(this.timer); window.removeEventListener(resize, this.handleResize); }我特别想强调清理这一步。曾经有个项目列表页每次进入都注册一个滚动监听离开时不取消用户来回切换十几次后页面卡得动不了。后来用 Performance 面板一看监听器堆了几十个全是重复注册的。这个教训让我养成了“凡在 didMount 里注册必在 willUnmount 里注销”的肌肉记忆。3.3 shouldComponentUpdate 的性能优化边界shouldComponentUpdate是更新阶段的一道闸门它接收nextProps和nextState返回true表示允许重新渲染返回false则跳过本次渲染。这是 class 组件时代最主要的性能优化手段。但它的使用有个大坑如果你返回false不仅render不执行连componentDidUpdate也不会触发。很多人只想着省渲染却忘了后续逻辑依赖componentDidUpdate结果功能莫名其妙失效。所以用之前一定要确认跳过渲染不会影响其他副作用。shouldComponentUpdate(nextProps, nextState) { // 只有 list 真正变化时才重新渲染 if (nextProps.list ! this.props.list) { return true; } if (nextState.keyword ! this.state.keyword) { return true; } return false; }实际项目里手写shouldComponentUpdate很容易漏字段维护成本高。更稳妥的做法是继承React.PureComponent它会自动对 props 和 state 做浅比较。但浅比较也有局限如果 props 里嵌套了对象只要引用没变即使内部属性变了也不会触发更新。这时候要么保证数据不可变要么老老实实手写比较逻辑。3.4 componentDidUpdate 的正确打开方式componentDidUpdate在每次更新完成后触发接收prevProps和prevState。它的典型用途是当 props 变化后需要重新拉数据或者需要在 DOM 更新后做测量和动画。这里有个必须遵守的规则在 componentDidUpdate 里调用 setState 一定要加条件判断否则就是无限循环。因为 setState 会触发更新更新完又进 componentDidUpdate再 setState如此往复浏览器直接卡死。componentDidUpdate(prevProps) { // 只有 userId 变化时才重新拉数据 if (prevProps.userId ! this.props.userId) { this.fetchUserDetail(this.props.userId); } }我个人的经验是把componentDidUpdate里的逻辑都写成“对比前后值”的形式先判断再执行。这样既避免了死循环也让代码意图更清晰。另外如果你在shouldComponentUpdate里返回了false那componentDidUpdate就不会执行这两者的配合要心里有数。3.5 componentWillUnmount 的清理清单componentWillUnmount是组件卸载前的最后机会你必须在这里把所有“外部资源”清理干净。我整理了一份清理清单基本覆盖了日常开发的所有场景资源类型注册位置清理方式定时器componentDidMountclearInterval / clearTimeout事件监听componentDidMountremoveEventListener网络请求componentDidMount取消请求或设置标志位第三方实例componentDidMount调用实例的 destroy 方法订阅流componentDidMountunsubscribe网络请求的清理经常被忽略。如果组件卸载后请求才返回回调里调用 setState 会报警告虽然新版本 React 不再报错但逻辑上仍然是浪费。更稳妥的做法是用一个标志位记录组件是否还挂载componentDidMount() { this.isMounted true; fetch(/api/data).then(res { if (this.isMounted) { this.setState({ data: res.data }); } }); } componentWillUnmount() { this.isMounted false; }4. 从类组件迁移到 Hooks 的实操路径4.1 useEffect 如何模拟生命周期Hooks 时代没有生命周期函数了但useEffect可以覆盖绝大部分场景。理解它的关键在于useEffect的执行时机是在渲染提交到 DOM 之后这一点和componentDidMount、componentDidUpdate是一致的。对应关系大致如下componentDidMountuseEffect(() {...}, [])依赖数组为空componentDidUpdateuseEffect(() {...})不传依赖数组或传入特定依赖componentWillUnmountuseEffect(() { return () {...} }, [])返回清理函数function UserPanel({ userId }) { const [userInfo, setUserInfo] useState(null); // 相当于 componentDidMount componentDidUpdate依赖 userId useEffect(() { fetchUserDetail(userId).then(setUserInfo); }, [userId]); // 相当于 componentDidMount componentWillUnmount useEffect(() { const timer setInterval(refreshData, 5000); return () clearInterval(timer); }, []); return div{userInfo?.name}/div; }这里有个容易混淆的点依赖数组为空时effect 只在挂载后执行一次不传依赖数组时每次渲染后都执行。很多人把这两个搞混导致请求被重复发送。我的建议是永远显式写出依赖数组哪怕它是空的这样意图最明确。4.2 迁移过程中的三个典型陷阱第一个陷阱是依赖数组漏项。假设 effect 里用到了props.onChange但依赖数组里只写了props.value那onChange变化时 effect 不会重新执行逻辑就可能出错。解决办法是开启 ESLint 的react-hooks/exhaustive-deps规则它会帮你检查漏掉的依赖。第二个陷阱是清理函数执行时机。useEffect返回的清理函数不仅会在组件卸载时执行还会在每次依赖变化、effect 重新执行之前先执行一次。这个行为和componentWillUnmount只执行一次是不同的。如果你在清理函数里做了“只该在卸载时做”的操作就会出问题。第三个陷阱是闭包捕获旧值。effect 里引用的 state 或 props 是当次渲染的快照如果依赖数组没包含它effect 里拿到的永远是旧值。这个坑非常隐蔽表现是“数据明明更新了但 effect 里读到的还是老的”。解决办法还是老老实实补全依赖。4.3 迁移决策什么时候该重写不是所有 class 组件都值得迁移。我的判断标准是如果组件逻辑简单、没有复杂生命周期交互可以顺手改成函数组件如果组件里生命周期钩子互相依赖、逻辑盘根错节强行迁移反而容易引入 bug不如先保持原样等有明确需求时再动。迁移时建议分三步走先把纯展示逻辑抽成函数组件保留 class 外壳再把数据请求和副作用逐步挪到useEffect最后删掉 class 相关代码。每改一步都跑一遍测试确保行为一致。我试过一次性重写一个三百行的组件结果调了两天才把边界情况对齐得不偿失。5. 常见问题与排查技巧实录5.1 setState 在生命周期里的异步陷阱setState在 React 事件处理和生命周期函数里是异步批处理的这意味着你调用完 setState 后立刻读this.state拿到的还是旧值。这个特性在componentDidUpdate里尤其要注意。componentDidUpdate() { this.setState({ count: this.state.count 1 }); console.log(this.state.count); // 打印的是旧值 }如果你需要基于新 state 做操作应该用 setState 的回调函数或者在componentDidUpdate里通过prevState对比。另外在componentDidMount里连续调用多次 setStateReact 会合并成一次渲染这是好事但别指望中间状态能被读到。5.2 无限循环的三种典型成因无限循环是生命周期里最常见的问题我总结了三种典型成因成因表现解决方式didUpdate 里无条件 setState页面卡死控制台疯狂输出加前后值对比判断父组件 render 里传新对象子组件 props 永远“变化”用 useMemo 缓存对象effect 依赖数组包含每次新建的函数effect 反复执行用 useCallback 包裹函数第二种情况特别隐蔽。比如父组件写Child style{{ color: red }} /每次渲染都创建一个新对象子组件的componentDidUpdate就会认为 props 变了如果里面又有 setState循环就起来了。解决办法是把对象提到组件外部或者用useMemo缓存。5.3 卸载后 setState 的警告处理老版本 React 会在组件卸载后调用 setState 时打印警告新版本虽然去掉了警告但问题依然存在。根本原因是异步操作请求、定时器在组件卸载后才完成回调。除了前面说的标志位方案更现代的做法是用AbortController取消请求componentDidMount() { this.controller new AbortController(); fetch(/api/data, { signal: this.controller.signal }) .then(res res.json()) .then(data this.setState({ data })) .catch(err { if (err.name ! AbortError) throw err; }); } componentWillUnmount() { this.controller.abort(); }这样请求会被真正取消既避免了无效的 setState也节省了网络资源。在 Hooks 里同样适用把 controller 放在 effect 里创建清理函数里 abort 即可。5.4 服务端渲染下的生命周期差异如果你的项目用了服务端渲染生命周期会有一半不执行。具体来说componentDidMount和componentWillUnmount只在客户端执行服务端只跑constructor、getDerivedStateFromProps和render。这意味着你不能在 constructor 里做任何依赖浏览器环境的操作比如访问window、document。我踩过的坑是在 constructor 里读localStorage本地开发没问题一上服务端就报错。正确做法是把这类逻辑放到componentDidMount或者用typeof window ! undefined做环境判断。Hooks 里同理useEffect不会在服务端执行所以副作用放里面是安全的。6. 我个人的几条实战心得关于getDerivedStateFromProps我的建议是能不用就不用。它看起来能解决“props 变化同步到 state”的问题但实际项目里大部分这类需求都可以通过“提升状态”或者“完全受控组件”来避免。只有在极少数场景比如根据 props 计算一个带缓存的派生值时才值得用它。用多了会让数据流向变得难以追踪。关于性能优化别一上来就shouldComponentUpdate或React.memo。先测量再优化。用 React DevTools 的 Profiler 看看哪个组件渲染耗时最长再针对性地加优化。我见过有人给所有组件都套上PureComponent结果因为 props 里全是新对象浅比较永远返回 true优化等于没做还增加了比较开销。关于生命周期和 Hooks 的选择我的态度是新项目一律用函数组件加 Hooks老项目按需迁移。不要为了“技术先进”而强行重写稳定运行比代码好看重要得多。真到了要迁移的时候先写测试用例锁住现有行为再动手改这样心里有底。最后分享一个排查生命周期问题的小技巧在关键钩子里打上带时间戳的日志把挂载、更新、卸载的调用顺序完整打印出来。很多看似诡异的问题把执行顺序理清楚就一目了然了。这个方法比盯着代码空想高效得多你可以试试。