在 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>;
}

当我们点击按钮:

  1. setCount(1) 触发 React 更新
  2. Counter 函数重新执行
  3. 再次运行到 const [count, setCount] = useState(0)

关键问题: 按理说 count 应该被重置为初始值 0,为什么它变成了 1

要回答这个问题,需要理解两个独立但紧密协作的机制:

  • 闭包快照:每一次渲染都是独立的”帧”
  • 链表存储:状态不在函数内部,而在 Fiber 节点上

2. 原理一:闭包快照(Closure Snapshot)

每一次渲染都是一张独立的”照片”

React 的函数组件通过闭包机制,让每一次渲染都拥有独立的 props 和 state 值。

你可以把 Counter 想象成一台拍立得相机:

  • 每一次渲染(Render),就是按下一次快门
  • Props 和 State,就是被定格在照片里的”景色”

当我们说 count 变了,不是同一个变量的值变了,而是 React 重新拍了一张照片,这张新照片里的 count1

用代码来理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 第一次渲染时,React 执行:
function Counter() {
const count = 0; // 从 Fiber 拿到的值
// 这次渲染中,所有用到 count 的地方都"定格"在 0
// 包括事件处理函数、useEffect 回调,全部捕获的是 0
return <button onClick={() => setCount(0 + 1)}>{0}</button>;
}

// 第二次渲染时,React 执行的其实是"另一个函数作用域":
function Counter() {
const count = 1; // 从 Fiber 拿到的新值
// 这次渲染中,所有地方捕获的都是 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(() => {
// 这里的 count 是点击那一瞬间的"快照"
// 即使你后来狂点按钮把界面上的 count 变成了 10
// 3 秒后弹出的依然是点击时的那个数字
alert('Count is: ' + count);
}, 3000);
};

return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>增加</button>
<button onClick={handleAlert}>3 秒后弹窗</button>
</div>
);
}

实验步骤:

  1. 页面显示 Count: 0
  2. 点击”3 秒后弹窗”
  3. 立刻疯狂点击”增加”,让计数器涨到 10
  4. 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);

// 每次渲染都把最新值同步到 ref
useEffect(() => {
countRef.current = count;
});

const handleAlert = () => {
setTimeout(() => {
// ref.current 始终指向最新值
alert('Latest count is: ' + countRef.current);
}, 3000);
};

// ...
}

useRef 返回的对象在整个组件生命周期里始终是同一个引用(不会随着渲染创建新对象),所以 .current 始终指向最新的值。


3. 原理二:链表存储(Linked List Storage)

闭包解释了”每次渲染看到的值为什么不同”,但还没回答最根本的问题:值本身存在哪?

状态挂在 Fiber 节点上

还记得上一篇学的 Fiber 节点吗?每个组件对应的 Fiber 都有一个 memoizedState 属性,Hooks 的数据就挂在这里

但一个组件可能有多个 Hook(useStateuseEffectuseMemo…),React 怎么区分它们?

答案是:依靠调用顺序。 React 内部用一个单向链表把它们串起来。

链表结构详解

每个 Hook 在内部对应一个 Hook 对象,大致结构如下:

1
2
3
4
5
6
7
interface Hook {
memoizedState: any; // 存储的值(useState 存状态值,useEffect 存 effect 对象)
baseState: any; // 基础状态(用于并发模式下的状态计算)
baseQueue: Update | null; // 未处理的更新队列
queue: UpdateQueue | null; // 当前更新队列
next: Hook | null; // 指向下一个 Hook → 形成链表
}

假如我们在组件里写了三个 Hook:

1
2
3
4
5
function App() {
const [name, setName] = useState('Mary'); // Hook 1
useEffect(() => { /* ... */ }); // Hook 2
const [age, setAge] = useState(18); // Hook 3
}

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
// React 内部(简化)
const HooksDispatcherOnMount = {
useState: mountState,
useEffect: mountEffect,
// ...
};

const HooksDispatcherOnUpdate = {
useState: updateState,
useEffect: updateEffect,
// ...
};

function renderWithHooks(current, workInProgress, Component, props) {
// 根据是首次渲染还是更新,切换不同的 dispatcher
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) {
// 创建新的 Hook 对象,追加到链表末尾
const hook = mountWorkInProgressHook();

// 如果初始值是函数,执行它
if (typeof initialState === 'function') {
initialState = initialState();
}

hook.memoizedState = initialState;
hook.baseState = initialState;

// 创建更新队列
const queue = { pending: null, dispatch: null, /* ... */ };
hook.queue = queue;

// 创建 dispatch 函数(就是 setXxx)
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) {
// 从链表中取出下一个 Hook(按顺序取)
const hook = updateWorkInProgressHook();

// 计算最新状态:遍历 queue 中的所有 update
const queue = hook.queue;
let newState = hook.baseState;
let update = queue.pending;

if (update !== null) {
do {
const action = update.action;
// 如果 action 是函数(如 setCount(prev => prev + 1)),执行它
// 如果是值(如 setCount(5)),直接赋值
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;        // current 树上的当前 Hook
let workInProgressHook = null; // WIP 树上的当前 Hook

function updateWorkInProgressHook() {
// 从 current 树的 Hook 链表取出下一个
if (currentHook === null) {
const current = currentlyRenderingFiber.alternate;
currentHook = current.memoizedState; // 链表头
} else {
currentHook = currentHook.next; // 链表的下一个
}

// 基于 current Hook 创建 WIP Hook(复用/克隆)
const newHook = {
memoizedState: currentHook.memoizedState,
baseState: currentHook.baseState,
baseQueue: currentHook.baseQueue,
queue: currentHook.queue,
next: null,
};

// 追加到 WIP 链表
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'); // Hook A

// 假设某次渲染 Math.random() 返回了一个小值
if (Math.random() > 0.5) {
const [surname, setSurname] = useState('Poppins'); // Hook B
}

const [width, setWidth] = useState(500); // Hook C
}

第一次渲染(所有 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) {
// 创建 Update 对象
const update = {
action, // 新的值或者 updater 函数
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); // Update 1 → 挂到 queue
setFlag(f => !f); // Update 2 → 挂到另一个 Hook 的 queue
setName('Bob'); // Update 3 → 挂到又一个 Hook 的 queue
// 以上三个调用只会触发一次重新渲染
}

在 React 18 之前,只有 React 事件处理函数内的 setState 才会被批量处理。React 18 的 createRoot 让所有场景(包括 setTimeoutPromise、原生事件回调)都默认启用批量更新。


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, // 标记为 passive effect(异步执行)
create, // effect 回调函数
destroy: undefined, // cleanup 函数(首次为空)
deps, // 依赖数组
next: null, // effect 链表的 next
};

hook.memoizedState = effect;

// 把 effect 也挂到 Fiber 的 updateQueue 上
// Commit 阶段会遍历这个队列来执行 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)) {
// 依赖没变 → 跳过,不执行 effect
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!