听风是风

学或不学,知识都在那里,只增不减。

导航

react中的setState是同步还是异步?react为什么要将其设计成异步?

壹 ❀ 引

了解react的同学都知道,react遵守渲染公式UI=Render(state),状态决定了组件UI最终渲染的样子(props也可以理解为外部传入的状态),由此可见state对于react的重要性。而在实际使用中,若我们想修改状态必须得借用APIsetState,也只有通过此方法修改状态才能顺利触发react下次render,那么对于一个使用如此高频的方法你了解它多少呢?

这里我们可以先抛出几个问题:

  • setState是同步还是异步?
  • 什么情况下同步?什么情况下异步?
  • setState批量合并是指只执行最后一次吗?比如执行了3次,第1,2次到底有没有执行?
  • 为什么要将setState设计成异步?这样设计的好处是什么?

设想一下,上述问题如果在面试中遇到该如何作答?那就让我们带着问题出发,另外,文中所有例子强烈建议本地跑一跑,加深对于概念的理解,那么本文开始。

贰 ❀ setState中的同步与异步

贰 ❀ 壹 updater为对象时的异步情况

setState接受一个带有形式参数的 updater 函数(也可能直接是一个对象)与一个回调callback(可选)。

setState(updater, [callback])

官方明确表示,setState对于this.state并不是立刻更新,若在调用setState后想要立刻获取到最新的this.state,那么建议在setStatecallback或者声明周期componentDidUpdate中获取,比如:

class Echo extends React.Component {
  state = {
    num: 1
  }

  componentDidUpdate() {
    console.log(this.state.num);//2
  }

  handleOnClick = () => {
    this.setState({ num: this.state.num + 1 }, () => {
      console.log(this.state.num);//2
    });
    console.log(this.state.num);//1
  }

  render() {
    return (
      <>
        <div>{this.state.num}</div>
        <button onClick={this.handleOnClick}>加1</button>
      </>
    )
  }
}

其实既然官方特意强调在callback中获取最新的this.state,那就已经说明存在某些地方拿不到最新的this.state的情况,比如上述代码中setState后我们立刻读取sum,可以发现num还是1,那么到这里我们可以得知setState对于this.state的更新确实是异步。

问题来了,react为什么将setState设计成异步呢?设想下我们有如下这种场景:

class Echo extends React.Component {
  state = {
    num: 1
  }

  componentDidUpdate() {
    console.log(this.state.num);//2
  }

  handleOnClick = () => {
    this.setState({
      num: this.state.num + 1
    }, () => {
      console.log(this.state.num)//2
    });

    this.setState({
      num: this.state.num + 1
    }, () => {
      console.log(this.state.num)//2
    });

    console.log(this.state.num);//1
  }

  render() {
    return (
      <>
        <div>{this.state.num}</div>
        <button onClick={this.handleOnClick}>加1</button>
      </>
    )
  }
}

当点击按钮,我们需要连着两次执行setState,那么react会帮我们修改两次this.state然后重新render两次吗?很明显并不是,react会批量合并多次setState操作,上述例子num最终是2,且render在点击后只会渲染一次。

React在开始重新渲染之前, 会有意地进行"等待",直到所有在组件的事件处理函数内调用的 setState()都完成之后再做最终的this.state变更,这样可以通过避免不必要的重新渲染来提升性能。

贰 ❀ 贰 updater为函数时的异步情况

突然奇想,上述代码的需求有了些许变更,我们还是在点击后执行两次setState,但我预期最终的sum是3,如何做到呢?别忘了前面我们对于setState的语法介绍,本质上updater是一个接受最新state与最新props并用于返回你用来更新this.state的函数:

// 这里可以拿到最新的state与props,注意,是最新的state,而不是最新的this.state
(state, props) => stateChange

函数写法能让我们拿到立刻变更后的state,因此我们可以来看看这个例子:

class Echo extends React.Component {
  state = {
    num: 1
  }

  componentDidUpdate() {
    console.log('我是更新完成后的this.state',this.state.num);
  }

  handleOnClick = () => {
    this.setState((state, props) => {
      console.log('第一次调用,我是最新的state',state.num)
      console.log('第一次调用,我是当前的this.state',this.state.num)
      // 注意,这里用的是state,不是this.state
      return { num: state.num + 1 };
    }, () => {
      console.log('第一次调用,我是调用完成后的this.state',this.state.num)
    });

    this.setState((state, preProps) => {
      console.log('第二次调用,我是最新的state',state.num)
      console.log('第二次调用,我是当前的this.state',this.state.num)
      return { num: state.num + 1 };
    }, () => {
      console.log('第二次调用,我是调用完成后的this.state',this.state.num)
    });

    console.log('我用于检验异步,此时拿不到最新的this.state',this.state.num);//1
  }

