文章

Hooks链表与调用顺序

Hooks链表与调用顺序

一句话概括

React Hooks 的底层不是魔法,而是一条挂在 fiber.memoizedState 上的单向链表——每个 Hook 通过 next 指针串联,渲染时按调用顺序遍历链表读取状态。一旦条件执行破坏顺序,链表索引错位,所有状态全崩。

核心知识点

1. Hook 链表长什么样

每个组件 fiber 上的 memoizedState 不是存 state 值,而是指向一条 Hook 单链表:

1
2
3
4
// fiber.memoizedState 的结构
const hook1 = { memoizedState: 0,    next: hook2 };  // useState(0)
const hook2 = { memoizedState: 'hi', next: hook3 };  // useState('hi')
const hook3 = { memoizedState: {...effect...}, next: null }; // useEffect

关键:React 不按”名称”找 Hook,而是按”第几个调用的”。第 3 次调用就是链表的第 3 个节点。

2. mount 创建 vs update 读取——两套分发器

React 通过一个全局 dispatcher 做 mount/update 分支:

1
2
3
4
5
6
7
8
9
10
11
12
13
// mount 时:创建节点挂到链表尾部
function mountState(initial) {
  const hook = mountWorkInProgressHook(); // 链表尾部追加
  hook.memoizedState = initial;
  return [hook.memoizedState, dispatch];
}

// update 时:按顺序取下一个节点
function updateState() {
  const hook = updateWorkInProgressHook(); // 推进游标
  // 处理 queue 中的更新,返回最新 memoizedState
  return [hook.memoizedState, hook.queue.dispatch];
}

mount 追加,update 推进游标——同一个 useState 调用,不同阶段做不同的事。

3. 为什么条件调用是灾难

1
2
3
4
5
6
7
function Bug({ flag }) {
  const [a] = useState(0);       // 链表节点 0
  if (flag) {
    const [b] = useState('x');   // 链表节点 1(flag=true 时存在)
  }
  const [c] = useState(2);       // 链表节点 2(flag=true)/ 节点 1(flag=false)
}
渲染调用序列实际读到的
flag=trueuseState(0)→useState(‘x’)→useState(2)✅ a→b→c
flag=falseuseState(0)→(跳过)→useState(2)❌ a→c→?错位

第二次渲染 useState(2) 读到了原来 useState('x') 的节点,返回字符串而非数字。更致命的是 useEffect 会跑错位置。

4. 自定义 Hook 不享有”豁免权”

1
2
3
4
5
6
7
8
9
10
11
function useWindowSize() {
  const [size] = useState({});  // 直接挂到调用方组件的链表上
  useEffect(() => { /*...*/ }, []);
  return size;
}

function App() {
  const [a] = useState(0);      // 链表节点 0
  const size = useWindowSize(); // 内部展开了 useState + useEffect → 节点 1, 2
  const [b] = useState(1);      // 链表节点 3
}

自定义 Hook 内的 Hooks 被内联展开到调用方链表上,同样要遵守顺序规则。

5. setState 函数为什么引用稳定

1
2
3
4
5
6
// mount 时 queue.dispatch 指向同一个函数 → 引用永不变
const queue = { pending: null, dispatch: null };
queue.dispatch = dispatchSetState.bind(null, fiber, queue);
hook.queue = queue;

// update 时直接从 hook.queue 取出 dispatch → 同一个引用

这就是为什么 setState 不需要放在 useEffect 依赖数组中——它是引用稳定的。

「其实你每天都在用」

  1. 输入框即时搜索:每次按键 setSearchQuery,React 按顺序找到链表中的对应节点,返回最新值。背后是链表游标在步进。

  2. 表单多字段管理const [name, setName] = useState(''); const [email, setEmail] = useState(''); 每个字段一个 Hook,链表里是相邻两个节点,写入互不干扰。

  3. 第三方 Hook 库useSWR('/api/data')useDebounce(value) 这些库内部的 useState/useEffect 被”内联”到你的组件链表里,和你的 Hooks 共享同一条链。

  4. React DevTools 看到的状态列表:DevTools 遍历 fiber.memoizedState → next → next 把链表读出来展示。你在面板里看到的 Hooks 顺序就是链表的物理顺序。

  5. ESLint 报错 Hooks must be called in the exact same order:就是 eslint-plugin-react-hooks 用 AST 分析检测到你的 Hook 调用了 if/for 包裹,链表顺序将被破坏。

常见误解 (FAQ)

❌ 误区 1:”多个同名 useState 靠变量名区分”

React 不看变量名 count 还是 name,只看调用位置(第一次、第二次……)。你把两行 useState 交换位置,所有状态全乱。

❌ 误区 2:”useCallback/useMemo 不用遵守顺序规则”

所有 Hooks(包括 useRefuseMemouseCallbackuseReducer)都挂在同一条链表上,都靠调用顺序定位。没有例外。

❌ 误区 3:”如果条件分支的 Hook 数量相同就安全”

即使数量相同,不同渲染中调用的具体 Hook 不同(比如 flag ? useState(0) : useReducer(fn, 0)),依然会错位——React 从链表拿到的节点和预期类型不匹配。

❌ 误区 4:”StrictMode 下 Hooks 执行两次是 bug”

这是 React 18 的故意行为,用于检测副作用是否纯。组件函数体和 useState 的初始化函数都会被双调用,方便暴露不纯的逻辑。

一句话总结

Hooks 的本质是一条隐式的单链表,调用顺序就是它的”身份证”——理解了这条链,你就理解了一切 Hooks 规则的根源。

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