在 React 16.8 之前,只有 Class 组件才能保存状态。Hooks 的出现让函数组件拥有了”记忆”,但如果你停下来想一想,会发现一个反直觉的事实:函数执行完毕后,所有局部变量都会被销毁——React 到底把状态藏在了哪里?
本篇从这个问题出发,完整拆解 Hooks 的两大核心原理。
1. 直击灵魂的拷问:状态去哪了?
先看这段最简单的代码:
1 2 3 4 5 6 7
| function Counter() { const [count, setCount] = useState(0);
console.log('Render:', count);
return <button onClick={() => setCount(count + 1)}>{count}</button>; }
|
当我们点击按钮:
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 2 3 4 5 6 7 8 9 10 11 12 13 14
| function Counter() { const count = 0; return <button onClick={() => setCount(0 + 1)}>{0}</button>; }
function Counter() { const count = 1; return <button onClick={() => setCount(1 + 1)}>{1}</button>; }
|
每次渲染创建了一个新的函数作用域,闭包捕获了当次渲染的值。这就是为什么 React 文档说”每一次渲染都有它自己的 props 和 state”。
闭包陷阱:证据确凿
为了证明每次渲染确实是独立的”快照”,看这个经典的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| function Counter() { const [count, setCount] = useState(0);
const handleAlert = () => { setTimeout(() => { alert('Count is: ' + count); }, 3000); };
return ( <div> <p>Count: {count}</p> <button onClick={() => setCount(count + 1)}>增加</button> <button onClick={handleAlert}>3 秒后弹窗</button> </div> ); }
|
实验步骤:
- 页面显示
Count: 0
- 点击”3 秒后弹窗”
- 立刻疯狂点击”增加”,让计数器涨到 10
- 3 秒后弹窗显示的是
Count is: 0,而不是 10
原因:handleAlert 是在 count === 0 的那次渲染中被创建的。setTimeout 的回调通过闭包捕获了那次渲染中的 count 值。后续的渲染创建了新的 handleAlert 函数,但旧的闭包已经”定格”了。
结论:在 React 函数组件中,State 是常量,不是变量。每次渲染都有它自己独立的 State 值。
useRef:逃出闭包快照的”逃生通道”
如果确实需要在回调中读到”最新值”,React 提供了 useRef:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| function Counter() { const [count, setCount] = useState(0); const countRef = useRef(count);
useEffect(() => { countRef.current = count; });
const handleAlert = () => { setTimeout(() => { alert('Latest count is: ' + countRef.current); }, 3000); };
}
|
useRef 返回的对象在整个组件生命周期里始终是同一个引用(不会随着渲染创建新对象),所以 .current 始终指向最新的值。
3. 原理二:链表存储(Linked List Storage)
闭包解释了”每次渲染看到的值为什么不同”,但还没回答最根本的问题:值本身存在哪?
状态挂在 Fiber 节点上
还记得上一篇学的 Fiber 节点吗?每个组件对应的 Fiber 都有一个 memoizedState 属性,Hooks 的数据就挂在这里。
但一个组件可能有多个 Hook(useState、useEffect、useMemo…),React 怎么区分它们?
答案是:依靠调用顺序。 React 内部用一个单向链表把它们串起来。
链表结构详解
每个 Hook 在内部对应一个 Hook 对象,大致结构如下:
1 2 3 4 5 6 7
| interface Hook { memoizedState: any; baseState: any; baseQueue: Update | null; queue: UpdateQueue | null; next: Hook | null; }
|
假如我们在组件里写了三个 Hook:
1 2 3 4 5
| function App() { const [name, setName] = useState('Mary'); useEffect(() => { }); const [age, setAge] = useState(18); }
|
React 解析后,Fiber 上的 memoizedState 是一个链表:
1 2 3 4 5 6 7 8
| fiber.memoizedState ↓ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ Hook 1 │ │ Hook 2 │ │ Hook 3 │ │ memoizedState: │ │ memoizedState: │ │ memoizedState: │ │ 'Mary' │────→│ effectObj │────→│ 18 │ │ next: Hook2 │ │ next: Hook3 │ │ next: null │ └──────────────────┘ └──────────────────┘ └──────────────────┘
|
首次渲染 vs 更新渲染
React 内部为 Hooks 维护了两套实现,通过一个全局的 ReactCurrentDispatcher 来切换:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| const HooksDispatcherOnMount = { useState: mountState, useEffect: mountEffect, };
const HooksDispatcherOnUpdate = { useState: updateState, useEffect: updateEffect, };
function renderWithHooks(current, workInProgress, Component, props) { if (current !== null && current.memoizedState !== null) { ReactCurrentDispatcher.current = HooksDispatcherOnUpdate; } else { ReactCurrentDispatcher.current = HooksDispatcherOnMount; }
const children = Component(props);
return children; }
|
首次渲染(Mount): 每个 useState 调用都会创建一个新的 Hook 对象,串入链表:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| function mountState(initialState) { const hook = mountWorkInProgressHook();
if (typeof initialState === 'function') { initialState = initialState(); }
hook.memoizedState = initialState; hook.baseState = initialState;
const queue = { pending: null, dispatch: null, }; hook.queue = queue;
const dispatch = (queue.dispatch = dispatchSetState.bind( null, currentlyRenderingFiber, queue ));
return [hook.memoizedState, dispatch]; }
|
更新渲染(Update): 每个 useState 调用不再创建 Hook,而是从已有链表中取下一个 Hook:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| function updateState(initialState) { const hook = updateWorkInProgressHook();
const queue = hook.queue; let newState = hook.baseState; let update = queue.pending;
if (update !== null) { do { const action = update.action; newState = typeof action === 'function' ? action(newState) : action; update = update.next; } while (update !== null); }
hook.memoizedState = newState; return [newState, queue.dispatch]; }
|
关键函数:updateWorkInProgressHook
这个函数做的事情很简单但很关键——从链表上取出当前位置的 Hook,然后把指针移到下一个:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| let currentHook = null; let workInProgressHook = null;
function updateWorkInProgressHook() { if (currentHook === null) { const current = currentlyRenderingFiber.alternate; currentHook = current.memoizedState; } else { currentHook = currentHook.next; }
const newHook = { memoizedState: currentHook.memoizedState, baseState: currentHook.baseState, baseQueue: currentHook.baseQueue, queue: currentHook.queue, next: null, };
if (workInProgressHook === null) { currentlyRenderingFiber.memoizedState = newHook; workInProgressHook = newHook; } else { workInProgressHook.next = newHook; workInProgressHook = newHook; }
return workInProgressHook; }
|
这里就能看清楚 React 的策略了:按照 Hook 在组件中的调用顺序,逐个从旧链表取值,构建新链表——不靠变量名,纯靠顺序。
4. 为什么不能写在 if 里(Hook 规则的底层原因)
理解了”链表 + 按顺序取”的机制,就彻底明白了为什么官网强调:不要在循环、条件或嵌套函数中调用 Hook。
灾难现场
1 2 3 4 5 6 7 8 9 10
| function Form() { const [name, setName] = useState('Mary');
if (Math.random() > 0.5) { const [surname, setSurname] = useState('Poppins'); }
const [width, setWidth] = useState(500); }
|
第一次渲染(所有 Hook 都执行了):
1 2
| 链表: Hook A ('Mary') → Hook B ('Poppins') → Hook C (500) 顺序: 1 2 3
|
第二次渲染(条件不满足,Hook B 被跳过了):
1 2 3 4 5 6 7
| 链表不变: Hook A ('Mary') → Hook B ('Poppins') → Hook C (500) 取值顺序: 1 2 3
实际执行: 第 1 个 useState → 取链表第 1 个 → 'Mary' ✅ 第 2 个 useState → 取链表第 2 个 → 'Poppins' ❌ 这应该是 width! (width 期望拿到 500,结果拿到了 '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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| function dispatchSetState(fiber, queue, action) { const update = { action, lane: requestUpdateLane(), next: null, };
const pending = queue.pending; if (pending === null) { update.next = update; } else { update.next = pending.next; pending.next = update; } queue.pending = update;
scheduleUpdateOnFiber(fiber, lane); }
|
注意 queue 是一个环形链表——这样 queue.pending 始终指向最后一个 update,而 queue.pending.next 就是第一个 update,方便从头遍历。
当多个 setState 在同一个事件中被调用时,React 会把它们合并成一批(Automatic Batching,React 18 的特性):
1 2 3 4 5 6
| function handleClick() { setCount(c => c + 1); setFlag(f => !f); setName('Bob'); }
|
在 React 18 之前,只有 React 事件处理函数内的 setState 才会被批量处理。React 18 的 createRoot 让所有场景(包括 setTimeout、Promise、原生事件回调)都默认启用批量更新。
6. useEffect 的挂载逻辑
useEffect 的 Hook 对象和 useState 共享同一条链表,但 memoizedState 里存的是一个 Effect 对象:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| function mountEffect(create, deps) { const hook = mountWorkInProgressHook();
const effect = { tag: HookPassive, create, destroy: undefined, deps, next: null, };
hook.memoizedState = effect;
pushEffect(effect); }
function updateEffect(create, deps) { const hook = updateWorkInProgressHook(); const prevEffect = hook.memoizedState;
if (deps !== null) { const prevDeps = prevEffect.deps; if (areHookInputsEqual(deps, prevDeps)) { hook.memoizedState = pushEffect(HookPassive, create, prevEffect.destroy, deps); return; } }
currentlyRenderingFiber.flags |= PassiveEffect; hook.memoizedState = pushEffect( HookPassive | HookHasEffect, create, prevEffect.destroy, deps ); }
|
关于依赖数组的比较逻辑 areHookInputsEqual:
1 2 3 4 5 6 7 8 9
| function areHookInputsEqual(nextDeps, prevDeps) { for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) { if (Object.is(nextDeps[i], prevDeps[i])) { continue; } return false; } return true; }
|
这就是为什么依赖数组里放对象时,即使内容没变,如果引用变了(每次渲染创建了新对象),effect 还是会重新执行。
7. 阶段总结
Hooks 并不神奇。去掉语法糖后,它只是利用了两个基础的编程概念:
| 机制 |
解决的问题 |
实现方式 |
| 闭包快照 |
每次渲染看到独立的 state |
JS 闭包捕获当次渲染的值 |
| 链表存储 |
函数执行完后状态不丢失 |
数据挂在 Fiber.memoizedState 上 |
| 环形更新队列 |
批量处理多个 setState |
Update 对象形成环形链表 |
| 两套 Dispatcher |
区分首次渲染和更新渲染 |
mount 创建链表,update 按序取值 |
正是因为底层”按顺序存储”的链表实现,才有了”不能在条件语句里写 Hook”这条铁律。这不是 React 团队的洁癖,而是数据结构决定的。
下一篇是这个系列的最后一块拼图——并发模式。我们要搞清楚 React 18 的 useTransition 和优先级调度是如何在底层实现”高优先级打断低优先级”的。
Happy Coding!