REACT · Vol.II · LESSON 16 · Hooks 精讲
Hooks 规则与十大常见陷阱
两条规则,与它们背后的「槽位」
卷二从第 9 篇到第 15 篇,把常用 Hook 挨个讲了一遍。用法之外,官方给 Hooks 立的规矩只有两条:
- 只在最顶层调用——不要在条件判断、循环、嵌套函数里调用 Hook;
- 只在 React 函数组件或自定义 Hook 里调用——普通 JS 函数、类组件、事件处理器都不行。
先看反面教材,这段代码看起来无害,实则直接把组件搞坏:
function Chat({ userId }: { userId: number | null }) {
const [name, setName] = useState(""); // 第 1 个 Hook
// ❌ 条件里调 Hook:userId 为空时,这个 useState 不执行
if (userId !== null) {
const [online, setOnline] = useState(false); // 第 2 个 Hook
}
const [draft, setDraft] = useState(""); // 第 3 个 Hook
}
为什么 React「管这么宽」?因为 React 不靠名字、不靠变量,只靠「调用顺序」区分同一组件里的多个 Hook。第一次渲染时,React 按调用顺序给每个 Hook 发一个「槽位」(内部是一条链表):useState("") 占 slot[0]、useState(false) 占 slot[1]、第三个 useState("") 占 slot[2]。之后每次渲染都按同样的顺序「对号入座」——第 1 次调用读 slot[0],第 2 次读 slot[1],完全按位置匹配。
现在看条件调用为什么是灾难。组件先在 userId 有值的渲染里登记满 3 个槽位;随后某次渲染 userId 为 null,第 2 个 useState 没执行——于是第 3 个 useState(draft)变成了「本次的第 2 次调用」:
| 本次第 n 次调用 | 读到哪个槽位 | 结果 |
|---|---|---|
| 第 1 次(name) | slot[0] | ✅ 正好是 name |
| 第 2 次(draft 的 useState) | slot[1] | ❌ 读到了 online 的值——状态串台 |
| (没有第 3 次调用) | slot[2] | 本轮无人读取,draft 状态丢失 |
一次插队,全队错位。规则二是同一机制的推论:普通 JS 函数没有「组件渲染」这个上下文,槽位链表不存在,Hook 无处登记;自定义 Hook(第 13 篇)必须以 use 开头,正是为了让工具识别它。(React 19 的 use(Context) 是唯一允许在条件里调用的例外。)
参数绑定靠的是位置:WHERE id = ? AND status = ?,setString(1, ...)、setString(2, ...)。如果动态拼 SQL 时少拼了一个条件,后面的参数全部错位绑定——字符串绑到了日期列上。React 的 Hook 与槽位就是这组 ?:第 n 次调用对第 n 个槽位,插队或缺席,后面全错。为什么不能按名字绑定?因为 useState 可能被调用多次,同一个「名字」出现多次时,顺序是唯一能依赖的标识。
规则二深挖:为什么事件处理器里也不能调 Hook
规则二还有个高频误区:「条件调用只是会错位,那我保证顺序不变,写在别处总行吧?」不行——事件处理器、普通工具函数里一律不能调,这与顺序无关。槽位只在「渲染正在进行」时开放登记:React 渲染某个组件时才打开它的槽位链表,渲染一结束就封存。事件处理器要等用户点击后才运行,那时链表早已封存,里面的 useState 无处登记:
function LikeButton() {
const [liked, setLiked] = useState(false);
function handleClick() {
// ❌ 事件处理器运行在渲染结束之后,槽位链表早已封存
// React 直接报 Invalid hook call
const [popup, setPopup] = useState(false);
setLiked(true);
}
return <button onClick={handleClick}>点赞</button>;
}
function LikeButtonFixed() {
// ✅ 需要哪些状态,在渲染顶层「预报」好
const [liked, setLiked] = useState(false);
const [popup, setPopup] = useState(false);
function handleClick() {
setLiked(true); // 事件处理器只负责「改值」,不负责「建槽」
setPopup(true);
}
return <button onClick={handleClick}>点赞</button>;
}
Spring 里你往容器里注册 Bean,只能发生在启动阶段;请求处理到一半想再塞一个新 Bean 定义,框架不认。运行期要用的资源,必须在装配阶段提前声明——React 的规矩一模一样:状态在渲染期预报(顶层声明),运行期只许读写。
State 类陷阱:函数式更新、闭包旧值与「假更新」
规则理解了,接下来是实战盘点——后端转型最容易踩的十个坑,按 state 类、effect 类、列表与命名类分三组过。
陷阱 2:函数式更新遗漏(呼应第 4 篇)
连续多次 setState、或在定时器与异步回调里 setState,直接写「值」就会踩到闭包快照:
setCount(count + 1); // ❌ 两次都读到本次渲染的 count = 0
setCount(count + 1); // 结果只 +1,不是 +2
setCount(c => c + 1); // ✅ 更新函数排队依次执行
setCount(c => c + 1); // 永远基于「最新值」,结果 +2
陷阱 3:闭包旧值(呼应第 10 篇)
effect 依赖写成 [],闭包就把首次渲染的 state 永久锁死在里面:
useEffect(() => {
const timer = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(timer);
}, []); // ❌ 闭包锁死首帧的 count = 0 → 界面永远卡在 1
// ✅ 更新函数拿「最新值」,不经过闭包里的旧 count
useEffect(() => {
const timer = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(timer);
}, []);
陷阱 4:对象 / 数组 state 直接 mutate(呼应第 4 篇)
React 靠 Object.is(引用相等)判断 state 变没变。原地改属性、往数组里 push,引用没变,React 判定「没变化」,跳过渲染——「改了却不刷新」的头号原因:
// ❌ 引用没变 → React 认为 state 没变 → 界面不动
user.age = 26;
setUser(user);
todos.push(newTodo);
setTodos(todos);
// ✅ 造新对象 / 新数组,引用变了才触发渲染
setUser({ ...user, age: 26 });
setTodos([...todos, newTodo]);
陷阱 5:把「能算出来的」塞进 state
后端思维里「先把数据算好存起来」很自然,但 React 里,渲染期间能算出来的值就不该占一个 state 槽位——存了就要回答「两份数据什么时候同步」,凭空多出一个 bug 面:
// ❌ total 是 items 的派生值,却单独占一个 state,还要用 effect 同步
const [items, setItems] = useState<Item[]>([]);
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(items.reduce((sum, i) => sum + i.price, 0));
}, [items]); // items 变 → 先渲染旧 total,再 setState 再渲染一次
// ✅ 渲染时直接算:items 一变,total 天然就是新的
const total = items.reduce((sum, i) => sum + i.price, 0);
这就像在订单表里冗余一个「合计金额」字段,每处插入明细都要记得手动加总——漏一处就对不上。能算的别存;计算成本真高,才轮到 useMemo(第 11 篇)。
Effect 类陷阱:依赖撒谎、清理遗漏与无限循环
陷阱 6:依赖数组「撒谎」
依赖数组的语义不是「我希望它什么时候执行」,而是「这个 effect 用到了哪些响应式值」的事实陈述。少写一个,React 就按你撒的谎执行:
// ❌ 撒谎:effect 里用了 roomId,依赖却写 []
useEffect(() => {
const conn = createConnection(roomId);
conn.connect();
return () => conn.disconnect();
}, []);
// 症状:切换到别的房间,连上的还是旧房间
// ✅ 用了谁,就声明谁
useEffect(() => {
const conn = createConnection(roomId);
conn.connect();
return () => conn.disconnect();
}, [roomId]);
反过来「多写依赖」也有代价:依赖里若混入每渲染都变引用的对象 / 函数,effect 会反复重跑——引出陷阱 8 的第二种形态。
陷阱 7:effect 清理遗漏 → 订阅泄漏
// ❌ 只订阅、不退订:组件卸载后监听器还挂在 window 上
// 轻则内存泄漏,重则对已卸载组件 setState 报错
useEffect(() => {
window.addEventListener("resize", onResize);
}, []);
// ✅ 成对出现:订阅/退订、连接/断开、起定时器/清定时器
useEffect(() => {
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
后端视角太熟悉了:连接不还池、线程不 shutdown、@PreDestroy 忘了清理——同一类资源管理失职。把 effect 的 return 当成 try-finally 的 finally 来写就行。
陷阱 8:无限循环(两种形态)
// 形态一:渲染期间 setState —— 渲染 → setState → 再渲染 → 直到报错
function OrderTotal({ price, count }: { price: number; count: number }) {
const [total, setTotal] = useState(0);
setTotal(price * count); // ❌ 渲染函数体里直接 setState
return <p>合计:{total}</p>;
}
// ✅ 这本来就是派生值问题(陷阱 5)——渲染时直接算
const total = price * count;
// 形态二:依赖「每渲染都新的引用」—— effect 狂跑,请求风暴
const params = { keyword }; // 每次渲染都是新对象
useEffect(() => {
search(params).then(setResult);
}, [params]); // ❌ 依赖每次都「变了」
// ✅ 依赖原始值;确需传对象,用 useMemo 稳定引用(第 11 篇)
useEffect(() => {
search({ keyword }).then(setResult);
}, [keyword]);
形态一 React 会直接抛 Too many re-renders 帮你叫停;形态二更阴险——界面照常跑,只是网络面板里请求刷成瀑布。排查口诀:effect 反复执行时,先盯依赖数组里有没有「渲染期创建的对象 / 函数 / 数组」。
列表与命名:key 用 index、Hook 不叫 use
陷阱 9:列表 key 用数组下标(呼应第 7 篇)
key 是 React 识别「同一个元素」的身份证。用 index 作 key,删除或排序后,React 把「位置 0」仍当作原来那条数据,组件内部状态跟着错位:
// ❌ key=index:删掉第一项后,李四「顶」到位置 0,
// React 以为位置 0 还是张三那行 —— 行内状态全部错位
{todos.map((t, i) => (
<TodoRow key={i} todo={t} />
))}
// ✅ key=稳定 id:无论怎么删 / 排 / 插,React 都认得「谁是谁」
{todos.map(t => (
<TodoRow key={t.id} todo={t} />
))}
index 何时可接受:列表纯静态——不删除、不重排、行内无自身状态。只要有一丝「会变」可能,就用 id。
陷阱 10:自定义 Hook 不以 use 开头
这条看着像洁癖,实际致命:eslint-plugin-react-hooks 靠命名识别 Hook,才检查得上规则一。你的 getData() 里若偷偷藏了 useEffect,linter 根本不查它——等哪天在循环里调了 getData(),槽位错位,全线崩盘。约定:内部调用了 Hook 的函数,一律 useXxx 命名(见第 13 篇)。
十大陷阱速查表
| # | 陷阱 | 典型症状 | 一句话解法 |
|---|---|---|---|
| 1 | 条件 / 循环 / 嵌套里调 Hook | 状态串台、诡异报错 | 提到顶层;条件写进 Hook 内部 |
| 2 | 函数式更新遗漏 | 连续 setState 只生效一次 | setX(c => ...) |
| 3 | 闭包旧值 | 定时器 / 订阅里读到过期 state | 函数式更新或补依赖(第 10 篇) |
| 4 | 直接 mutate state | 改了就是不刷新 | 不可变更新(第 4 篇) |
| 5 | 可派生值塞进 state | 两份数据不同步 | 渲染时直接算 |
| 6 | 依赖数组撒谎 | 用旧值 / effect 狂跑 | 只声明真实用到的值 |
| 7 | effect 清理遗漏 | 泄漏、重复订阅、报错 | return 清理函数 |
| 8 | 无限循环 | 渲染期 setState / 依赖引用不稳 | 派生值 / useMemo(第 11 篇) |
| 9 | key 用 index | 删改排序后状态错乱 | 稳定唯一 id(第 7 篇) |
| 10 | Hook 命名不带 use | lint 失效、坑队友 | 一律 useXxx(第 13 篇) |
- 两条规则不是风格建议,而是机制约束:React 靠调用顺序绑定槽位,插队即错位。
- 十大陷阱的病根高度集中:引用相等 + 闭包快照 + 调用顺序,三件事想通了,坑就认得出来。
- 把
eslint-plugin-react-hooks开成报错级别——机器盯得住的规则,别靠肉眼。
从「背规则」到「凭直觉」
把十个坑摆在一起看,会发现它们不是十个独立知识点,而是同一个底层模型的十种发作方式。模型只有三句话,每句对上一批陷阱:
| 底层机制 | 一句话解释 | 发作成哪些陷阱 |
|---|---|---|
| 顺序即身份 | Hook 靠第几次调用来对号入座 | 陷阱 1(条件调用)、陷阱 10(命名) |
| 引用即相等 | 一切都经 Object.is 引用比较 | 陷阱 4(mutate)、陷阱 6(依赖撒谎)、陷阱 8(循环) |
| 渲染即快照 | 闭包抓的都是「那一帧」的值(第 10 篇) | 陷阱 2(函数式更新)、陷阱 3(旧值)、陷阱 5(派生值) |
以后遇到 Hook 疑难杂症,拿这三句话过一遍,八成能自己推出来;剩下两成撞上了,再回头翻第 5 篇(渲染模型)和第 37 篇(重渲染剖析)。
本章回顾
卷二到此收官。从第 9 篇的 useEffect 到第 15 篇的 useContext,加上本篇的避坑手册,官方内置 Hook 工具箱你已经过了一遍。工具会用了、坑也认得了,但还有个更根本的问题悬而未决——到底哪些东西才值得放进 state?
下一篇是卷三开篇「客户端状态 vs 服务端状态」。「弹窗开没开」和「数据库里有哪些用户」是两种性质完全不同的状态,混为一谈是绝大多数「状态管理难题」的病根。先分清,再谈方案——第 18 篇的 Zustand 和第 33 篇的 TanStack Query 正好各管一摊。
Comments · 评论