从 Vue 到 React:深入理解 useState 的异步更新与函数式写法
目录
- 从 Vue 到 React:深入理解 useState 的异步更新与函数式写法
- 1. Vue 的响应式回顾:每次赋值立即生效
- 2. React 的状态更新是异步且批量的
- 原因解析
- 3. 函数式更新:唯一的正确写法
- 4. 总结对比:Vue vs React 状态更新
- 附录:React `useState` 的核心源码机制
- 1️⃣ Hook 数据结构:链式存储的 Hook 节点
- 2️⃣ 初次渲染:挂载阶段的 useState
- 3️⃣ 更新过程:将 `action` 推入队列
- 4️⃣ 更新应用:render 阶段的 `updateState`
- 5️⃣ 函数式更新为何正确?
- 6️⃣ 总结一下
从 Vue 到 React:深入理解 useState 的异步更新与函数式写法
在从 Vue 转向 React 的过程中,很容易被一个看似简单的问题困扰:
setCount(count + 1);
setCount(count + 1); // 预期 +2,实际只 +1?
为什么我们连续两次调用 setCount(count + 1)
,却没有得到我们预期的 +2 效果?而换成函数式写法:
setCount(prev => prev + 1);
setCount(prev => prev + 1); // 结果才是 +2
却又一切正常?
本文将从 Vue 的响应式系统出发,一步步理解 React 中useState
的状态更新机制,并且在文末会附上核心源码解析,帮你更深入地理解。
1. Vue 的响应式回顾:每次赋值立即生效
在 Vue 中,响应式数据是实时变更的:
const count = ref(0);
count.value++;
count.value++; // 最终为 2
Vue 是利用 Proxy 拦截 .value
的修改,每次赋值都会立即生效并触发响应式更新,从开发者来看就是所写即所得。
2. React 的状态更新是异步且批量的
React 的状态更新行为则截然不同。以 useState
为例:
const [count, setCount] = useState(0);
如果我们连续两次执行:
setCount(count + 1);
setCount(count + 1);
会发现页面上 count
只增加了 1!
原因解析
React 为了性能优化,在一次事件循环中会合并所有的 setState
操作(批处理 / batching),并且这些更新是异步生效的。也就是说:
- 多次
setCount(count + 1)
实际上使用的是同一个旧值count
- 每次 render 周期中,state 是只读的快照,相当于每次 render 周期会给 count 拍一张照片,照片停格在 1 ,而非 2
结果就是:
const count = 0;
setCount(count + 1); // 相当于 setCount(1)
setCount(count + 1); // 还是 setCount(1)
最终只更新一次。
3. 函数式更新:唯一的正确写法
为了解决这个问题,React 提供了 函数式更新写法:
setCount(prev => prev + 1);
setCount(prev => prev + 1); // 最终为 2
这种写法的优势在于:每次执行都会传入 最新的 state 值,即使处于同一个批处理中,也能逐步叠加。
它的工作方式等价于:
let current = count;
current = current + 1;
current = current + 1;
setCount(current);
4. 总结对比:Vue vs React 状态更新
特性 | Vue | React |
---|---|---|
响应性实现 | Proxy 拦截或 ref() | Fiber 链表 + Hook 存储 |
多次状态修改 | 同步生效,立即响应 | 异步合并更新(batch) |
闭包问题 | 很少遇到 | 高频出现,需小心处理 |
正确累加方式 | count.value++ | setCount(prev => prev + 1) |
附录:React useState
的核心源码机制
让我们再深入一步,了解 useState
背后是如何工作的。
useState
的底层逻辑,本质上是通过构建一个单向链表结构的 Hook 存储系统,结合更新队列与调度策略来驱动状态更新。我们从以下几个维度拆解其机制:
1️⃣ Hook 数据结构:链式存储的 Hook 节点
在 React 函数组件中,每调用一次 useState
(或其他 Hook),React 就在当前组件 Fiber 节点上注册一个对应的 Hook 节点,结构大致如下:
type Hook = {
memoizedState: any; // 当前 state 值
queue: UpdateQueue | null; // 更新队列(待应用的 state 变更)
next: Hook | null; // 指向下一个 Hook(形成链表)
}
每个组件内部维护着一个单向链表的 Hook 列表,通过「调用顺序」来标识唯一性。
⚠️ 注意:不能写条件调用 Hook(如
if (...) useState()
),否则链表顺序不一致,状态错位。
2️⃣ 初次渲染:挂载阶段的 useState
在组件初次渲染时,React 调用 mountState
来创建 Hook 节点:
function mountState(initialState) {
const hook = mountWorkInProgressHook();
hook.memoizedState = typeof initialState === 'function'
? initialState()
: initialState;
hook.queue = {
pending: null, // 更新链表为空
dispatch: null,
lastRenderedReducer: basicStateReducer
};
const dispatch = (hook.queue.dispatch = (action) => {
// 将 action 推入队列
const update = {
action,
next: null
};
enqueueUpdate(hook.queue, update);
scheduleRender(); // 触发调度
});
return [hook.memoizedState, dispatch];
}
hook.memoizedState
:保存当前状态值hook.queue
:保存更新 action 的链表队列dispatch
:即我们使用的setState
3️⃣ 更新过程:将 action
推入队列
当你调用 setState
时,实际发生的是:
dispatch(action);
然后内部调用:
const update = {
action, // 可以是函数或值
next: null
};
enqueueUpdate(queue, update); // 插入环状链表
scheduleRender(); // 触发一次组件更新调度
更新队列为 循环单向链表(circular linked list),便于在 render 阶段完整遍历。
4️⃣ 更新应用:render 阶段的 updateState
组件重新渲染时,React 调用 updateState
,核心逻辑如下:
function updateState() {
const hook = updateWorkInProgressHook();
const queue = hook.queue;
let newState = hook.memoizedState;
let update = queue.pending;
if (update !== null) {
// 进入环形队列的遍历
let first = update.next;
let current = first;
do {
const action = current.action;
newState = typeof action === 'function'
? action(newState) // 函数式更新(prev => next)
: action;
current = current.next;
} while (current !== first);
hook.memoizedState = newState;
queue.pending = null; // 清空队列
}
return [hook.memoizedState, queue.dispatch];
}
💡 关键点:
- 如果
action
是函数,就使用函数式更新- 更新是基于前一次的 state 累加的
- 最终更新
memoizedState
,用于本轮 render
5️⃣ 函数式更新为何正确?
因为 action 是一个函数,且传入的是队列中最新的 newState
,每次都基于上一个结果计算:
setCount(prev => prev + 1);
setCount(prev => prev + 1); // prev 已是前一次递增后的值
这就是为什么 函数式写法可以连续叠加更新,而直接写 count + 1
会闭包住旧值。
6️⃣ 总结一下
[初始化]
└─ mountState → 创建 Hook 节点并保存初始值
[调用 setState]
└─ dispatch(action) → enqueueUpdate() → queue 中插入更新节点
[下一次 render]
└─ updateState() 遍历队列 → 应用每个更新 → 更新 memoizedState
[完成更新]
└─ React 触发重渲 → 组件拿到新 state → UI 重新渲染
所以呢总的来说就是:
React 中每次调用
useState
实际是在组件内部构建一个 Hook 链表节点,该节点保存当前的状态值与更新队列。在调用setState
时,更新被推入队列,在下一轮 render 时遍历这些更新并依次应用。函数式写法setState(prev => ...)
能够正确地累加,是因为每次都基于最新的状态进行计算,这是解决闭包陷阱的核心。