Skip to content

setState究竟是同步还是异步?

Posted on:July 25, 2023
import { useState, useEffect } from "react";
import "./styles.css";

export default function App() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setInterval(() => {
      setCount(count + 1);
    }, 1000);
  }, []);

  return (
    <div className="App">
      <p>You clicked {count} times</p>
    </div>
  );
}

结果:

count变为1后不再更新

原因:

在函数(setTimeout / setInterval / fetch API / Promise)内,React 无法命中 batchUpdate,即无法批量更新,setState 是同步的而不是异步的。 那如果去掉 setInterval,直接多次 setState,效果会不会是一样的呢?

// setInterval等价于

useEffect(() => {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
  // ....
}, []);

/**
 * 输出: 1
 **/

移除了 setInterval 函数,React 就会命中批量更新了,所以多个 setState 会合并成一个 setState,所以最终输出的是 1。

React 为了避免 dom 的频繁更新而造成的性能浪费(浏览器渲染线程和 js 线程是互斥的),会将多次 setState 的 update 合并成一次 update,不管是 class component 和 function component 都是这样的,不同之处在于,在 class component 中,this.setState 是合并 state,而 function component 是替换 state

state = {
  data: "data",
  data1: "data1",
};

this.setState({ data: "new data" });
console.log(state);
//{ data: 'new data',data1: 'data1' }

const [state, setState] = useState({ data: "data", data1: "data1" });
setState({ data: "new data" });
console.log(state);
//{ data: 'new data' }

在组件生命周期或  React  事件中, setState  是异步,在  setTimeout/setInterval  或者原生  dom  事件中, setState  是同步

但是有一种情况 react 控制不到,就是在 setTimeout、setInterval、Promise.then 这类函数的回调里调用 setState。

回到开头的那段代码,按照 react 批量更新的原理,在异步函数 setInterval 内,setState 应该是同步更新的,但实际发现 state 并没有改变,又是为什么呢?

其实这是闭包的问题,setInterval 回调函数形成了一个闭包,该闭包捕获了 count 变量的值 0,即使 count+1,但是 useEffect 依赖的是一个空数组,所以此时回调并不会执行(但是执行 setState 会也触发 re-render 啊,还是因为闭包的原因,这里的 count 每次都是 0,每次都+1,React 在 diff 的时候判断前后 state 值相等,不会触发 update DOM), 所以这个定时器只在组件初始化的时候执行了一次,并形成了闭包一直保存着,更新的过程并没有涉及到这个定时器函数(虽然定时器在默默地每隔一秒执行一次回调函数)。

如何才能每次都拿到最新的 state 呢?有两种方式:

/*
 * 方法一:
 * 将count加入useEffect的依赖,每次更新后清除当前过时的闭包
 */
function App() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => {
      clearInterval(id);
    };
  }, [count]);

  return (
    <div className="App">
      <p>You clicked {count} times</p>
    </div>
  );
}

/*
 * 方法二:
 * 通过传递函数给setState
 * 这样就更新了count状态,确保了将最新的状态值作为参数传递给了更新状态函数,过时的闭包问题就解决了
 */
function App_2() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setInterval(() => {
      setCount(count => count + 1);
    }, 1000);
  }, []);

  return (
    <div className="App">
      <p>You clicked {count} times</p>
    </div>
  );
}

setState 机制:

  1. 会根据一个变量 isBatchingUpdates 判断是直接更新还是放到队列中等待

  2. isBatchingUpdates 默认值是 false,在执行生命周期或 React 合成事件处理函数中会设置成 true,当执行完生命周期或事件处理函数后会变成 false,然后会一起更新状态和组件,所以整个过程看起来是异步的

  3. 在原生 DOM 事件中,react 不会调用批处理机制,所以 isBatchingUpdates 一直是 false,所以 setState 会直接更新

为什么 setState 要设置成异步?

本质上来讲  setState  是同步的, 之所以出现异步的假象是因为要进行状态合并或者说是批处理,需要等生命周期、事件处理器执行完毕, 再批量修改状态,这样做的目的:

  1. 保证 props 和 state 的一致性,实际开发过程中,状态会提升到父组件,和兄弟组件进行共享,如果 props 和 state 不一致很大概率会有问题

  2. 提高性能:避免多次更新 state 而渲染组件带来的性能损耗