react中setState()是异步的还是同步的,如何控制?

538 阅读10分钟

react中setState()是异步的还是同步的,如何控制?

之前一直在做vue开发,可能对vue了解的多一些,但是近期想开始整明白react背后的事情。 仅仅适用于react18版本之前

this.state.count += 1 不会触发数据更新

  handleClickDemo = () => {
    this.state.count += 1 // 该代码仅在作用域范围之内有效 不会触发页面更新渲染
    console.log(this.state.count) // 第1次点击 结果为1 // 第1次点击 结果为3
    this.setState({ count: this.state.count + 1 }) // setState才会更新数据 触发页面渲染
    this.state.count += 5  // 该行不会改变state中数据
  }

setState()的使用说明

 setState(updater, [callback])

一、参数1 updater

功能:更新数据,渲染UI

1.1、更新数据

注意:使用该语法时,注意:【后面的setState()不要依赖于前面的setState()更新后的数据】

可能(异步情况下,什么时候为异步/同步,后面会有介绍)多次调用setState(),但只会触发一次重新渲染

handleClick = () => {

  console.log("count", this.state.count) //0

  this.setState({ count: this.state.count + 1 })
  console.log("count", this.state.count) //0

  this.setState({ count: this.state.count + 1 })
  console.log("count", this.state.count) //0

  this.setState({ count: this.state.count + 1 })
  console.log("count", this.state.count) //0

  // 页面渲染结果为: 1
}

1.2、推荐语法【如果有两个setState()方法,第二个setState()里面的数据依赖第1个setState()更新后的数据】

推荐:使用setState((state, props) => {})

参数state:表示最新的state

参数props: 表示最新的props

注意:这种语法可能是异步更新state的,但是都能获取到最新数据

handleClickFn = () => {

  console.log("count", this.state.count) //0
  this.setState((preState, props) => {
    return {
      // 0 + 1
      count: preState.count + 1
    }
  })

  console.log("count", this.state.count) //0
  this.setState((preState, props) => {
    return {
      // 1 + 1
      count: preState.count + 1
    }
  })

  console.log("count", this.state.count) //0
  this.setState((preState, props) => {
    return {
      // 2 + 1
      count: preState.count + 1
    }
  })
  console.log("count", this.state.count) //0

  // 页面渲染结果为:3
}

二、参数2 callback

场景:在状态更新后并且页面完成重新渲染后立即执行某个操作

this.setState(
    (state, props) => { },
    () => {
        console.log('这个回调函数会在状态更新后并且页面完成重新渲染后立即执行')
        console.log('这里可以打印更改this.state的值', this.state.count)
    }
)

handleClick = () => {

  console.log("count", this.state.count) //0

  this.setState(
    { count: this.state.count + 1 },
    () => {
      console.log('这个回调函数会在状态更新后并且页面完成重新渲染后立即执行')
      console.log('1这里可以打印更改this.state的值', this.state.count) //1
    })
  console.log("count", this.state.count) //0

  this.setState(
    { count: this.state.count + 1 },
    () => {
      console.log('这个回调函数会在状态更新后并且页面完成重新渲染后立即执行')
      console.log('2这里可以打印更改this.state的值', this.state.count) //1
    })
  console.log("count", this.state.count) //0

  this.setState(
    { count: this.state.count + 1 },
    () => {
      console.log('这个回调函数会在状态更新后并且页面完成重新渲染后立即执行')
      console.log('3这里可以打印更改this.state的值', this.state.count) //1
    })

  console.log("count", this.state.count) //0

  // 页面渲染结果为: 1
}

handleClickFn = () => {

  console.log("count", this.state.count) //0
  this.setState((preState, props) => {
    return {
      // 0 + 1
      count: preState.count + 1
    }
  }, () => {
    console.log('这个回调函数会在状态更新后并且页面完成重新渲染后立即执行')
    console.log('1这里可以打印更改this.state的值', this.state.count) //3
  })

  console.log("count", this.state.count) //0
  this.setState((preState, props) => {
    return {
      // 1 + 1
      count: preState.count + 1
    }
  }, () => {
    console.log('这个回调函数会在状态更新后并且页面完成重新渲染后立即执行')
    console.log('2这里可以打印更改this.state的值', this.state.count) //3
  })

  console.log("count", this.state.count) //0
  this.setState((preState, props) => {
    return {
      // 2 + 1
      count: preState.count + 1
    }
  }, () => {
    console.log('这个回调函数会在状态更新后并且页面完成重新渲染后立即执行')
    console.log('3这里可以打印更改this.state的值', this.state.count) //3
  })
  console.log("count", this.state.count) //0

  // 页面渲染结果为:3
}

