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 里忘记清理订阅/定时器,双调用立刻暴露问题。
「其实你每天都在用」
useEffect 忘记加依赖:编辑器里的 eslint 黄线提醒
missing dependency: count,背后是exhaustive-deps规则对比 AST 中的自由变量。setState(prev => prev + 1)不会闭包陈旧值:因为函数式更新里prev是从queue.pending链中即时计算的,不依赖闭包。自定义 Hook 的
use前缀:不是 React 强制要求,但 eslint-plugin-react-hooks 通过use[A-Z]这个模式识别”这是一个 Hook 函数”,从而递归检查其内部的规则遵守情况。React DevTools 里看到 Hook 名称:DevTools 遍历
fiber.memoizedState链表,根据每个节点的结构(有无 queue、有无 effect tag)推断类型并展示。useRef的值变了不触发重渲染:因为useRef节点上的memoizedState是{ current: value },修改ref.current是直接改对象属性,不走 React 的queue.pending更新机制。
常见误解 (FAQ)
❌ 误区 1:”规则只对 useState 和 useEffect 有效”
所有 Hooks——useRef、useMemo、useCallback、useReducer、useLayoutEffect——都在同一条链表上,规则一样适用。useContext 也不例外,只是它的节点结构略有不同。
❌ 误区 2:”提前 return 后面的 Hooks 不会执行,所以安全”
提前 return 意味着后续 Hook 根本没被调用,链表索引停留,下一次渲染整个组件的 Hook 调用数量就变了。这是最常见的隐蔽 bug。
❌ 误区 3:”自定义 Hook 写成普通函数就绕过规则了”
不写 use 前缀确实能骗过 ESLint,但 Hook 内部的状态仍挂在组件的 Fiber 上。如果自定义 Hook 在不同条件下被调用,同样会导致链表错位。
一句话总结
Hooks 规则不是”React 的约束”,而是”JavaScript 闭包 + 单向链表”下的数学必然——打破顺序就打破一切。