2602 字
13 分钟
React 中 setState 的同步与异步
2021-10-10

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会执行几次呢?先思考下,答案如下:

alt text

最终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>
      </>
    )
  }
}

alt text

事实证明,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>
      </>
    )
  }
}

alt text

事实上,超出了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更新设计为异步,这也让状态提升时对于状态的管理更合理与更安全。

alt text

  • 性能优化

如果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是异步而不能是同步。

React 中 setState 的同步与异步
https://alexdev.top/posts/set-state/post/
作者
凡百一新
发布于
2021-10-10
许可协议
CC BY-NC-SA 4.0