React中setTimeout和useEffect的执行顺序为什么不符合预期?
我在用React写一个计数器组件,点击按钮后先调用setTimeout再更新状态,但发现useEffect里的console.log总是先于setTimeout里的输出。明明setTimeout在代码里写在前面啊,这是事件循环的问题吗?
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log("Effect ran: " + count); // 总是先执行
}, [count]);
const handleClick = () => {
setTimeout(() => {
console.log("Timeout: " + count); // 后执行
}, 0);
setCount(prev => prev + 1);
};
return (
<button onClick={handleClick}>
Click me {count}
</button>
);
}
我试过把setTimeout的延迟改成100ms就正常了,但想不通为什么0ms时会这样。是不是状态更新和宏任务的执行顺序有特殊规则?
代码放这了,加个同步日志就能看明白了:
点击按钮后的执行顺序是这样的:先打印"Before timeout",然后执行setCount,这里会触发状态更新调度但不会立即执行,接着useEffect就因为组件重新渲染而触发了,最后才是setTimeout这个宏任务执行。
所以你看,不是setTimeout慢,而是React的状态更新和effect的执行时机比较特殊。想让setTimeout里的值是最新的,记得用函数式更新:
setTimeout(() => console.log("Timeout: " + count), 0);改成这样:
setTimeout(() => console.log("Timeout: " + (count+1)), 0);或者用ref存最新值。这种坑我踩过好多次了,习惯了就好。
当你调用setCount时,React并不会立即更新状态,而是把状态更新放入一个队列,并在当前执行栈清空后批量处理。useEffect的回调函数会在DOM更新后、下一次绘制前同步执行,它实际上是插在你的setTimeout之前运行的。
建议改成这样的写法来验证:
你会发现state update的日志会在effect之前打印。至于为什么100ms就正常,是因为这已经超出了React的渲染周期,宏任务自然会等到渲染完成后才执行。
如果你确实需要在状态更新后再做某些操作,可以考虑使用flushSync:
不过说实话,日常开发中很少需要这么精确地控制执行顺序。大部分时候我们更应该关注代码的可读性和可维护性,而不是纠结这些底层细节。