首页 / React 学习笔记 / 16

REACT · Vol.II · LESSON 16 · Hooks 精讲

Hooks 规则与十大常见陷阱

进阶核心#Hooks#规范

两条规则,与它们背后的「槽位」

卷二从第 9 篇到第 15 篇,把常用 Hook 挨个讲了一遍。用法之外,官方给 Hooks 立的规矩只有两条:

  1. 只在最顶层调用——不要在条件判断、循环、嵌套函数里调用 Hook;
  2. 只在 React 函数组件或自定义 Hook 里调用——普通 JS 函数、类组件、事件处理器都不行。

先看反面教材,这段代码看起来无害,实则直接把组件搞坏:

❌ Chat.tsx · 在条件里调 Hook
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 个槽位;随后某次渲染 userIdnull,第 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) 是唯一允许在条件里调用的例外。)

后端类比:MyBatis / JDBC 的 ? 占位符

参数绑定靠的是位置WHERE id = ? AND status = ?setString(1, ...)setString(2, ...)。如果动态拼 SQL 时少拼了一个条件,后面的参数全部错位绑定——字符串绑到了日期列上。React 的 Hook 与槽位就是这组 ?第 n 次调用对第 n 个槽位,插队或缺席,后面全错。为什么不能按名字绑定?因为 useState 可能被调用多次,同一个「名字」出现多次时,顺序是唯一能依赖的标识。

规则二深挖:为什么事件处理器里也不能调 Hook

规则二还有个高频误区:「条件调用只是会错位,那我保证顺序不变,写在别处总行吧?」不行——事件处理器、普通工具函数里一律不能调,这与顺序无关。槽位只在「渲染正在进行」时开放登记:React 渲染某个组件时才打开它的槽位链表,渲染一结束就封存。事件处理器要等用户点击后才运行,那时链表早已封存,里面的 useState 无处登记:

❌/✅ LikeButton.tsx · 状态要在渲染期「预报」,事件里只许改值
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>;
}
后端类比:Bean 注册只在容器启动期

Spring 里你往容器里注册 Bean,只能发生在启动阶段;请求处理到一半想再塞一个新 Bean 定义,框架不认。运行期要用的资源,必须在装配阶段提前声明——React 的规矩一模一样:状态在渲染期预报(顶层声明),运行期只许读写

State 类陷阱:函数式更新、闭包旧值与「假更新」

规则理解了,接下来是实战盘点——后端转型最容易踩的十个坑,按 state 类、effect 类、列表与命名类分三组过。

陷阱 2:函数式更新遗漏(呼应第 4 篇)

连续多次 setState、或在定时器与异步回调里 setState,直接写「值」就会踩到闭包快照:

❌/✅ Counter.tsx · 基于「最新值」更新,一律用函数式写法
setCount(count + 1);   // ❌ 两次都读到本次渲染的 count = 0
setCount(count + 1);   //    结果只 +1,不是 +2

setCount(c => c + 1);  // ✅ 更新函数排队依次执行
setCount(c => c + 1);  //    永远基于「最新值」,结果 +2

陷阱 3:闭包旧值(呼应第 10 篇)

effect 依赖写成 [],闭包就把首次渲染的 state 永久锁死在里面:

❌/✅ Timer.tsx · 定时器里的 count 永远是 0
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 判定「没变化」,跳过渲染——「改了却不刷新」的头号原因:

❌/✅ Profile.tsx · 不可变更新
// ❌ 引用没变 → React 认为 state 没变 → 界面不动
user.age = 26;
setUser(user);
todos.push(newTodo);
setTodos(todos);

// ✅ 造新对象 / 新数组,引用变了才触发渲染
setUser({ ...user, age: 26 });
setTodos([...todos, newTodo]);

陷阱 5:把「能算出来的」塞进 state

后端思维里「先把数据算好存起来」很自然,但 React 里,渲染期间能算出来的值就不该占一个 state 槽位——存了就要回答「两份数据什么时候同步」,凭空多出一个 bug 面:

❌/✅ Cart.tsx · 派生值直接在渲染时算
// ❌ 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 就按你撒的谎执行:

❌/✅ Connection.tsx · 少依赖 = 用旧值
// ❌ 撒谎: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 清理遗漏 → 订阅泄漏

❌/✅ useWindowWidth.ts · 有订阅就要有退订
// ❌ 只订阅、不退订:组件卸载后监听器还挂在 window 上
//    轻则内存泄漏,重则对已卸载组件 setState 报错
useEffect(() => {
  window.addEventListener("resize", onResize);
}, []);

// ✅ 成对出现:订阅/退订、连接/断开、起定时器/清定时器
useEffect(() => {
  window.addEventListener("resize", onResize);
  return () => window.removeEventListener("resize", onResize);
}, []);

后端视角太熟悉了:连接不还池、线程不 shutdown、@PreDestroy 忘了清理——同一类资源管理失职。把 effect 的 return 当成 try-finallyfinally 来写就行。

陷阱 8:无限循环(两种形态)

❌/✅ 无限循环.tsx · 渲染期 setState / 依赖引用不稳
// 形态一:渲染期间 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」仍当作原来那条数据,组件内部状态跟着错位

❌/✅ TodoList.tsx · key 要用稳定唯一的 id
// ❌ 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 狂跑只声明真实用到的值
7effect 清理遗漏泄漏、重复订阅、报错return 清理函数
8无限循环渲染期 setState / 依赖引用不稳派生值 / useMemo(第 11 篇)
9key 用 index删改排序后状态错乱稳定唯一 id(第 7 篇)
10Hook 命名不带 uselint 失效、坑队友一律 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 · 评论