react开发对setState的使用可能一点也不陌生,但肯定会碰到过这种情况

import React, { Component } from 'react'

export default class BatchedDemo extends Component {
  state = {
    number: 0,
  }

  handleClick = () => {
    this.countNumber()
  }

  countNumber() {

    this.setState({
      number: this.state.number + 1
    })

    this.setState({
      number: this.state.number + 1
    })

    this.setState({
      number: this.state.number + 1
    })
    
   this.setState({
      number: this.state.number + 1
    })
  }

  render() {
    return <button onClick={this.handleClick}>Num: {this.state.number}</button>
  }

}

问题

点击button按钮后,发现只加了1,why?

num.png

这就涉及到了setState的更新策略

setState批量更新

  • 除了virtual-dom的优化减少数据更新的频率是另外一种手段,也就是React的批量更新
  • 顾名思义,批量更新,可以避免短期内的多次渲染,攒为一次性更新。

setState()合并策略:

我对攒为一次性更新的理解是是覆盖而不是叠加(类似于Object.assign())

证明如下: 把countNumber函数改为如下,如果是覆盖,那么只会执行 number: this.state.number + 5,相当于把前面同类都覆盖了

  countNumber() {
    this.setState({
      number: this.state.number + 11
    })

    this.setState({
      number: this.state.number + 20
    })

    this.setState({
      number: this.state.number + 5,
    })
  }

点击按钮后

num2.png

所以如下操作

this.setState({
    age: 18
})
this.setState({
    color: 'black‘
})
this.setState({
    age: 20
})
this.setState({
     name: 'yank'
})

会被React合成为一次setState调用

this.setState({
        name: 'yank',
        age: 20, 
        color: 'black'
})

而我们要搞清楚的就是setState()到底是如何去合并的,我能自由控制它的合并吗?

setState()合并原理:

通过伪代码更好的去理解setState()是如何去合并的 setState实现

  setState(newState) {
    if (this.canMerge) {
      this.updateQueue.push(newState)
      return
    }

    // 下面是真正的更新: dom-diff, lifeCycle...
    ...
  }

然后countNumber()方法调用之后,把隐式操作通过伪代码显示出来:

  countNumber() {
    this.canMerge = true

    this.setState({
      number: this.state.number + 11
    })

    this.setState({
      number: this.state.number + 20
    })

    this.setState({
      number: this.state.number + 5,
    })

    this.canMerge = false

    // 通过this.updateQueue合并出finalState
    const finalState = ...
    // 此时canMerge 已经为false 故而走入时机更新逻辑
    this.setState(finaleState)
  }

可以看出 setState首先会判断是否可以合并,如果可以合并this.canMerge = true,就直接返回了。直到this.canMerge = false时,代表finalState已经合并完成,就开始走更新

不过有人会问:在使用React的时候,我并没有设置this.canMerge呀?我们的确没有,是React隐式的帮我们设置了!事件处理函数,生命明周期,这些函数的执行是发生在React内部的,React对它们有完全的控制权。

canMerge逻辑存在于哪里?

除了事件处理函数会执行canMerge逻辑,在执行componentDidMount前后也会有canMerge逻辑可以理解为:React委托代理了所有的事件,在执行你的函数componentDidMount之前,会执行React逻辑,这样React也是有时机执行canMerge逻辑的。

如何控制canMerge逻辑

批量更新是极好滴!我们当然希望任何setState都可以被批量,关键点在于React是否有时机执行canMerge逻辑,也就是React对目标函数有没有控制权。如果没有控制权,那么就不会执行canMerge逻辑,也就不会发生setState()被react隐式合并了

通过setTimeout脱离react的控制

import React, { Component } from 'react'

export default class BatchedDemo extends Component {
  state = {
    number: 0,
  }

  handleClick = () => {

    this.setState({
      number: this.state.number + 1
    })
    this.setState({
      number: this.state.number + 2
    })
    this.setState({
      number: this.state.number + 3
    })

    setTimeout(() => {
      this.setState({
        number: this.state.number + 4
      })
      this.setState({
        number: this.state.number + 5
      })
      this.setState({
        number: this.state.number + 6
      })
    })

  }
  render() {
    return <button id="myButton" onClick={this.handleClick}>Num:
      {this.state.number}
    </button>
  }
}

分析上述代码:

