setState同步还是异步

141 阅读5分钟

定义

这里所说的同步还是异步其实指的是调用setState后能否马上得到更新后的值,能得到最新值则为同步,不能得到最新值则为异步;

而不是指的setState这个函数是同步还是异步,单纯的说 setState函数肯定是同步的。

类组件

一道面试题目

import React from 'react';
import './App.css';

class AppClass extends React.Component {
  state = {
    count: 0,
  };

  handleClick = () => {
    this.setState({ count: 1 });
    console.log('count: ', this.state.count);

    this.setState({ count: 2 });
    console.log('count: ', this.state.count);

    setTimeout(() => {
      this.setState({ count: 3 });
      console.log('count: ', this.state.count);

      this.setState({ count: 4 });
      console.log('count: ', this.state.count);
    }, 0);
  };

  render() {
    return (
      <div className='App'>
        <button onClick={this.handleClick}>count = {this.state.count}</button>
      </div>
    );
  }
}

export default AppClass;

react17

  • 第一个输出 count: 0 ,说明setState 是异步执行的,所以在调用之后打印count 还是初始值0

  • 第二个输出还是count: 0 ,说明setState 还是异步执行的。

  • 第三个输出count: 3 ,而且在打印语句前正是调用setStatecount 置为了3 ,这里的setState 是同步执行的。

  • 第四个输出count: 4 ,而且前面也正是调用setStatecount 置为了4 , 这里setState也是同步执行的。

react 可调度范围内的setState 是异步的,会对更新开启批量更新;在react17中,react 合成事件内、生命周期函数内同步执行的setState 就是可调度范围;宏任务:setTimeout ,微任务:.then ,或直接在DOM元素上绑定的事件等都是react 可调度范围外。

分析下以上的输出:

  • handleClick 函数是react 的合成事件,所以其内部的setState 是异步的

  • 进入handleClick 函数内部,发现前两个setState 是没有被setTimeout 包裹的,在调度范围内,故表现为异步,所以前两次的输出都是0

  • 还有两个setState 是在setTimeout 内的,不在react调度范围内,故表现为同步,所以每次setState执行后都可以立即获取到更新后的值。

react18

批处理:是指将多个状态更新合并为一个更新,从而减少组件重新渲染的次数,提高应用程序的性能,React 18 实现了自动批处理。

  • 前两次的结果相同,都是0,证明这块是跟 v17 中一样的,都是异步

  • 后两次结果不一样,v17中是同步更新的,所以每次setState 后都可以立即获取到更新后的值,但v18 中打印的是两个2 ,说明是异步更新的,只是这个异步更新跟setTimeout 外部的不在一个批中,setTimeout 中的批处理明显落后外部的批处理。

flushSync

自动批处理是安全的,但某些代码可能依赖于在状态更改后立即从 DOM 中读取某些内容。这种情况,可以选择 ReactDOM.flushSync() 退出批处理,立即执行调度更新。

import { flushSync } from 'react-dom';

 state = {
    count: 0,
  };

handleClick = () => {
  this.setState({ count: 1 });
  console.log('count: ', this.state.count);

  // 此处执行同步刷新
  ReactDom.flushSync(() => {
    this.setState({ count: 2 });
  });
  console.log('ReactDom.flushSync 后的 count: ', this.state.count);

  setTimeout(() => {
    this.setState({ count: 3 });
    console.log('count: ', this.state.count);

    this.setState({ count: 4 });
    console.log('count: ', this.state.count);
  }, 0);
};

可以看到,同步刷新后,立即可以获取到变化后的值。

函数组件

import { useState } from 'react';
import './App.css';

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

  const handle = () => {
    setCount(1);
    console.log('count1: ', count);

    setCount(2);
    console.log('count2: ', count);

    setTimeout(() => {
      setCount(3);
      console.log('count3: ', count);

      setCount(4);
      console.log('count4: ', count);
    }, 0);

  };

  return (
    <>
      <div className='App'>
        <button onClick={handle}>count is {count}</button>
      </div>
    </>
  );
}

export default App;

react17

react18

为了能更清楚的看到 React 的渲染行为,修改下上边的代码,在每次渲染都都打印下当前的 count值,添加如下代码:

  console.log("render count:", count);
  useEffect(() => {
    console.log('useEffect: 此时的count: ', count);
  });
  

react17

  • 在 React 17 下,render 打印了 3 次,说明组件重新渲染了 3 次。 React 17 setState时,批处理时渲染一次,在setTimeout中是同步的,渲染两次。
  • 在函数组件中,由于闭包的原因,尽管setState是同步的,并且count的值已经同步更新了,但仍只能获取到原值。

注意render count:3 是调用函数组件打印的, count3:0 还是handle函数打印的。

函数组件调用后就被销毁,它创建的fiber节点才会持续存在,函数组件只是一个函数,用来构建同步fiber节点的。

react18

  • 在来看下 React 18 下,render 打印了 2 次,说明组件渲染了 2 次。 React18 有自动批处理,setTimeout外部批处理一次,内部批处理一次,一共渲染两次。

总结

同步还是异步其实指的是调用setState后能否马上得到更新后的值,能得到最新值则为同步,不能得到最新值则为异步。

在 React 18 中,利用了浏览器的事件循环机制实现自动批处理。在当前事件循环的宏任务中,每次setState后都会创建一个update对象,并且挂载到fiber上。并且只会将第一个setState产生的performWorkOnRoot方法放在本次循环的微任务或者下一次循环的宏任务中执行,当前宏任务中的其他setState仅产生数据,最终在微任务或者下次宏任务中批量更新一次。

在react17中只有在合成事件、生命周期函数中的setState是异步的;setTimeOut, Promise.then 等都是同步的。

在react18中,开启了自动批处理,setTimeOut, Promise.then都加上了批处理,想同步更新的话,可以使用flushSync。

注意:在函数式组件中,尽管是同步更新时并且状态值已经变了,但因为闭包的原因无法获取到最新的值。

⚠️ 批量更新 和 并发更新 是两个概念。

批量更新指的是setState状态更新时是异步还是同步的。

并发更新和同步更新是相对的,指的是render阶段是否可以被中断,将线程交给浏览器,以此响应一些重要的事件。