2.2 生命周期与 Hooks 本节摘要:组件的生命周期描述它从挂载、更新到卸载的一生。类组件用生命周期方法管理这一切,函数组件用 Hooks 完成对应。本节从类组件的生命周期方法讲起,建立「挂载 / 更新 / 卸载 / 错误处理」四阶段的完整地图,再逐一映射到 useState、useEffect、useRef 等 Hooks,最后用「为何函数组件每次渲染都会重新执行」解释闭包陷阱与依赖数组。
本节摘要:组件的生命周期描述它从挂载、更新到卸载的一生。类组件用生命周期方法管理这一切,函数组件用 Hooks 完成对应。本节从类组件的生命周期方法讲起,建立「挂载 / 更新 / 卸载 / 错误处理」四阶段的完整地图,再逐一映射到 useState、useEffect、useRef 等 Hooks,最后用「为何函数组件每次渲染都会重新执行」解释闭包陷阱与依赖数组。
阅读完本节,你应当能够:
写 React 组件时,有一类代码没有「自然的位置」:请求数据、订阅事件、设置定时器、手动操作 DOM。这些代码统称副作用——它们不产生界面描述,而是影响外界或被外界影响。
比如一个组件挂载后要请求用户数据、卸载前要清理定时器。这些逻辑放哪?放在渲染函数里?不行——渲染函数会被反复调用,副作用也会反复执行,还可能在渲染期间修改外部状态,破坏渲染的纯性。
于是 React 给了两套答案:类组件用生命周期方法,函数组件用 Hooks。它们的本质是同一件事——给副作用安排一个确定执行的时机。
在 Hooks 出现之前,类组件是管理状态和处理副作用的主要方式。每个类组件都经历四个阶段:
组件被创建并插入 DOM。方法按顺序调用:
class MyComponent extends React.Component { constructor(props) { super(props); this.state = { data: null }; } componentDidMount() { // 挂载后发请求,这是最常见的用途 fetch('/api/data') .then(res => res.json()) .then(data => this.setState({ data })); } render() { return <div>{this.state.data ? this.state.data : 'Loading...'}</div>; } }
组件因 props 或 state 改变而重新渲染。方法调用顺序:
class MyComponent extends React.Component { componentDidUpdate(prevProps) { // 只在 userId 变化时才请求,避免无限循环 if (this.props.userId !== prevProps.userId) { fetch(`/api/user/${this.props.userId}`) .then(res => res.json()) .then(data => this.setState({ userData: data })); } } render() { /* ... */ } }
componentDidUpdate 里有一个著名陷阱:如果在这里无条件 setState,会触发又一次更新,再次走进 componentDidUpdate,无限循环。解法就是上面的「比较 prevProps,只在变化时更新」。
组件从 DOM 中移除。只有 componentWillUnmount 一个方法,用于清理——取消定时器、取消订阅、清理监听器。这一步漏了就是内存泄漏。
class MyComponent extends React.Component { componentDidMount() { this.intervalId = setInterval(() => console.log('Tick'), 1000); } componentWillUnmount() { clearInterval(this.intervalId); // 忘记清理 = 泄漏 } render() { /* ... */ } }
两个方法:static getDerivedStateFromError 更新 state 显示降级 UI,componentDidCatch 记录错误。这构成错误边界的基础,第 3.8 节会详细展开。
类组件的生命周期方法把「同一件事的相关代码」拆散了——请求数据、清理订阅往往分散在 componentDidMount 和 componentWillUnmount 两处。Hooks 的卖点之一就是把相关逻辑聚合在一起。
import { useState } from 'react'; function Counter() { const [count, setCount] = useState(0); return <button onClick={() => setCount(count + 1)}>{count}</button>; }
useState 返回一个数组:当前状态值和更新函数。setCount 调用后触发重渲染,新渲染里 count 是更新后的值。它取代了类组件的 this.state 与 setState。
useEffect 是最常用也最容易被误用的 Hook。它的定位是「在渲染完成后执行副作用」。
import { useState, useEffect } from 'react'; function UserProfile({ userId }) { const [data, setData] = useState(null); useEffect(() => { // 副作用:请求数据 let cancelled = false; fetch(`/api/user/${userId}`) .then(res => res.json()) .then(d => { if (!cancelled) setData(d); }); // 清理函数:组件卸载或依赖变化前执行 return () => { cancelled = true; }; }, [userId]); // 依赖数组:userId 变化才重新执行 return <div>{data ? data.name : 'Loading...'}</div>; }
拆开看三个关键点:
依赖数组 [userId]:只有依赖变化时,effect 才重新执行。空数组 [] 表示只在挂载时执行一次(清理在卸载时执行)——对应 componentDidMount 与 componentWillUnmount 的组合。不传数组,则每次渲染都执行,要小心。
清理函数:useEffect 返回的函数在「下次 effect 执行前」和「组件卸载时」调用。上面例子里用它标记请求已取消,避免卸载后 setState 报错。它对应 componentWillUnmount。
async 不能直接写在 useEffect 里:useEffect 的参数函数要求返回清理函数或 undefined,而 async 函数返回 Promise。正确姿势是内部再包一层 async 函数调用。
关于依赖数组,有一个常见的疑问:为什么空数组的效果只执行一次,却仍能读取到最新的 props?答案是不能——空数组意味着 effect 只在挂载时创建一次,它闭包捕获的是挂载那次渲染的值。如果挂载后 props 变了,空数组 effect 里读到的还是旧值。这再次印证了上一节讲的闭包规则:要读到新值,就得把变化的值写进依赖。
| 生命周期方法 | useEffect 对应 | 说明 |
|---|---|---|
| componentDidMount | useEffect(fn, []) |
空数组,挂载后执行一次 |
| componentDidUpdate | useEffect(fn, [deps]) |
依赖变化时执行 |
| componentWillUnmount | 清理函数 | effect 返回的函数 |
| 三个全包 | useEffect(fn) |
每次渲染都执行(慎用) |
useRef 返回一个 .current 可变对象,在组件整个生命周期内保持不变。它有两个用途:保存可变值(不触发重渲染)、引用 DOM(第 2.6 节)。
import { useRef } from 'react'; function Timer() { const intervalRef = useRef(null); // intervalRef.current 可以存定时器 id // 修改它不会触发重渲染 }
useRef 与 useState 的本质区别在于:修改 ref.current 不触发重渲染,修改 state 会。所以「需要跨渲染保存、但变化不需要驱动界面」的值,用 useRef;「变化需要驱动界面」的值,用 useState。这条分界线是选择时的第一判断。
当状态逻辑变复杂——多个子状态联动、更新规则多——useState 会显得散。useReducer 把更新逻辑集中到 reducer 函数里,更接近 Redux 的思维(第 3.3 节会讲):
import { useReducer } from 'react'; function reducer(state, action) { switch (action.type) { case 'increment': return { count: state.count + 1 }; case 'decrement': return { count: state.count - 1 }; default: return state; } } function Counter() { const [state, dispatch] = useReducer(reducer, { count: 0 }); return ( <button onClick={() => dispatch({ type: 'increment' })}> {state.count} </button> ); }
reducer 是纯函数:接收旧状态和 action,返回新状态。所有更新逻辑集中在一处,可读性和可测试性都更好。判断标准:多个 useState 之间互相依赖、更新规则复杂时,升级到 useReducer。
函数组件之间复用状态逻辑,官方方案是自定义 Hook——一个以 use 开头、内部调用其他 Hook 的函数。比如把「窗口尺寸」逻辑抽出来:
import { useState, useEffect } from 'react'; function useWindowSize() { const [size, setSize] = useState({ width: window.innerWidth, height: window.innerHeight }); useEffect(() => { const handleResize = () => { setSize({ width: window.innerWidth, height: window.innerHeight }); }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); return size; }
任何一个组件都能用 const size = useWindowSize(); 拿到窗口尺寸,逻辑只写一次。这是 Hooks 相对类组件最大的优势——类组件时代想复用这类逻辑,只能靠高阶组件,又绕又晦涩。自定义 Hook 是第 3.5 节组件设计模式里「逻辑与视图分离」的基石。
函数组件每次渲染都会重新执行整个函数体。这意味着:每次渲染里访问到的 props、state,都是那次渲染时的快照。 这个特性引出了著名的闭包陷阱。
看这个「计数器加一后停顿一秒再打印」的组件:
function Counter() { const [count, setCount] = useState(0); const handleClick = () => { setCount(count + 1); setTimeout(() => { alert(count); // 打印的是旧值 }, 1000); }; return <button onClick={handleClick}>加一</button>; }
点击时 count 是 0,setCount(1) 排队更新。一秒后 setTimeout 里的回调执行,它捕获的是点击那次渲染的 count——0,而不是最新的 1。所以弹窗显示「0」。
这不是 bug,是闭包的正常工作方式:函数记住了定义它的那次渲染里的变量值。 类似的问题还有:在 setInterval 里读 state 永远读到初始值、effect 依赖漏写导致读到旧值。
setState 的函数式写法不依赖当前渲染的值:
setCount(prev => prev + 1);
prev 是 React 提供的最新值,永远正确。适用于「更新依赖旧值」的场景。
如果 effect 里用到了某个变量,把它加进依赖数组:
useEffect(() => { const id = setInterval(() => console.log(count), 1000); return () => clearInterval(id); }, [count]); // count 变化时重新创建定时器
count 每次变化,effect 重新执行,定时器重建,打印的永远是最新值。代价是定时器频繁重建,但正确性优先。
⚠️ 常见坑:依赖数组漏写。ESLint 的 react-hooks/exhaustive-deps 规则能帮你自动检测,强烈建议开启。遇到「界面和数据不一致」的诡异现象,先怀疑依赖数组。
💡 关键直觉:把「函数组件每次渲染都是独立世界」想成「每次渲染都是给同一个角色换了一套剧本」。闭包捕获的是写台词那一版的剧本,不是最新一版。要拿到最新一版,就把它写进依赖,或者用函数式更新绕开。
现在回答一个实际选型问题。React 16.8 之后,官方推荐新代码用函数组件,原因在第 1.4 节说过:简洁、无 this、逻辑聚合。但存量项目里类组件大量存在,你必须能读、能改、能迁移。
| 维度 | 类组件 | 函数组件 |
|---|---|---|
| 状态 | this.state + setState | useState、useReducer |
| 副作用 | componentDidMount 等 | useEffect |
| 逻辑复用 | 无内建方案(要靠 HOC) | 自定义 Hook |
| 代码量 | 多 | 少 |
| 社区现状 | 维护存量 | 新代码主流 |
迁移时的典型套路:class 里的 constructor 状态 → useState;componentDidMount 里的副作用 → useEffect(依赖空数组);componentDidUpdate → useEffect(带依赖);componentWillUnmount → useEffect 清理函数。照这个映射搬,大部分组件可以平滑过渡。
迁移还有一个值得注意的点:类组件里的 this.state 在 setState 后是合并更新——只改传入的属性,其余保留。函数组件的 useState 则是替换更新——setState 传入什么,状态就是什么。所以当状态是对象时,函数组件里要手动展开旧值:
// 类组件:setState({ name: '新名字' }) 会保留原 state 的其他字段 // 函数组件: const [user, setUser] = useState({ name: '', age: 0 }); setUser({ ...user, name: '新名字' }); // 必须展开,否则 age 丢失
这个差异是迁移时的隐蔽坑,也是「同样的逻辑换了写法行为却变了」的常见原因。跨组件共享的复杂状态对象,可以考虑 useReducer 或合并到一个 state 里,避免频繁展开。
用 Hooks 必须遵守两条规则,违反时 React 会在运行时报警告甚至崩溃:
规则一:只在顶层调用 Hooks。 不要在 if、for、函数嵌套里调用 useState、useEffect。原因:React 靠 Hook 的调用顺序来识别每个 Hook 属于哪份状态。如果渲染间 Hook 数量或顺序变了,状态就错位了。
规则二:只在 React 函数组件或自定义 Hook 中调用。 普通 JavaScript 函数里调用 Hook 没有意义,React 也找不到对应的组件实例。
这两条规则是理解 Hooks 实现的一把钥匙:React 用「位置」而非「名字」管理状态,所以调用顺序必须稳定。这也是为什么 ESLint 的 react-hooks/rules-of-hooks 规则是必开的。
下一节看用户交互的入口——事件处理。合成事件、事件委托、this 绑定、受控组件,这些是让界面「活起来」的关键,也是和原生 DOM 事件差异最集中的地方,理解了它,表单、按钮、输入框的交互行为就不再靠猜。