handleClick 是事件回调,React有时机执行canMerge逻辑,所以x为+1,+2,+3是合并的,handleClick结束之后canMerge被重新设置为false。注意这里有一个setTimeout(fn, 0)。 这个fn会在handleClick之后调用,而React对setTimeout并没有控制权,React无法在setTimeout前后执行canMerge逻辑所以x为4,5,6是无法合并的,所以fn这里会存在3次dom-diff。React没有控制权的情况有很多: Promise.then(fn), fetch回调,xhr网络回调等等

所以点击按钮: 3+4+5+6=18

num3.png

如何在setTimeout中让react拿回控制权

以上代码的setTimeout中,我想让react去拿回控制权,合并代码,怎么办呢? 需要用unstable_batchedUpdates这个API 代码如下:

import React, { Component } from 'react'
import { unstable_batchedUpdates as batchedUpdates } from 'react-dom'

export default class BatchedDemo extends Component {
  state = {
    number: 0,
  }

  handleClick = () => {

    this.setState({
      number: this.state.number + 1
    })
    this.setState({
      number: this.state.number + 2
    })
    this.setState({
      number: this.state.number + 3
    })

    setTimeout(() => {
      //通过这个api,让react拿回控制权,执行canMerge逻辑
      batchedUpdates(() => {
        this.setState({
          number: this.state.number + 4
        })
        this.setState({
          number: this.state.number + 5
        })
        this.setState({
          number: this.state.number + 6
        })
      })
    })

  }
  render() {
    return <button onClick={this.handleClick}>
      Num:{this.state.number}
    </button>
  }
}

打印如下:3+6=9

num4.png

最后看一下unstable_batchedUpdates这个api的伪代码:

function unstable_batchedUpdates(fn) {
    this.canMerge = true

    fn()

    this.canMerge = false
    const finalState = ...  //通过this.updateQueue合并出finalState
    this.setState(finaleState)
}

总结:

异步的情况:

由React控制的事件处理函数,以及生命周期函数调用setState时表现为异步 。大部分开发中用到的都是React封装的事件,比如onChange、onClick、onTouchMove等(合成事件中),这些事件处理函数中的setState都是异步处理的。

同步的情况:

React控制之外的事件中调用setState是同步更新的。比如原生js绑定的事件,setTimeout/setInterval,ajax,promise.then内 React 无法掌控的 APIs情况下,setState是同步更新state的。

setState一定是异步吗?

其实分成两种情况:

  • 在组件生命周期或React合成事件中,setState是异步;
  • 在setTimeout或者原生dom事件中,setState是同步;

问题回答

通过前面的介绍,我们已经知道:setState什么时候是同步的,什么时候是异步的。

在handleClick方法内调四次setState,为什么 每次点击不是自增4,页面渲染却是1,打印结果却是0呢?

import React, { Component } from 'react'

export default class StateDemo extends Component {
  constructor(props) {
    super(props)
    this.state = { count: 0 }
  }

  handleClick = () => {
    this.setState({ count: this.state.count + 1 })
    console.log('第一次handleClick-count', this.state.count)  // 0
    this.setState({ count: this.state.count + 1 })
    console.log('第二次handleClick-count', this.state.count)  // 0
    this.setState({ count: this.state.count + 1 })
    console.log('第三次handleClick-count', this.state.count)  // 0
    this.setState({ count: this.state.count + 1 })
    console.log('第四次handleClick-count', this.state.count)  // 0
  }

  render() {
    return (
      <>
        Count: {this.state.count}
        <button onClick={this.handleClick}>+</button>
      </>
    )
  }
}

React setState 函数实现中,会根据一个变量 isBatchingUpdates 判断直接更新 this.state 还是放到一个updateQueue中延时更新,而 isBatchingUpdates 默认是 false,表示 setState 会同步更新 this.state;但是,有一个函数 batchedUpdates,该函数会把 isBatchingUpdates 修改为 true。

因为调用的handleClick事件属于react的合成事件,更新状态由React控制,而react内部为了优化setState()的批处理,会对setState()进行合并,React在调用事件处理函数之前就会先调用这个 batchedUpdatesisBatchingUpdates修改为true,这样由 React 控制事件处理过程的 setState 不会同步更新 this.state,而是放到一个updateQueue中延时更新,打印最后一条语句时候还未执行批量更新。所以结果还是打印0,打印语句之后就将isBatchingUpdates修改为false,执行updateQueue任务队列,合并状态:

