☰
React生命周期深度解析:从类组件到Hooks的思维框架
2026/10/13 9:09:30 网站建设 项目流程

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负责渲染降级 UI,componentDidCatch负责记录错误日志,两者分工明确。用法上,把可能出错的组件包在 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 最容易出问题的地方就是依赖数组和闭包。先看一个经典 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 重写,边改边验证。全量重构风险太大,渐进式替换才是稳妥的做法。我自己经手过的一个项目就是这么干的,半年时间慢慢把核心组件换成了函数组件,期间线上一直稳定运行,没有出现大的回归问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询