一、异步动机和原理-批量更新

先来看一段代码:

  1. import React from "react";
  2. export default class App extends React.Component {
  3. state = {
  4. count: 0,
  5. };
  6. firstIncrement = () => {
  7. console.log("增加1前", this.state.count);
  8. this.setState({
  9. count: this.state.count + 1,
  10. });
  11. console.log("增加1后", this.state.count);
  12. };
  13. secondIncrement = () => {
  14. console.log("增加2前", this.state.count);
  15. this.setState({
  16. count: this.state.count + 1,
  17. });
  18. this.setState({
  19. count: this.state.count + 1,
  20. });
  21. console.log("增加2后", this.state.count);
  22. };
  23. reduce = () => {
  24. setTimeout(() => {
  25. console.log("减少1前", this.state.count);
  26. this.setState({
  27. count: this.state.count - 1,
  28. });
  29. console.log("减少1后", this.state.count);
  30. }, 0);
  31. };
  32. render() {
  33. return (
  34. <div>
  35. <button onClick={this.firstIncrement}>按钮1-增加1</button>
  36. <button onClick={this.secondIncrement}>按钮2-增加2</button>
  37. <button onClick={this.reduce}>减少1</button>
  38. </div>
  39. );
  40. }
  41. }

依次点击操作结果:
image.png
从上图结果可以看出,我们直接操作setState打印结果时,是异步的,拿不到最新的值。进行多次setState时结果会被合并只执行一次。放在setTimeout中会变成同步可以立即获取到值。

站在生命周期角度看setState更新。
image.png

从图上我们可以看出,一个完整的更新流程,涉及了包括 re-render(重渲染) 在内的多个步骤。re-render 本身涉及对 DOM 的操作,它会带来较大的性能开销。假如说“一次 setState 就触发一个完整的更新流程”这个结论成立,那么每一次 setState 的调用都会触发一次 re-render,我们的视图很可能没刷新几次就卡死了。

  1. this.setState({
  2. count: this.state.count + 1 ===> shouldComponentUpdate->componentWillUpdate->render->componentDidUpdate
  3. });
  4. this.setState({
  5. count: this.state.count + 1 ===> shouldComponentUpdate->componentWillUpdate->render->componentDidUpdate
  6. });
  7. this.setState({
  8. count: this.state.count + 1 ===> shouldComponentUpdate->componentWillUpdate->render->componentDidUpdate
  9. });

所以setState异步的一个重要的动机——避免频繁的 re-render。

所以React在实现setState异步采用批量更新操作,使用一个队列把他存起来,每次进来一个setState,就进行入队操作,等时机成熟,把state的值做合并,最后只针对最新的 state 值走一次更新流程。

  1. this.setState({
  2. count: this.state.count + 1 ===> 入队,[count+1的任务]
  3. });
  4. this.setState({
  5. count: this.state.count + 1 ===> 入队,[count+1的任务,count+1的任务]
  6. });
  7. this.setState({
  8. count: this.state.count + 1 ===> 入队, [count+1的任务,count+1的任务, count+1的任务]
  9. });
  10. 合并 state,[count+1的任务]
  11. 执行 count+1的任务

注意:只要我们的同步代码在执行,入队这个操作就不会停止,所以最终只有一次+1生效。

  1. test = () => {
  2. console.log('循环100次 setState前的count', this.state.count)
  3. for(let i = 0; i < 100 ; i++) {
  4. this.setState({
  5. count: this.state.count + 1
  6. })
  7. }
  8. console.log('循环100次 setState后的count', this.state.count)
  9. }

image.png

setState更新的流程图如下:

image.png

render 阶段 render 函数执行 -> commit 阶段真实 DOM 替换 -> setState 回调函数执行 callback 。

如何提升更新优先级?

React-dom 提供了 flushSync

  1. ReactDOM.flushSync(()=>{
  2. this.setState({ number: 3 })
  3. })

二、“同步现象”背后的故事:从源码角度看 setState 工作流

setState 的同步现象:

  1. reduce = () => {
  2. setTimeout(() => {
  3. console.log('reduce setState前的count', this.state.count)
  4. this.setState({
  5. count: this.state.count - 1
  6. });
  7. console.log('reduce setState后的count', this.state.count)
  8. },0);
  9. }

从代码上看,setState 似乎是在 setTimeout 函数的“保护”之下,才有了同步这一“特异功能”。