  render() {
    console.log('用于检验render了几次');
    return (
      <>
        <div>{this.state.num}</div>
        <button onClick={this.handleOnClick}>加1</button>
      </>
    )
  }
}

请问每次setState时的statethis.state是多少,更新完成后最终的this.state是多少?render会执行几次呢?先思考下,答案如下:

最终this.state是3,且每次setState中拿到的state(注意不是this.state)都是我们预期修改后的,而且根据调用顺序来看,虽然确实执行了多次setState,但最终对于this.state的修改只有一次,且render只执行了一次,这种情况下react依旧做了批量合并处理。

贰 ❀ 叁 批量合并是只执行最后一次setState吗?

在上述例子中,我们在setState执行了多次this.state.num+1的操作,但最后this.state.num是2,那么请问,所谓批量合并,是只执行了其中某一次的setState吗?执行的是第一次还是最后一次?其实看个例子就懂了:

class Echo extends React.Component {
  state = {
    a:false,
    b:false
  }

  componentDidUpdate() {
    console.log(this.state);
  }

  handleOnClick = () => {
    this.setState({
      a:true
    }, () => {
      console.log(this.state)
    });

    this.setState({
      b: true
    }, () => {
      console.log(this.state)
    });

    this.setState({
      a: false
    }, () => {
      console.log(this.state)
    });
  }

  render() {
    return (
      <>
        <button onClick={this.handleOnClick}>click me</button>
      </>
    )
  }
}

事实证明,setState在批量合并过程中还是会是执行每个setState,但在updater是对象的情况下,setState对于相同key的操作始终以最后一次修改为准:

// 执行了三次了加1,但最终其实只会加一次
this.setState({num:this.state.num + 1});
this.setState({num:this.state.num + 1});
this.setState({num:this.state.num + 1});

比如上述代码执行了三次+1操作,等待渲染结束后,我们会发现结果num其实只加了一个1,它等同于:

const num = this.state.num;
this.setState({num:num + 1});
this.setState({num:num + 1});
this.setState({num:num + 1});

这里的const num = this.state.num; 就相当于是一个快照,setState确实执行了三次,只是设置的一直都是相同的值,导致最终this.state的值确确实实是以最后一次为准。

贰 ❀ 叁 什么情况下setState是同步?

其实要回到这个问题,我们只需要知道什么情况下setState是异步,那么反过来的情况自然就都是同步了。一般来说,react在事件处理函数内部的 setState 都是异步的,比如合成事件onClickonBlur,其次react提供的生命周期钩子函数中也是异步。

那么是不是说只要setState不在合成事件内调用,我们就能实现同步更新了呢?来看个例子:

class Echo extends React.Component {
  state = {
    num:1
  }

  componentDidUpdate() {
    console.log(this.state.num);//2 3 4
  }

  handleOnClick = () => {
    setTimeout(()=>{
      this.setState({num:this.state.num+1});
      this.setState({num:this.state.num+1});
      this.setState({num:this.state.num+1});
      console.log(this.state.num);//4
    })
  }

  render() {
    console.log('我在render了');// 执行3次
    return (
      <>
        <button onClick={this.handleOnClick}>click me</button>
      </>
    )
  }
}

事实上,超出了react能力范畴之外的上下文,比如原生事件,定时器回调等等,在这里面进行setState操作都会同步更新state。比如在上述例子中,我们实现了在setState后获取到同步更新的this.state,但遗憾的是,react此时并不能做到批量合并操作,导致render执行了三次。

贰 ❀ 肆 为什么一定要设计成异步,同步批量处理不行吗?

