一句话定义
两条硬规则(只在顶层调用、只在 React 函数里调用)来自 Hooks 的实现机制(按调用顺序索引状态);而 effect 里的「旧值」来自 JavaScript 的闭包快照——理解这两件事,「规则」就从背诵变成了推理。
为什么重要
Hooks 没有任何运行时保护:条件调用 Hook 会让状态索引错位、整个组件崩溃;闭包过期值是异步代码里最难定位的 bug 类别。这一篇是把「为什么」讲透,让你在 review 时能一眼看出违规代码。
前置知识
核心概念
- 规则 1:只在顶层调用——不能放进 if/循环/嵌套函数/return 之后。
- 规则 2:只在 React 函数里调用——组件或自定义 Hook 顶层;普通 JS 函数不行。
- Hooks 链表:React 按「调用顺序」把每个 Hook 的状态记在组件的 Fiber 节点上;
useState(0)第一次是第 0 格,第二次是第 1 格……顺序变了格子就错位。 - 闭包快照:本次渲染创建的函数(事件处理器、effect)捕获的是本次渲染的 props/state。
- stale closure:异步回调里读到的闭包变量是「创建那一刻」的值,不是最新值。
原理 / 机制
规则 1 的机制演示——为什么条件调用会炸:
jsx// 假设 React 内部按顺序记格子:
// 第 1 次渲染: [count=0] [name=''] [effect]
function Form({ withName }) {
const [count, setCount] = useState(0); // 格子 0
if (withName) {
const [name, setName] = useState(''); // ❌ 有时占格子 1,有时不占
}
useEffect(() => {}, []); // 格子身份取决于 withName!
return <input value={count} onChange={e => setCount(+e.target.value)} />;
}
withName 翻转后,effect 的格子身份错位,React 抛出「Rendered fewer hooks than expected」。这就是 lint 规则 react-hooks/rules-of-hooks 存在的原因——它在编译期拦截所有这种写法。
stale closure 的经典案例:
jsxfunction Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // ❌ count 永远是 effect 创建那次的 0
}, 1000);
return () => clearInterval(id);
}, []); // 空依赖 = 永不重跑 = 闭包永不更新
// 修正一:函数式更新,不引用外部 count
// setCount(c => c + 1);
// 修正二:把 count 放进依赖数组(每秒重建 interval,也能跑但不优雅)
}
依赖数组「为什么必须诚实」:effect 函数捕获了本次渲染的 count;若依赖里不写 count,React 就「以为」无需同步,旧闭包持续生效。lint 的 exhaustive-deps 规则做的就是「扫描 effect 体里引用了哪些外部值,提示你补全」。
跨渲染保留可变量的正确工具是 ref(kp-017):countRef.current 读到的永远是最新值,因为它不参与快照。
直观类比
- Hooks 链表像火车站储物柜:你的钥匙按「第几个柜子」分发(第 1 个 Hook 开 1 号柜)。如果某天你跳过了一个柜子(条件调用),后面所有柜子的钥匙都会对错门。所以规矩是:无论今天带不带行李,柜子顺序必须每天一样(需要条件逻辑就在 Hook 内部处理)。
- 闭包快照像发布会录像:演讲者(渲染)说到「count 是 3」时录像定格;三小时后回放(异步回调),听到的还是那句「3」——不是「现在的 count」。想听最新版,要么每分钟重录(依赖数组重跑),要么直接看直播(ref.current)。
实例 / 案例
一次性事件订阅中防 stale 的完整模式(ref 桥接):
jsxfunction useRafLoop(callback) {
const saved = useRef(callback);
useEffect(() => { saved.current = callback; }); // 每次渲染更新引用
useEffect(() => {
let running = true;
function tick() {
if (!running) return;
saved.current(); // 调用的永远是最新 callback
requestAnimationFrame(tick);
}
const id = requestAnimationFrame(tick);
return () => { running = false; cancelAnimationFrame(id); };
}, []); // 只启动一次循环,但逻辑始终新鲜
}
这是「定时器/循环 + 最新回调」的通用配方:空依赖保循环、ref 保新鲜。
常见误区
- 在回调里调用 Hook:
onClick={() => useState(0)}、setTimeout(() => useEffect(...))全部违规;Hook 只属于「渲染这次」。 - 用条件包裹 Hook 而不是在 Hook 内部条件:
if (ready) useEffect(...)❌ →useEffect(() => { if (ready) {...} })✅。 - 「补依赖 = 加 bug」:补依赖是让 effect 与数据同步一致;真正的修复通常是「把这段逻辑移出 effect」(kp-010)或用函数式更新。
- 在循环里调用 Hook:
items.map(x => useSomething(x))❌;列表数量变化即错位。需要复用状态就拆子组件。 - 以为闭包陷阱只影响 effect:
setTimeout、Promise 回调、事件监听器、memo 化的回调同样捕获快照。
自测题
- 用「格子/储物柜」机制解释为什么 Hook 不能放在 if 里;lint 规则在什么层面拦截?
- 重现 Timer 案例:空依赖 +
setCount(count+1)为什么只加到 1?两种修正各自的代价? - 「effect 依赖数组不是可选的优化开关」——这句话在闭包模型下如何解释?
onClick={() => setCount(count + 1)}里连点三次会加几次?这算不算 stale closure bug?- 写出 useRafLoop 模式里两个 effect 各自的职责。
与其他知识点的关系
- 向前:把 kp-005 快照与 kp-010 依赖数组的「为什么」补齐。
- 向后:kp-015 自定义 Hook 继承全部规则;kp-017 的 ref 是「逃出快照」的正道;kp-018 的 useCallback 解决「闭包引用导致 memo 失效」的联动问题;kp-022 中并发渲染让「渲染可重放」更依赖纯函数纪律。
延伸阅读
- Rules of Hooks:<https://react.dev/reference/rules/rules-of-hooks>
- State as a Snapshot:<https://react.dev/learn/state-as-a-snapshot>
- eslint-plugin-react-hooks:<https://www.npmjs.com/package/eslint-plugin-react-hooks>