模块二 · Hooks 与数据
Hooks 为什么必须按顺序调用
Hooks 不是魔法,而是挂在 Fiber 上的链表。这一节讲清调用规则、依赖比较、闭包陷阱,以及自定义 Hook 的边界。
读完这一节,你能做到
- 说清 Hooks 存储在哪,以及为什么依赖调用顺序。
- 解释"条件调用 Hooks"为什么会错位。
- 判断依赖数组该写什么,避免无限循环与漏更新。
- 用 ref 或函数式更新解决闭包读到旧值的问题。
一、Hooks 存在哪
每个组件实例(Fiber)上有一条 Hooks 链表:
fiber.memoizedState → hook1 → hook2 → hook3 → ...
每次渲染时,React 按顺序遍历这条链表,把第 n 次 useState 对应到第 n 个节点。顺序就是身份,没有名字。
所以 Hooks 的规则不是风格要求,而是数据结构决定的:顺序变了,状态就串了。
二、为什么不能条件调用
if (visible) {
const [name, setName] = useState(""); // 错误
}
第一次渲染 visible=true,链表是 [useState, useEffect];第二次 visible=false,链表变成 [useEffect]。React 会把 useEffect 当成第一个 Hook,读到的是 useState 的状态,直接错位。
正确做法:Hooks 永远在顶层调用,条件放到 Hook 内部:
const [name, setName] = useState("");
if (!visible) return null;
三、依赖数组怎么比较
React 用 Object.is 逐项比较依赖:
| 写法 | 结果 | 说明 |
|---|---|---|
[] | 只执行一次 | 没有依赖 |
[a, b] | a 或 b 变化时执行 | 基本类型安全 |
[obj] | 每次渲染都执行 | 对象字面量每次都是新引用 |
[obj.id] | id 变化时执行 | 用稳定值做依赖 |
三条实践:
- 依赖里只放"用到的、会变的"值,不要放整个对象。
- 函数依赖用
useCallback包,或用函数式更新避免依赖。 - 不要为了"只执行一次"而故意漏依赖,用 ref 保存最新值更安全。
四、闭包陷阱
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, []);
count 永远是最初的 0,因为 effect 只执行一次,闭包捕获了那次渲染的 count。两种修法:
- 把
count放进依赖,每次重建定时器。 - 用函数式更新
setCount(c => c + 1),不依赖外部值。
五、自定义 Hook 的边界
自定义 Hook 本质是"把一组 Hooks 抽成函数",它必须:
- 以
use开头,让 lint 能检查规则。 - 只在顶层调用其他 Hooks。
- 返回稳定的接口,不暴露内部实现。
function useDebounced(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(t);
}, [value, delay]);
return debounced;
}
注意:自定义 Hook 之间不共享状态,每次调用都是独立的一份。
六、常见坑
- 在条件、循环、回调里调用 Hooks。
- 依赖数组放了对象/函数字面量,导致每次渲染都执行。
- 漏依赖导致读到旧值,还以为是 React 的 bug。
- 用
useEffect做派生计算,其实应该用useMemo。 - 自定义 Hook 里返回了新对象,导致调用方每次重渲染。
七、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 故意在
if里调用useState,观察报错信息。 - 写一个
useDebounced,并用它做搜索输入。 - 把一个漏依赖的 effect 改成函数式更新,验证不再读旧值。
② 让 AI 写同一题
把第 2 题(useDebounced)交给 AI,补一句 React 19 + TS,返回的函数必须引用稳定。
③ 对比要盯的点
- 定时器它在
return里clearTimeout了吗?依赖一变,旧定时器不清就会连发多次。 - 依赖数组里放了每次渲染都新建的回调吗?那防抖永远不会生效。
- 返回值它用
useCallback包了吗?没包的话,调用方一放进依赖就会连环重渲染。
④ 一句话取舍
它的防抖在“父组件每秒重渲染一次”时还生效吗?说清为什么,而不是“应该可以”。
八、自测
- Hooks 存储在哪?
- 为什么条件调用会错位?
Object.is比较对依赖数组意味着什么?- 闭包读到旧值的两种修法是什么?
- 自定义 Hook 之间共享状态吗?
参考答案要点:Fiber 链表;顺序即身份;对象引用比较;依赖补全或函数式更新;不共享。
附:Hooks 链表长什么样
fiber.memoizedState
→ { memoizedState: 0, next: hook2 } // useState
→ { memoizedState: fn, next: hook3 } // useEffect
→ { memoizedState: null, next: null } // useRef
每次渲染按 next 依次读取,调用顺序变了,读到的节点就变了。这就是规则的来源。
附:依赖数组的四个反例
| 写法 | 问题 | 修法 |
|---|---|---|
[user] | 对象引用每次新 | [user.id] |
[] 但用了 props | 永远旧值 | 补依赖或 ref |
[fn] | 函数每次新 | useCallback / 函数式更新 |
[a.b.c] | 中间层可能为空 | 可选链 + 默认值 |
附:从类组件到 Hooks 的迁移
类组件的 componentDidMount 对应 useEffect(..., []),componentDidUpdate 对应带依赖的 effect,componentWillUnmount 对应 cleanup。区别是:Hooks 把“按生命周期组织”改成“按逻辑组织”,同一个功能的代码放在一起。
迁移时不要机械替换:先按职责拆逻辑,再决定哪些状态需要保留。类组件里常见的 this.setState 合并更新,在 Hooks 里要用展开运算符显式合并。
附:useState 的最小实现
let hookIndex = 0;
const hooks = [];
function useState(initial) {
const i = hookIndex++;
hooks[i] = hooks[i] ?? initial;
const setState = (next) => {
hooks[i] = typeof next === "function" ? next(hooks[i]) : next;
render();
};
return [hooks[i], setState];
}
真实实现挂在 Fiber 上,并且有调度与批处理;但“按调用顺序取槽位”这一点,就是上面这几行。
附:Hooks 的常见报错
| 报错 | 原因 | 修法 |
|---|---|---|
| Rendered more hooks | 条件分支多调了 Hook | 提到顶层 |
| Rendered fewer hooks | 提前 return | 把 return 放到 Hooks 之后 |
| Cannot update during render | render 里 setState | 移到 effect/事件 |
| Missing dependency | 依赖漏写 | 补依赖或改函数式 |
看到这几类报错,先查调用顺序与依赖,不要先怀疑 React。
附:useRef 与闭包
useRef 返回的对象在多次渲染间保持同一引用,.current 可读写且不会触发渲染。两个典型用途:
- 保存定时器 id、上一次的值、是否已挂载。
- 在回调里读取最新 state,绕过闭包快照。
但它不是“响应式”的:改 .current 不会让组件更新,不要拿它存需要展示的数据。
附:Hooks 与类组件的差异
| 维度 | 类组件 | Hooks |
|---|---|---|
| 逻辑复用 | HOC/render props | 自定义 Hook |
| 状态组织 | 按生命周期 | 按逻辑 |
| this | 需要绑定 | 不需要 |
| 闭包 | 实例属性 | 每次渲染独立 |
Hooks 的代价是“顺序规则”和“闭包快照”,换来的是更简单的逻辑复用与更少的嵌套。规则不是限制,而是数据结构的外显;理解链表,就不会再问为什么。每次渲染都是一次独立的函数调用,Hooks 只是把状态挂在了 Fiber 上。顺序对了,状态才对;依赖写全,副作用才可预测。顺序即契约,规则即设计;理解即自由,先记规则再记原因。
附录:高频追问
问:Hooks 为什么不能放在循环里? 循环次数变化会改变调用顺序,状态错位。
问:依赖数组为空等于 componentDidMount 吗? 不完全等价;StrictMode 下会执行两次,且拿不到最新 props。
问:useMemo 和 useCallback 的关系? 前者缓存值,后者缓存函数;都是性能提示,不是语义保证。
问:为什么 effect 里读到旧 state? 闭包捕获了那次渲染的快照。
问:useRef 能替代 state 吗? 不能,ref 变化不触发渲染。
问:自定义 Hook 会共享状态吗? 不会,每次调用都是独立的一份。
问:Hooks 能在普通函数里调用吗? 不能,必须在组件或其他 Hook 的顶层。
问:依赖数组里放函数会怎样? 函数每次新建,effect 每次都执行;用 useCallback 或函数式更新。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
提示:读完可以顺手做几道对应题目,检验是否真的理解