文章

Hooks使用规则深入

Hooks使用规则深入

一句话概括

React Hooks 两条黄金规则——”只在顶层调用”和”只在 React 函数中调用”——不是拍脑袋的编码建议,而是 memoizedState 链表实现导致的必然约束:任何违规都会让状态指针错位,后续所有 Hook 全乱。

核心知识点

1. 规则一:顶层调用——根源在链表

每次渲染时 updateWorkInProgressHook 做一件事:currentHook = currentHook.next,往下走一个节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// React 源码简化版
function updateWorkInProgressHook() {
  // 第一个 Hook:取链表头
  if (currentHook === null) {
    nextCurrentHook = currentFiber.alternate.memoizedState;
  } else {
    nextCurrentHook = currentHook.next; // 继续往下走
  }
  if (nextCurrentHook === null) {
    throw new Error('Rendered more hooks than during the previous render.');
  }
  currentHook = nextCurrentHook;
  // ... 克隆到 workInProgress
}

你少调一次 Hook,游标就少走一步,后续全部读到错误的节点。多调一次,走到 null 直接抛错。

2. 闭包陷阱:useEffect 捕获的是快照

1
2
3
4
5
6
7
8
9
10
function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      console.log(count); // 永远打印 0!
    }, 1000);
    return () => clearInterval(id);
  }, []); // 空依赖 → effect 只创建一次 → 闭包永远捕获 count=0
}

这和 Hook 的实现没关系,是 JS 闭包的基本行为。三个解法:

1
2
3
4
5
6
7
8
9
// 方案一:函数式更新(推荐)
setCount(prev => prev + 1);  // 不依赖外部 count

// 方案二:加入依赖(每次重建定时器)
useEffect(() => { /*...*/ }, [count]);

// 方案三:useRef 绕过闭包
const countRef = useRef(count); countRef.current = count;
useEffect(() => { /* 用 countRef.current */ }, []);

3. 循环调用 Hooks:问题在动态数量

1
2
3
4
// ❌ 每次渲染 items 可能变化 → Hook 数量动态
items.forEach(item => {
  const [v] = useState(item);
});

解法:把动态数据放进一个 useState 的复合结构里。

1
2
// ✅ 一个 useState 管理全部
const [values, setValues] = useState(() => items.map(() => ''));

4. eslint-plugin-react-hooks 能做什么、不能做什么

能检测:AST 层面判断 Hook 是否被 if/for/while/switch 包裹、是否在嵌套函数里。

不能检测:

  • 变量间接调用:const hooks = [useState]; hooks[0](0);
  • 动态分发:fn[cond ? 'useA' : 'useB']();
  • evel / new Function 里的 Hook 调用

所以 ESLint 只是第一道防线,真正的保障是你理解链表。

5. StrictMode 双调用是特性不是 bug

React 18 StrictMode 在开发环境下会让组件函数体、useState 初始化器、useMemo 回调都执行两次,useEffect 经历 mount→cleanup→remount。

目的:提前检测不纯的副作用——如果你在 effect 里忘记清理订阅/定时器,双调用立刻暴露问题。

「其实你每天都在用」

  1. useEffect 忘记加依赖:编辑器里的 eslint 黄线提醒 missing dependency: count,背后是 exhaustive-deps 规则对比 AST 中的自由变量。

  2. setState(prev => prev + 1) 不会闭包陈旧值:因为函数式更新里 prev 是从 queue.pending 链中即时计算的,不依赖闭包。

  3. 自定义 Hook 的 use 前缀:不是 React 强制要求,但 eslint-plugin-react-hooks 通过 use[A-Z] 这个模式识别”这是一个 Hook 函数”,从而递归检查其内部的规则遵守情况。

  4. React DevTools 里看到 Hook 名称:DevTools 遍历 fiber.memoizedState 链表,根据每个节点的结构(有无 queue、有无 effect tag)推断类型并展示。

  5. useRef 的值变了不触发重渲染:因为 useRef 节点上的 memoizedState{ current: value },修改 ref.current 是直接改对象属性,不走 React 的 queue.pending 更新机制。

常见误解 (FAQ)

❌ 误区 1:”规则只对 useStateuseEffect 有效”

所有 Hooks——useRefuseMemouseCallbackuseReduceruseLayoutEffect——都在同一条链表上,规则一样适用。useContext 也不例外,只是它的节点结构略有不同。

❌ 误区 2:”提前 return 后面的 Hooks 不会执行,所以安全”

提前 return 意味着后续 Hook 根本没被调用,链表索引停留,下一次渲染整个组件的 Hook 调用数量就变了。这是最常见的隐蔽 bug。

❌ 误区 3:”自定义 Hook 写成普通函数就绕过规则了”

不写 use 前缀确实能骗过 ESLint,但 Hook 内部的状态仍挂在组件的 Fiber 上。如果自定义 Hook 在不同条件下被调用,同样会导致链表错位。

一句话总结

Hooks 规则不是”React 的约束”,而是”JavaScript 闭包 + 单向链表”下的数学必然——打破顺序就打破一切。

本文由作者按照 CC BY 4.0 进行授权