// var target = { name: 'guxin', age: 18 };
// var source = { state: 'single' }
// Object.assign(target, source, { state: '11' }); //可以看到如果有同名属性的话,后面的属性值会覆盖前面的属性值。
// console.log(target) //{ name: 'guxin', age: 18, state: '11' }

//在React合并多次修改为一次的情况下,相当于等价执行了下面的操作:
Object.assign(
  preState,
  {count: this.state.count + 1},
  {count: this.state.count + 1},
  {count: this.state.count + 1},
  {count: this.state.count + 1}
)
// 于是,最终只会增加一次操作的值。

this.setState(previousState) 更新状态,触发组件重新渲染,更新视图 UI,所以页面结果为1。

如何让页面渲染结果为4呢?

// 采用这种方式更新数据
setState((state, props) => {})

代码如下

  handleClickFn = () => {
    this.setState(prevState => {
      return { count: prevState.count + 1 }
    })
    console.log('第一次handleClickFn-count', this.state.count) //0
    this.setState(prevState => {
      return { count: prevState.count + 1 }
    })
    console.log('第二次handleClickFn-count', this.state.count) //0

    this.setState(prevState => {
      return { count: prevState.count + 1 }
    })
    console.log('第三次handleClickFn-count', this.state.count) //0
    this.setState(prevState => {
      return { count: prevState.count + 1 }
    })
    console.log('第四次handleClickFn-count', this.state.count) //0
  }

笔试题

  componentDidMount() {
    // a
    this.setState({ count: this.state.count + 1 });
    console.log('1:' + this.state.count) //0 ok 
      
    // b
    this.setState({ count: this.state.count + 1 });
    console.log('2:' + this.state.count) //0 ok 

    setTimeout(() => {
      // c
      this.setState({ count: this.state.count + 1 });
      console.log('3:' + this.state.count) // ? ok
    }, 0)

    // d
    this.setState(preState => ({ count: preState.count + 1 }), () => {
      console.log('4:' + this.state.count) // ? ok
    })

    console.log('5:' + this.state.count) //0 ok 
      
    // e
    this.setState(preState => ({ count: preState.count + 1 }))
    console.log('6:' + this.state.count) // 0 ok 
  }

打印结果

result.png 思考一下,你的答案是什么???

你的答案是否正确?你又是否理解为什么会出现上面的答案?接下来我们就来仔细分析一下。

setState

在上面的代码中,【a,b,c】的 setState 的第一个参数都是一个对象,【d, e】的 setState 的第一个参数都是函数。

1.首先,我们先说说执行顺序的问题。 【1,2,5,6】最先打印,【4】在中间,最后打印【3】。因为【1,2,5,6】是同步任务,【4】是回调,相当于 NextTick 微任务,会在同步任务之后执行,最后的【3】是宏任务,最后执行。

2.接下来说说打印的值的问题。 在【1,2,5,6】下面打印的 state 都是0,说明这里是异步的,没有获取到即时更新的值;

3.在【4】里面为什么打印出3呢?

首先在【a,b】两次 setState 时,都是直接获取的 this.state.count 的值,我们要明白,这里的这个值有异步的性质,异步就意味着这里不会拿到能即时更新的值,那每次 setState 时,拿到的 this.state.count 都是0。

在【d,e】两个 setState 时,它的参数是函数,这个函数接收的第一个参数 preState (旧的 state ),在这里是同步的,虽有能拿到即时更新的值,那么经过了【a,b】两次 setState (这里类似于被合并),这里即时的 count 还是1。因为上面我们说过的执行顺序的关系,再经过【d,e】两次 setState ,所以 count 变成了3。

那么在【3】中打印出4又是为什么?你不是说了在 this.state.count 中拿到的值是“异步”的吗,不是应该拿到0吗,怎么会打印出4呢?

method() {
    isBatchingUpdate = true;
    // 你需要执行的一些代码
    // ...
    isBatchingUpdate = false
}

那么在上面的那个面试题中,在 setTimeout 执行的时候 isBatchingUpdate 是 false ,没有命中 batchUpdate 机制,所有同步更新,这里的 this.state.count 已经是 3 了,所有在【3】中打印的就是 4。

componentDidMount(){
  isBatchingUpdate = true
    
  setTimeout(() => {
    // c
    // 由于执行顺序的原因,在这里 isBatchingUpdate 已经是 false 了,所以同步更新
    this.setState({ count: this.state.count + 1 });
    console.log('3:' + this.state.count)
  }, 0)
    
  isBatchingUpdate = false
}