setState到底是同步的还是异步的?

您好,如果喜欢我的文章,可以关注我的公众号「量子前端」,将不定期关注推送前端好文~

以下内容针对React v18以下版本。

前言

setState到底是同步还是异步?很多人可能面试都被问到过,就好比这道代码输出题:

typescript 复制代码
constructor(props) {
  super(props);
  this.state = {
    data: 'data'
  }
}

componentDidMount() {
  this.setState({
    data: 'did mount state'
  })

  console.log("did mount state ", this.state.data);
  // did mount state data

  setTimeout(() => {
    this.setState({
      data: 'setTimeout'
    })

    console.log("setTimeout ", this.state.data);
  })
}

这段代码的输出结果,第一个console.log会输出data,而第二个console.log会输出setTimeout。也就是第一次setState的时候,是异步更新的,而第二次setState的时候,它又变成了同步更新,是不是有点晕呢?我们去源码里看一下setState更新调度的时候到底做了些什么。

探针

setState被调用后最终会走到scheduleUpdateOnFiber函数中,那我们看一下这个函数做了些什么呢?

typescript 复制代码
if (executionContext === NoContext) {
  // Flush the synchronous work now, unless we're already working or inside
  // a batch. This is intentionally inside scheduleUpdateOnFiber instead of
  // scheduleCallbackForFiber to preserve the ability to schedule a callback
  // without immediately flushing it. We only do this for user-initiated
  // updates, to preserve historical behavior of legacy mode.
  flushSyncCallbackQueue();
}

executionContext代表了React目前所处的阶段,而NoContext你可以理解为是React没活干的状态,而flushSyncCallbackQueue里面就会去同步调用我们的this.setState,也就是说同步更新我们的state。所以,我们已经知道了,当executionContextNoContext的时候,我们的setState就是同步的。那什么地方会改变executionContext的值呢?

我们随便找几个地方看看:

typescript 复制代码
function batchedEventUpdates$1(fn, a) {
  var prevExecutionContext = executionContext;
  executionContext |= EventContext;
  // ...省略
}

function batchedUpdates$1(fn, a) {
  var prevExecutionContext = executionContext;
  executionContext |= BatchedContext;
  // ...省略
}

React进入它自己的调度步骤时,会给executionContext赋予不同的枚举,表示不同的操作和目前React所处的调度状态,而executionContext的初始值就是NoContext,所以只要你不进入React的调度流程,这个值就是NoContext,那你的setState就是同步的。

那在useState呢?自从React出了hooks之后,函数组件也能拥有自己的状态,那么如果我们调用它的第二个参数去setState更改状态,和类组件的this.setState是一样的效果吗?

没错,因为useStateset函数最终也会走到scheduleUpdateOnFiber,所以在这一点上和this.setState是没有区别的,相当于使用了一个通用函数。

但是值得注意的是,当我们调用this.setState的时候,React会自动帮我们做一个state的合并,而hook则不会,所以我们在使用的时候更着重注意这一点。

举个例子:

typescript 复制代码
// 类组件中
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' }

但是如果你自己去尝试在函数组件中的setTimeout中去调用setState之后,打印state,你会发现并没有改变,这时你就会很疑惑,为什么呢?这不是同步执行的么?这其实是一个闭包问题,实际上拿到的还是上一个state,那打印出来的值自然也还是上一次的,此时真正的state已经改变了。

相信看到这里对于标题你已经有了答案了吧?只要你进入了React的调度流程,那就是异步的。只要你没有进入React的调度流程(executionContext === NoContext),那就是同步的。什么情况不会进入React的调度流程?setTimeout、setInterval,直接在DOM上绑定原生事件等。这些都不会走React的调度流程,你在这种情况下调用setState,那这次setState就是同步的。否则就是异步的。而setState同步执行的情况下,DOM也会被同步执行更新,也就意味着如果多次setState会导致多次更新,这也是毫无意义且浪费性能的。