我们不禁发出这样一个疑问:为什么 setTimeout 可以将 setState 的执行顺序从异步变为同步?事实上并不是 setTimeout 改变了 setState,而是 setTimeout 帮助 setState “逃脱”了 React 对它的管控。只要是在 React 管控下的 setState,一定是异步的

1. setState 工作流

image.png

1、setState 入口函数,入口函数在这里就是充当一个分发器的角色,根据入参的不同,将其分发到不同的功能函数中去。

  1. ReactComponent.prototype.setState = function (partialState, callback) {
  2. this.updater.enqueueSetState(this, partialState);
  3. if (callback) {
  4. this.updater.enqueueCallback(this, callback, 'setState');
  5. }
  6. };

2、enqueueSetState 做了两件事:

  • 将新的 state 放进组件的状态队列里;
  • 用 enqueueUpdate 来处理将要更新的实例对象。
    1. enqueueSetState: function (publicInstance, partialState) {
    2. // 根据 this 拿到对应的组件实例
    3. var internalInstance = getInternalInstanceReadyForUpdate(publicInstance, 'setState');
    4. // 这个 queue 对应的就是一个组件实例的 state 数组
    5. var queue = internalInstance._pendingStateQueue || (internalInstance._pendingStateQueue = []);
    6. queue.push(partialState);
    7. // enqueueUpdate 用来处理当前的组件实例
    8. enqueueUpdate(internalInstance);
    9. }

3、enqueueUpdate

  1. function enqueueUpdate(component) {
  2. ensureInjected();
  3. // 注意这一句是问题的关键,isBatchingUpdates标识着当前是否处于批量创建/更新组件的阶段
  4. if (!batchingStrategy.isBatchingUpdates) {
  5. // 若当前没有处于批量创建/更新组件的阶段,则立即更新组件
  6. batchingStrategy.batchedUpdates(enqueueUpdate, component);
  7. return;
  8. }
  9. // 否则,先把组件塞入 dirtyComponents 队列里,让它“再等等”
  10. dirtyComponents.push(component);
  11. if (component._updateBatchNumber == null) {
  12. component._updateBatchNumber = updateBatchNumber + 1;
  13. }
  14. }

4、batchingStrategy 是 React 内部专门用于管控批量更新的对象。batchingStrategy 对象并不复杂,你可以理解为它是一个“锁管理器”。

这里的“锁”,是指 React 全局唯一的 isBatchingUpdates 变量,isBatchingUpdates 的初始值是 false,意味着“当前并未进行任何批量更新操作”。每当 React 调用 batchedUpdate 去执行更新动作时,会先把这个锁给“锁上”(置为 true),表明“现在正处于批量更新过程中”。当锁被“锁上”的时候,任何需要更新的组件都只能暂时进入 dirtyComponents 里排队等候下一次的批量更新,而不能随意“插队”。此处体现的“任务锁”的思想,是 React 面对大量状态仍然能够实现有序分批处理的基石。

  1. /**
  2. * batchingStrategy源码
  3. **/
  4. var ReactDefaultBatchingStrategy = {
  5. // 全局唯一的锁标识
  6. isBatchingUpdates: false,
  7. // 发起更新动作的方法
  8. batchedUpdates: function(callback, a, b, c, d, e) {
  9. // 缓存锁变量
  10. var alreadyBatchingStrategy = ReactDefaultBatchingStrategy. isBatchingUpdates
  11. // 把锁“锁上”
  12. ReactDefaultBatchingStrategy. isBatchingUpdates = true
  13. if (alreadyBatchingStrategy) {
  14. callback(a, b, c, d, e)
  15. } else {
  16. // 启动事务,将 callback 放进事务里执行
  17. transaction.perform(callback, null, a, b, c, d, e)
  18. }
  19. }
  20. }

5、React 中的 Transaction(事务)机制。Transaction 就像是一个“壳子”,它首先会将目标函数用 wrapper(一组 initialize 及 close 方法称为一个 wrapper) 封装起来,同时需要使用 Transaction 类暴露的 perform 方法去执行它。

  1. transaction.perform(callback, null, a, b, c, d, e)

