搞混了 setState 同步还是异步问题
刚学 React 接触setState的时候,经常会想一个问题:setState 到底是同步的还是异步的?
“好像是异步的”,结果写代码时又发现有时候它“立刻生效”了。越想越糊涂,直到后来踩了坑、看了源码、再结合 React 18 的变化,才真正理清楚。
就最近遇到的切换页面主题的react项目,里面的有一下一段代码
const toggleTheme = () => {
setTheme(previousState => previousState === 'light' ? 'dark' : 'light');
};
这又让我想起setState这个许久的问题,它和“同步/异步”有关系吗?决定写一篇文章来捋一捋。
一开始,我以为 setState 是“异步”的
脑子里立刻浮现出那个经典例子:
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
console.log(count); 、
};
这里打印出来的还是老值,导致我一直以为是因为“setState 是异步的,还没执行完”。 但后来我才意识到——这个理解其实有点跑偏了
一、 所谓的“异步”,其实是 React 在“攒大招”
为什么 console.log(count) 打印的是 0?
并不是因为 setCount 像 setTimeout 或者接口请求那样真的是个异步任务,被扔到了微任务队列里。根本原因是 React 为了性能,开启了一个叫 “批处理” 的机制。
想象一下你去超市结账。如果你拿一瓶水,收银员算一次钱;再拿包薯片,收银员再算一次钱……收银员(渲染引擎)肯定会被你累死。 React 很聪明,它会把你的多次 setState 操作先“记在小本本上”,等你这一轮事件处理函数执行完了,它再一次性把所有账单结了,这个操作在react里面叫更新dom。
所以,当你执行 console.log 的时候,React 甚至还没开始动手更新呢,你读到的自然是旧值。
为了验证这一点,咱们直接上代码测试,用 React 17 和 React 18 对比,真相立马浮出水面。
二、在 React 17 里的不同
后来我看了一些老教程,说“在 setTimeout 里 setState 是同步的”。于是我兴奋地去试了一下:
// 环境:React 17
const handleClick = () => {
setTimeout(() => {
setCount(c => c + 1);
// 很多人(包括以前的我)以为这里能打印出 1
// 结果控制台啪的一下打脸:依然是 0 !!!
console.log(count);
}, 0);
};
![]()
当时我就懵了,直到我打开 Chrome 开发者工具的 Elements 面板,盯着那个 DOM 节点看,才发现了一个惊人的事实:
-
DOM 确实变了! 在
console.log执行的那一瞬间,页面上的数字已经变成 1 了。说明 React 确实同步完成了渲染。 -
但
count变量没变! 因为我是用函数式组件写的。
这就触及到了知识盲区: 在 React 17 的 setTimeout 里,React 确实失去了“批处理”的能力,导致它被迫同步更新了视图。但是!由于函数式组件的闭包特性,我当前这个 handleClick 函数是在 count=0 的时候创建的,它手里拿的 count 永远是 0。
所以,视图是新的,变量是旧的。这才是最坑的地方。
三、React 18 的大一统
回到 React 18,官方推出了 自动批处理 。
现在,不管你是在 setTimeout、Promise 还是原生事件里,React 都会把门焊死,统统进行批处理。
setTimeout(() => {
setCount(c => c + 1);
setName('Alice');
setIsLoading(false);
}, 0);
👉 结果:只 re-render 1 次!
React 18 无论你在哪调用状态更新(事件、定时器、Promise、fetch 回调等) ,都会自动把它们“攒起来”,在当前 tick 结束时一次性合并更新并渲染。
这意味着,在 React 18 里,除非你用 flushSync 这种逃生舱,否则你几乎看不到 DOM 同步更新的情况了。这其实是好事,心智负担少了很多,不用再去记那些特例。
首先,我们来看最常见的场景。如果它是同步的,那我改三次,它就应该变三次
来看这段代码:
// React 18 环境
export default function App() {
console.log("组件渲染了!"); // 埋点:监控渲染次数
const [count, setCount] = useState(0);
const handleClick = () => {
// 连发三枪
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// 马上查看
console.log("点击时的 count:", count);
};
return <button onClick={handleClick}>{count}</button>;
}
![]()
运行结果直接打脸:
- 控制台打印
点击时的 count: 0。(说明:代码执行到这行时,状态根本没变) -
"组件渲染了!"只打印了 1 次。(说明:三次操作被合并了) - 页面上的数字变成了
1,而不是3。
四、setState 同步的情况
我们可以逼 React 同步执行。在 React 18 里,我们需要用 flushSync 这个 API 来关掉自动批处理。
上代码:
import { useState } from 'react';
import { flushSync } from 'react-dom';
export default function App() {
const [count, setCount] = useState(0);
const handleClick = () => {
// 第一次更新:强制同步
flushSync(() => {
setCount(c => c + 1);
});
console.log("第一次 flushSync 结束,DOM 上的数字是:", document.getElementById('count-span').innerText);
// 第二次更新:强制同步
flushSync(() => {
setCount(c => c + 1);
});
console.log("第二次 flushSync 结束,DOM 上的数字是:", document.getElementById('count-span').innerText);
};
return (
<div>
<span id="count-span">{count}</span>
<button onClick={handleClick}>点击增加</button>
</div>
);
}
![]()
结论: 看,React 其实完全有能力同步更新。只要你用 flushSync 勒令它“立刻、马上干活”,它就会停下手头的工作,立刻执行更新流程。
所以,准确地说:setState 本质上是同步执行代码的,只是 React 默认挂了个“防抖”的机制,让它看起来像是异步的。
五、最坑的“假异步”(闭包陷阱)
既然上面的代码里,DOM 都已经同步变了,那我在 JS 里直接打印 count 变量
看这段代码:
const handleClick = () => {
flushSync(() => {
setCount(c => c + 1);
});
// 刚才代码证明了,DOM 这里已经变成 1
// 那这里打印 count 应该是几?
console.log("也就是现在的 count 是:", count);
};
![]()
这不是 React 的锅,这是 JavaScript 闭包的锅。
我们这个 handleClick 函数,是在 count 为 0 的那次渲染中生成的。它就像一张照片,永远定格在了那一刻。
无论你用办法(比如 flushSync)让 React 在外部把 DOM 更新了,或者把 React 内部的 State 更新了,但你当前正在运行的这个 handleClick 函数作用域里,count 这个局部变量,它就是个常量 0,再怎么搞它也是 0。
回到最初的问题
理清了这些,再回过头看开头那段代码:
const toggleTheme = () => {
setTheme(previousState => previousState === 'light' ? 'dark' : 'light');
};
为什么要写成 previousState => ... 这种函数形式?
这和“同步/异步”有关系吗?有关系。
正因为 React 的 setState 是“异步”(批处理)的,而且函数式组件有闭包陷阱,如果直接写 setTheme(theme === 'light' ? ...),你拿到的 theme 很可能是旧值(也就是上面例子里那个永远是 0 的 count)。
当你传入一个函数时,你是在告诉 React:
“麻烦把当时最新的那个状态值传给我的函数。我不信我自己闭包里的旧变量,我只信你传给我的新值。”
总结一下
1、定性: “严格来说,setState 是由 React 调度的更新,表现得像异步(批处理的原因)。”
2、亮点:
-
“在 React 18 中,得益于自动批处理,无论在 React 事件还是
setTimeout中,它都会合并更新,表现为异步。” -
“但在 React 17 及以前,如果在
setTimeout或原生事件中,它会脱离 React 的管控,表现为同步行为。”
3、补充特例: “如果需要在 React 18 中强制同步更新 DOM,我们可以使用 flushSync。”
4、最后补刀(闭包): “但无论 DOM 是否同步更新,在函数式组件中,由于 JS 闭包 的存在,我们在当前函数执行上下文中拿到的 state 永远是本次渲染的快照(旧值),要获取最新值应该依赖 useEffect 或 useRef。”