setTimeout、原生事件中调用setState的操作确实比较少见,还是先看一个案例:

typescript 复制代码
const fetch = async () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve('fetch data');
    }, 300);
  })
}

componentDidMount() {
  (async () => {
    const data = await this.fetch();
    this.setState({data});
    console.log("data: ", this.state);
    // data: fetch data
  })()
}

在生命周期componentDidMount挂载阶段时发送了一个网络请求,然后拿到请求响应结果后再调用setState,这时候我们用了async/await来处理。

这时候我们会发现其实setState变成同步了,为什么呢?componentDidMount不是React的内置钩子函数吗?这难道都不算React的调度环境吗?因为componentDidMount执行完毕后,就已经退出了React调度,而请求的代码是异步的,相当于队列中的宏任务还没处理完毕,等结果请求回来以后,setState才会执行。async函数中await后面的代码其实是异步执行的,这就和在setTimeout中执行setState是同样的效果,所以我们的setState就变成同步的了。

那如果变成同步的情况下滥用setState会出现什么坏处呢?我们来看看在非React调度环境下调用setState会发生什么:

typescript 复制代码
this.state = {
  data: 'init data',
}

componentDidMount() {
    setTimeout(() => {
      this.setState({data: 'data 1'});
      // console.log("dom value", document.querySelector('#state').innerHTML);
      this.setState({data: 'data 2'});
      // console.log("dom value", document.querySelector('#state').innerHTML);
      this.setState({data: 'data 3'});
      // console.log("dom value", document.querySelector('#state').innerHTML);
    }, 1000)
}

render() {
  return (
    <div id="state">
      {this.state.data}
    </div>
  );
}

我们先看一下console的输出结果:

可以看到,console的结果是符合预期的,在setTimeout中,属于非React调度环境,在1秒后同步打印了三个最新的结果。

但是界面上出现了从最早的init data直接变成了data 3,这是为啥呢?我们每次都能在DOM上拿到最新的state,是因为React已经把state的修改同步更新了,但是为什么界面上没有显示出来?因为对于浏览器来说,渲染线程JS线程是互斥阻塞的,React代码运行调度时,浏览器是无法渲染的。所以实际上我们把DOM更新了,但是state又被修改了,React只好再做一次更新,这样反复了三次,最终React代码执行完毕后,浏览器才把最终的结果渲染到了页面上,也意味着前两次更新是无用无意义的。

我们把setTimeout去掉,就会发现三次输出都为init data,因此此时的setState就变成了异步的,会把三次更新批量合并到一次去执行,在渲染上也不会出现问题。所以当setState变成同步时就要注意,不要写出让React多次更新组件的代码,这样是毫无意义的。

结尾

React已经帮助我们做了很多优化措施,但是有时代码不同的实现方式导致了React的性能优化失败,相当于我们自己做了反优化,因此深入理解React的运行理解对于日常开发的帮助也是很大的。

如果喜欢我的文章,可以关注我的公众号「量子前端」,将不定期关注推送前端好文~

相关推荐
GIS程序媛—椰子14 分钟前
【Vue 全家桶】7、Vue UI组件库(更新中)
前端·vue.js
DogEgg_00120 分钟前
前端八股文(一)HTML 持续更新中。。。
前端·html
ZL不懂前端23 分钟前
Content Security Policy (CSP)
前端·javascript·面试
乐闻x26 分钟前
ESLint 使用教程(一):从零配置 ESLint
javascript·eslint
木舟100927 分钟前
ffmpeg重复回听音频流,时长叠加问题
前端
王大锤439137 分钟前
golang通用后台管理系统07(后台与若依前端对接)
开发语言·前端·golang
我血条子呢1 小时前
[Vue]防止路由重复跳转
前端·javascript·vue.js
黎金安1 小时前
前端第二次作业
前端·css·css3
啦啦右一1 小时前
前端 | MYTED单篇TED词汇学习功能优化
前端·学习