Transaction流程如下:

  1. * <pre>
  2. * wrappers (injected at creation time)
  3. * + +
  4. * | |
  5. * +-----------------|--------|--------------+
  6. * | v | |
  7. * | +---------------+ | |
  8. * | +--| wrapper1 |---|----+ |
  9. * | | +---------------+ v | |
  10. * | | +-------------+ | |
  11. * | | +----| wrapper2 |--------+ |
  12. * | | | +-------------+ | | |
  13. * | | | | | |
  14. * | v v v v | wrapper
  15. * | +---+ +---+ +---------+ +---+ +---+ | invariants
  16. * perform(anyMethod) | | | | | | | | | | | | maintained
  17. * +----------------->|-|---|-|---|-->|anyMethod|---|---|-|---|-|-------->
  18. * | | | | | | | | | | | |
  19. * | | | | | | | | | | | |
  20. * | | | | | | | | | | | |
  21. * | +---+ +---+ +---------+ +---+ +---+ |
  22. * | initialize close |
  23. * +-----------------------------------------+
  24. * </pre>

上图代码注释简单翻译下就为:

  1. // 使用一个盒子包住操作
  2. function xx(){
  3. isBatchUpdate = true
  4. this.setState({)}
  5. isBatchUpdate = false
  6. }

2. “同步现象”的本质

1、ReactDefaultBatchingStrategy 其实就是一个批量更新策略事务,它的 wrapper 有两个:FLUSH_BATCHED_UPDATES 和 RESET_BATCHED_UPDATES。

  1. var RESET_BATCHED_UPDATES = {
  2. initialize: emptyFunction,
  3. close: function () {
  4. ReactDefaultBatchingStrategy.isBatchingUpdates = false;
  5. }
  6. };
  7. var FLUSH_BATCHED_UPDATES = {
  8. initialize: emptyFunction,
  9. close: ReactUpdates.flushBatchedUpdates.bind(ReactUpdates)
  10. };
  11. var TRANSACTION_WRAPPERS = [FLUSH_BATCHED_UPDATES, RESET_BATCHED_UPDATES];

2、把这两个 wrapper 套进 Transaction 的执行机制里,不难得出一个这样的流程:

image.png
来实现这个流程。

3、但是到目前为止 setState 为何会表现同步这个问题还是没有解决。这是因为 batchedUpdates 这个方法,不仅仅会在 setState 之后才被调用。若我们在 React 源码中全局搜索 batchedUpdates,会发现调用它的地方很多,但与更新流有关的只有这两个地方:

  1. // ReactMount.js
  2. _renderNewRootComponent: function( nextElement, container, shouldReuseMarkup, context ) {
  3. // 实例化组件
  4. var componentInstance = instantiateReactComponent(nextElement);
  5. // 初始渲染直接调用 batchedUpdates 进行同步渲染
  6. ReactUpdates.batchedUpdates(
  7. batchedMountComponentIntoNode,
  8. componentInstance,
  9. container,
  10. shouldReuseMarkup,
  11. context
  12. );
  13. ...
  14. }

4、当我们在组件上绑定了事件之后,事件中也有可能会触发 setState。为了确保每一次 setState 都有效,React 同样会在此处手动开启批量更新。

  1. // ReactEventListener.js
  2. dispatchEvent: function (topLevelType, nativeEvent) {
  3. ...
  4. try {
  5. // 处理事件
  6. ReactUpdates.batchedUpdates(handleTopLevelImpl, bookKeeping);
  7. } finally {
  8. TopLevelCallbackBookKeeping.release(bookKeeping);
  9. }
  10. }

5、isBatchingUpdates 这个变量,在 React 的生命周期函数以及合成事件执行前,已经被 React 悄悄修改为了 true,这时我们所做的 setState 操作自然不会立即生效。当函数执行完毕后,事务的 close 方法会再把 isBatchingUpdates 改为 false。

6、示例代码:
在 isBatchingUpdates 的约束下,setState 只能是异步的。

  1. increment = () => {
  2. // 进来先锁上
  3. isBatchingUpdates = true
  4. console.log('increment setState前的count', this.state.count)
  5. this.setState({
  6. count: this.state.count + 1
  7. });
  8. console.log('increment setState后的count', this.state.count)
  9. // 执行完函数再放开
  10. isBatchingUpdates = false
  11. }

7、setTimeout 从中作祟时,事情就会发生一点点变化: isBatchingUpdates,对 setTimeout 内部的执行逻辑完全没有约束力。

  1. reduce = () => {
  2. // 进来先锁上
  3. isBatchingUpdates = true
  4. setTimeout(() => {
  5. console.log('reduce setState前的count', this.state.count)
  6. this.setState({
  7. count: this.state.count - 1
  8. });
  9. console.log('reduce setState后的count', this.state.count)
  10. },0);
  11. // 执行完函数再放开
  12. isBatchingUpdates = false
  13. }

