React 学习站
React›核心›核心

Hooks 规则与闭包陷阱(快照模型)

核心核心

一句话定义

两条硬规则(只在顶层调用、只在 React 函数里调用)来自 Hooks 的实现机制(按调用顺序索引状态);而 effect 里的「旧值」来自 JavaScript 的闭包快照——理解这两件事,「规则」就从背诵变成了推理。

为什么重要

Hooks 没有任何运行时保护:条件调用 Hook 会让状态索引错位、整个组件崩溃;闭包过期值是异步代码里最难定位的 bug 类别。这一篇是把「为什么」讲透,让你在 review 时能一眼看出违规代码。

前置知识

  • kp-005 快照模型。
  • kp-010 effect 依赖数组。
  • JavaScript 闭包基础。

核心概念

  • 规则 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 化的回调同样捕获快照。

自测题

  1. 用「格子/储物柜」机制解释为什么 Hook 不能放在 if 里;lint 规则在什么层面拦截?
  2. 重现 Timer 案例:空依赖 + setCount(count+1) 为什么只加到 1?两种修正各自的代价?
  3. 「effect 依赖数组不是可选的优化开关」——这句话在闭包模型下如何解释?
  4. onClick={() => setCount(count + 1)} 里连点三次会加几次?这算不算 stale closure bug?
  5. 写出 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>