阅读视图

发现新文章,点击刷新页面。

搞混了 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?

并不是因为 setCountsetTimeout 或者接口请求那样真的是个异步任务,被扔到了微任务队列里。根本原因是 React 为了性能,开启了一个叫 “批处理” 的机制。

想象一下你去超市结账。如果你拿一瓶水,收银员算一次钱;再拿包薯片,收银员再算一次钱……收银员(渲染引擎)肯定会被你累死。 React 很聪明,它会把你的多次 setState 操作先“记在小本本上”,等你这一轮事件处理函数执行完了,它再一次性把所有账单结了,这个操作在react里面叫更新dom

所以,当你执行 console.log 的时候,React 甚至还没开始动手更新呢,你读到的自然是旧值。

为了验证这一点,咱们直接上代码测试,用 React 17 和 React 18 对比,真相立马浮出水面。

二、在 React 17 里的不同

后来我看了一些老教程,说“在 setTimeoutsetState 是同步的”。于是我兴奋地去试了一下:

// 环境:React 17 
const handleClick = () => {
  setTimeout(() => {
    setCount(c => c + 1);
    
    // 很多人(包括以前的我)以为这里能打印出 1
    // 结果控制台啪的一下打脸:依然是 0 !!!
    console.log(count); 
  }, 0);
};

image.png

当时我就懵了,直到我打开 Chrome 开发者工具的 Elements 面板,盯着那个 DOM 节点看,才发现了一个惊人的事实:

  1. DOM 确实变了!console.log 执行的那一瞬间,页面上的数字已经变成 1 了。说明 React 确实同步完成了渲染。
  2. count 变量没变! 因为我是用函数式组件写的。

这就触及到了知识盲区: 在 React 17 的 setTimeout 里,React 确实失去了“批处理”的能力,导致它被迫同步更新了视图。但是!由于函数式组件的闭包特性,我当前这个 handleClick 函数是在 count=0 的时候创建的,它手里拿的 count 永远是 0。

所以,视图是新的,变量是旧的。这才是最坑的地方。

三、React 18 的大一统

回到 React 18,官方推出了 自动批处理

现在,不管你是在 setTimeoutPromise 还是原生事件里,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>;
}

image.png

运行结果直接打脸:

  1. 控制台打印 点击时的 count: 0。(说明:代码执行到这行时,状态根本没变)
  2. "组件渲染了!" 只打印了 1 次。(说明:三次操作被合并了)
  3. 页面上的数字变成了 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>
  );
}

image.png

结论: 看,React 其实完全有能力同步更新。只要你用 flushSync 勒令它“立刻、马上干活”,它就会停下手头的工作,立刻执行更新流程。

所以,准确地说:setState 本质上是同步执行代码的,只是 React 默认挂了个“防抖”的机制,让它看起来像是异步的。

五、最坑的“假异步”(闭包陷阱)

既然上面的代码里,DOM 都已经同步变了,那我在 JS 里直接打印 count 变量

看这段代码:

const handleClick = () => {
  flushSync(() => {
    setCount(c => c + 1); 
  });
  
  // 刚才代码证明了,DOM 这里已经变成 1 
  // 那这里打印 count 应该是几?
  console.log("也就是现在的 count 是:", count); 
};

image.png

这不是 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 永远是本次渲染的快照(旧值),要获取最新值应该依赖 useEffectuseRef。”

❌