8、isBatchingUpdates 是在同步代码中变化的,而 setTimeout 的逻辑是异步执行的。当 this.setState 调用真正发生的时候,isBatchingUpdates 早已经被重置为了 false,这就使得当前场景下的 setState 具备了立刻发起同步更新的能力。setState 并不是具备同步这种特性,只是在特定的情境下,它会从 React 的异步管控中“逃脱”掉。

9、综上所述,在 React 事件执行之前通过 isBatchingEventUpdates=true 打开开关,开启事件批量更新,当该事件结束,再通过 isBatchingEventUpdates = false; 关闭开关。

  1. export default class index extends React.Component{
  2. state = { number:0 }
  3. handleClick= () => {
  4. this.setState({ number:this.state.number + 1 },()=>{ console.log( 'callback1', this.state.number) })
  5. console.log(this.state.number)
  6. this.setState({ number:this.state.number + 1 },()=>{ console.log( 'callback2', this.state.number) })
  7. console.log(this.state.number)
  8. this.setState({ number:this.state.number + 1 },()=>{ console.log( 'callback3', this.state.number) })
  9. console.log(this.state.number)
  10. }
  11. render(){
  12. return <div>
  13. { this.state.number }
  14. <button onClick={ this.handleClick } >number++</button>
  15. </div>
  16. }
  17. }

image.png

加入setTimeout:

  1. setTimeout(()=>{
  2. this.setState({ number:this.state.number + 1 },()=>{ console.log( 'callback1', this.state.number) })
  3. console.log(this.state.number)
  4. this.setState({ number:this.state.number + 1 },()=>{ console.log( 'callback2', this.state.number) })
  5. console.log(this.state.number)
  6. this.setState({ number:this.state.number + 1 },()=>{ console.log( 'callback3', this.state.number) })
  7. console.log(this.state.number)
  8. })

image.png

所以批量更新规则被打破

那么,如何在如上异步环境下,继续开启批量更新模式呢?React-Dom 中提供了批量更新方法 unstable_batchedUpdates,可以去手动批量更新,可以将上述 setTimeout 里面的内容做如下修改:

  1. import ReactDOM from 'react-dom'
  2. const { unstable_batchedUpdates } = ReactDOM
  3. setTimeout(()=>{
  4. unstable_batchedUpdates(()=>{
  5. this.setState({ number:this.state.number + 1 })
  6. console.log(this.state.number)
  7. this.setState({ number:this.state.number + 1})
  8. console.log(this.state.number)
  9. this.setState({ number:this.state.number + 1 })
  10. console.log(this.state.number)
  11. })
  12. })

三、总结

setState 并不是单纯同步/异步的,它的表现会因调用场景的不同而不同:在 React 钩子函数及合成事件中,它表现为异步;而在 setTimeout、setInterval 等函数中,包括在 DOM 原生事件中,它都表现为同步。这种差异,本质上是由 React 事务机制和批量更新机制的工作方式来决定的。

setState 并非真异步,只是看上去像异步。在源码中,通过 isBatchingUpdates 来判断setState 是先存进 state 队列还是直接更新,如果值为 true 则执行异步操作,为 false 则直接更新。那么什么情况下 isBatchingUpdates 会为 true 呢?在 React 可以控制的地方,就为 true,比如在 React 生命周期事件和合成事件中,都会走合并操作,延迟更新的策略。但在 React 无法控制的地方,比如原生事件,具体就是在 addEventListener 、setTimeout、setInterval 等事件中,就只能同步更新。一般认为,做异步设计是为了性能优化、减少渲染次数,React 团队还补充了两点。保持内部一致性。如果将 state 改为同步更新,那尽管 state 的更新是同步的,但是 props不是。启用并发更新,完成异步渲染。

四、问与答

类组件中的 setState 和函数组件中的 useState 有什么异同?

相同点:首先从原理角度出发,setState和 useState 更新视图,底层都调用了 scheduleUpdateOnFiber 方法,而且事件驱动情况下都有批量更新规则。

不同点:

  • 在不是 pureComponent 组件模式下, setState 不会浅比较两次 state 的值,只要调用 setState,在没有其他优化手段的前提下,就会执行更新。但是 useState 中的 dispatchAction 会默认比较两次 state 是否相同,然后决定是否更新组件。
  • setState 有专门监听 state 变化的回调函数 callback,可以获取最新state;但是在函数组件中,只能通过 useEffect 来执行 state 变化引起的副作用。
  • setState 在底层处理逻辑上主要是和老 state 进行合并处理,而 useState 更倾向于重新赋值。