其实在众多react使用者中一直有一个这样的疑问,虽然我们知道异步本质目的是为了异步累积处理setState,达到减少render提升性能的目的。那么问题来了,异步能做到批量处理,同步难道就不行吗?我们让state同步更新,只要在最终render时做好把控,不是一样能达到这样的效果,而且从代码可读上来说,同步更利于状态维护。对此,官方给出了合理解释,大致分为三点:

  • 保证内部的一致性

    即便setState能做到同步,react对于props的更新依旧是异步,这是因为对于一个子组件而言,它只有等到父组件重新渲染了,它才知道最新的props是多少,所以让setState异步的另一个原因是为了让state,props,refs更新的行为与表现保持一致。我们假设有下面这段代码,它是同步执行:

    console.log(this.state.value);//0
    this.setState({value:this.state.value+1});
    console.log(this.state.value);//1
    this.setState({value:this.state.value+1});
    console.log(this.state.value);//2
    

    但现在我们有个场景,这个状态需要被多个兄弟组件使用,因此我们需要将其状态提升到父组件,以便于给多个兄弟组件共享:

    console.log(this.props.value);//0
    this.props.onIncrement();
    console.log(this.props.value);//0
    this.props.onIncrement();
    console.log(this.props.value);//0
    

    很遗憾上述代码并不能按照我们预期的执行,因为在同步模型中,this.state会立刻更新,但是this.props并不会,而且在没有重新渲染父组件的情况下,我们没办法立刻更新this.props,那要假设要做到每执行一次onIncrement能让兄弟组件都拿到最新的props,唯一的办法就是立刻重新渲染父组件,而这种场景下,已经与我们最初的批量合并处理减少重复渲染相违背了。

    而为了解决这个问题,reactthis.propsthis.state更新设计为异步,这也让状态提升时对于状态的管理更合理与更安全。

  • 性能优化

    如果setState是同步的话,那么对于状态的改变一定会按照setState调用顺序来执行并改变,但事实上react会根据setState不同的调用源,为这些setState分配不同的优先级,调用源包含事件处理,网络请求,动画等等。

    官方给了一个这样的例子,比如我们在一个聊天窗口聊天,输入的信息变化会触发setState,而此时我们搜到了一条新消息,新消息也会触发setState,那么这里更好的做法是延迟新消息的setState的执行,降低其优先级,这样就能避免输入过程中因为新消息触发的渲染,导致输入过程中抖动以及延迟。如果给某些更新分配更低的优先级,那么就可以把它们拆分成几毫秒的渲染块,这样用户也不会察觉到。

  • 异步创造更多可能性

    异步除了性能优化之外,异步也为未来的react升级埋下更多可能性。比如我们有个需要,需要从页面A导航到页面B,那么这时候你可能需要做一个加载动画,等待B页面渲染。但如果导航切换特别快,闪烁一下加载动画又会降低用户体验。

    而站在异步的基础上,当我们调用setState去渲染一个新页面,因为异步的缘故,react可以在后台渲染这个新页面,而且不去阻塞旧页面的交互,假设等待时间过长,我们还是可以展示loading,但如果等待耗时非常短暂,setState可以因为异步批量合并的缘故减少渲染,不会让页面频繁闪动,从而提升用户体验。

对于问题的原回答,可阅读此issues:RFClarification: why is setState asynchronous?那么到这里,我们站在react官方的角度解释了为什么react中的setState是异步而不能是同步。

叁 ❀ 总

我们花了较大的篇幅解释了好几个setState相关非常意思的问题,但其实我们还剩余一个问题没解释,那就是像合成事件中的setState会异步执行批量合并操作,而像原生定时器中的setState却不会如此。那么react如何区分这两者情况,或者说react在合成事件的底层到底做了什么?

考虑到篇幅的问题,这个问题我打算放在与setState异步紧急相连的合成事件篇章去解释,也便于大家对于本篇知识点的快捷梳理与消化。请回到文章开头再次面对最初的那几个答案,那么现在你心中是否有了自己的答案?

文章最后附上非常经典的setState点三次的问题,代码如下:

class Echo extends React.Component{
  state = {
    count: 0
  }

	// count +1
  increment = () => {
    console.log('increment setState前的count', this.state.count)
    this.setState({
      count: this.state.count + 1
    });
    console.log('increment setState后的count', this.state.count)
  }
	
  // count +1 三次
  triple = () => {
    console.log('triple setState前的count', this.state.count)
    this.setState({
      count: this.state.count + 1
    });

    this.setState({
      count: this.state.count + 1
    });

    this.setState({
      count: this.state.count + 1
    });
    console.log('triple setState后的count', this.state.count)
  }
	// count - 1
  reduce = () => {
    setTimeout(() => {
      console.log('reduce setState前的count', this.state.count)
      this.setState({
        count: this.state.count - 1
      });
      console.log('reduce setState后的count', this.state.count)
    }, 0);
  }

  render(){
    return <div>
      <button onClick={this.increment}> +1 </button>
      <button onClick={this.triple}> +1 三次 </button>
      <button onClick={this.reduce}> -1 </button>
    </div>
  }
}

大家可以自行思考,如果此时你已经能轻松回答,那么你对于setState同步异步问题已经有了一个清晰的认知了。剩余的问题,我们在下一篇合成事件再详细阐述,那么到这里本文结束。

参考

React setState 异步真的只是为了性能吗?

React setState 同步异步背后的故事

react官网setState实际做了什么?

setState是同步的还是异步的?

React 中setState更新state何时同步何时异步?

React 中 setState() 为什么是异步的

posted on 2021-11-06 22:27  听风是风  阅读(859)  评论(2编辑  收藏  举报