React 源码深潜(三):Hooks 的幻术——闭包快照与链表存储
在 React 16.8 之前,只有 Class 组件才能保存状态。Hooks 的出现让函数组件拥有了”记忆”,但如果你停下来想一想,会发现一个反直觉的事实:函数执行完毕后,所有局部变量都会被销毁——React 到底把状态藏在了哪里?
本篇从这个问题出发,完整拆解 Hooks 的两大核心原理。
1. 直击灵魂的拷问:状态去哪了?
先看这段最简单的代码:
1 | function Counter() { |
当我们点击按钮:
setCount(1)触发 React 更新Counter函数重新执行- 再次运行到
const [count, setCount] = useState(0)
关键问题: 按理说 count 应该被重置为初始值 0,为什么它变成了 1?
要回答这个问题,需要理解两个独立但紧密协作的机制:
- 闭包快照:每一次渲染都是独立的”帧”
- 链表存储:状态不在函数内部,而在 Fiber 节点上
2. 原理一:闭包快照(Closure Snapshot)
每一次渲染都是一张独立的”照片”
React 的函数组件通过闭包机制,让每一次渲染都拥有独立的 props 和 state 值。
你可以把 Counter 想象成一台拍立得相机:
- 每一次渲染(Render),就是按下一次快门
- Props 和 State,就是被定格在照片里的”景色”
当我们说 count 变了,不是同一个变量的值变了,而是 React 重新拍了一张照片,这张新照片里的 count 是 1。
用代码来理解:
1 | // 第一次渲染时,React 执行: |
每次渲染创建了一个新的函数作用域,闭包捕获了当次渲染的值。这就是为什么 React 文档说”每一次渲染都有它自己的 props 和 state”。
闭包陷阱:证据确凿
为了证明每次渲染确实是独立的”快照”,看这个经典的例子:
1 | function Counter() { |
实验步骤:
- 页面显示
Count: 0 - 点击”3 秒后弹窗”
- 立刻疯狂点击”增加”,让计数器涨到 10
- 3 秒后弹窗显示的是
Count is: 0,而不是10
原因:handleAlert 是在 count === 0 的那次渲染中被创建的。setTimeout 的回调通过闭包捕获了那次渲染中的 count 值。后续的渲染创建了新的 handleAlert 函数,但旧的闭包已经”定格”了。
结论:在 React 函数组件中,State 是常量,不是变量。每次渲染都有它自己独立的 State 值。
useRef:逃出闭包快照的”逃生通道”
如果确实需要在回调中读到”最新值”,React 提供了 useRef:
1 | function Counter() { |
useRef 返回的对象在整个组件生命周期里始终是同一个引用(不会随着渲染创建新对象),所以 .current 始终指向最新的值。
3. 原理二:链表存储(Linked List Storage)
闭包解释了”每次渲染看到的值为什么不同”,但还没回答最根本的问题:值本身存在哪?
状态挂在 Fiber 节点上
还记得上一篇学的 Fiber 节点吗?每个组件对应的 Fiber 都有一个 memoizedState 属性,Hooks 的数据就挂在这里。
但一个组件可能有多个 Hook(useState、useEffect、useMemo…),React 怎么区分它们?
答案是:依靠调用顺序。 React 内部用一个单向链表把它们串起来。
链表结构详解
每个 Hook 在内部对应一个 Hook 对象,大致结构如下:
1 | interface Hook { |
假如我们在组件里写了三个 Hook:
1 | function App() { |
React 解析后,Fiber 上的 memoizedState 是一个链表:
graph LR
F["fiber.memoizedState"] --> H1
H1["Hook 1<br/>memoizedState: 'Mary'<br/>next: Hook2"] --> H2["Hook 2<br/>memoizedState: effectObj<br/>next: Hook3"]
H2 --> H3["Hook 3<br/>memoizedState: 18<br/>next: null"]
首次渲染 vs 更新渲染
React 内部为 Hooks 维护了两套实现,通过一个全局的 ReactCurrentDispatcher 来切换:
1 | // React 内部(简化) |
首次渲染(Mount): 每个 useState 调用都会创建一个新的 Hook 对象,串入链表:
1 | function mountState(initialState) { |
更新渲染(Update): 每个 useState 调用不再创建 Hook,而是从已有链表中取下一个 Hook:
1 | function updateState(initialState) { |
关键函数:updateWorkInProgressHook
这个函数做的事情很简单但很关键——从链表上取出当前位置的 Hook,然后把指针移到下一个:
1 | let currentHook = null; // current 树上的当前 Hook |
这里就能看清楚 React 的策略了:按照 Hook 在组件中的调用顺序,逐个从旧链表取值,构建新链表——不靠变量名,纯靠顺序。
4. 为什么不能写在 if 里(Hook 规则的底层原因)
理解了”链表 + 按顺序取”的机制,就彻底明白了为什么官网强调:不要在循环、条件或嵌套函数中调用 Hook。
灾难现场
1 | function Form() { |
第一次渲染(所有 Hook 都执行了):
graph LR
A["Hook A 'Mary'<br/>顺序 1"] -->|next| B["Hook B 'Poppins'<br/>顺序 2"]
B -->|next| C["Hook C 500<br/>顺序 3"]
第二次渲染(条件不满足,Hook B 被跳过了):
graph TD
subgraph list["链表不变"]
direction LR
A["Hook A 'Mary'"] -->|next| B["Hook B 'Poppins'"] -->|next| C["Hook C 500"]
end
D["第 1 个 useState → 取链表第 1 个 → 'Mary' ✅"] --> E["第 2 个 useState → 取链表第 2 个 → 'Poppins' ❌"]
E --> G["width 期望拿到 500<br/>结果拿到了 'Poppins'"]
链表上的顺序是固定的,但代码中 Hook 的调用顺序变了。React 傻傻地按顺序取,取到的值和 Hook 对不上,整个状态系统就乱套了。
eslint-plugin-react-hooks
正是因为这个原因,React 官方提供了 ESLint 插件 eslint-plugin-react-hooks,它会在编译时检查:
- Hook 是否在函数组件或自定义 Hook 的顶层调用
- Hook 的调用是否可能被条件/循环影响
这不是”编码风格”层面的约束,而是数据结构层面的硬性要求。
5. useState 的更新队列:批量更新与优先级
当你调用 setCount(count + 1) 时,React 并不会立即重新渲染组件。它会创建一个 Update 对象并挂到 Hook 的 queue 上:
1 | function dispatchSetState(fiber, queue, action) { |
注意 queue 是一个环形链表——这样 queue.pending 始终指向最后一个 update,而 queue.pending.next 就是第一个 update,方便从头遍历。
当多个 setState 在同一个事件中被调用时,React 会把它们合并成一批(Automatic Batching,React 18 的特性):
1 | function handleClick() { |
在 React 18 之前,只有 React 事件处理函数内的 setState 才会被批量处理。React 18 的 createRoot 让所有场景(包括 setTimeout、Promise、原生事件回调)都默认启用批量更新。
6. useEffect 的挂载逻辑
useEffect 的 Hook 对象和 useState 共享同一条链表,但 memoizedState 里存的是一个 Effect 对象:
1 | function mountEffect(create, deps) { |
关于依赖数组的比较逻辑 areHookInputsEqual:
1 | function areHookInputsEqual(nextDeps, prevDeps) { |
这就是为什么依赖数组里放对象时,即使内容没变,如果引用变了(每次渲染创建了新对象),effect 还是会重新执行。
7. 阶段总结
Hooks 并不神奇。去掉语法糖后,它只是利用了两个基础的编程概念:
| 机制 | 解决的问题 | 实现方式 |
|---|---|---|
| 闭包快照 | 每次渲染看到独立的 state | JS 闭包捕获当次渲染的值 |
| 链表存储 | 函数执行完后状态不丢失 | 数据挂在 Fiber.memoizedState 上 |
| 环形更新队列 | 批量处理多个 setState | Update 对象形成环形链表 |
| 两套 Dispatcher | 区分首次渲染和更新渲染 | mount 创建链表,update 按序取值 |
正是因为底层”按顺序存储”的链表实现,才有了”不能在条件语句里写 Hook”这条铁律。这不是 React 团队的洁癖,而是数据结构决定的。
下一篇是这个系列的最后一块拼图——并发模式。我们要搞清楚 React 18 的 useTransition 和优先级调度是如何在底层实现”高优先级打断低优先级”的。
Happy Coding!
