React essence note - 11 -React setState 到底是同步的, 还是异步的?

React setState 到底是同步的, 还是异步的?

经典题目

import React from 'react'
import { Button } from 'antd'

interface StateType {
  count: number
  name?: string
}

class SetStateDemo extends React.Component<any, StateType> {
  state = {
    count: 0,
  }

  //   + 1
  increment = () => {
    console.info('increment setState前的count', this.state.count)
    this.setState({
      count: this.state.count + 1,
    })
    console.info('increment setState后的count', this.state.count)
  }
  //    +3
  triple = () => {
    console.info('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.info('triple setState后的count', this.state.count)
  }
  //   + 1
  reduce = () => {
    setTimeout(() => {
      console.info('reduce setState前的count', this.state.count)
      this.setState({
        count: this.state.count - 1,
      })
      console.info('reduce setState后的count', this.state.count)
    }, 0)
  }
  render() {
    return (
      <div>
        <div
          style={{
            display: 'flex',
            justifyContent: 'space-around',
            margin: 50,
          }}
        >
          <Button type="primary" onClick={this.increment}>
            增加
          </Button>
          <Button type="default" onClick={this.triple}>
            三倍
          </Button>
          <Button type="primary" onClick={this.reduce}>
            减少
          </Button>
        </div>
        <div>{this.state.count}</div>
      </div>
    )
  }
}

export default SetStateDemo

// 分别点击 增加, 三倍, 减少的运行结果
// increment setState前的count 0
// increment setState后的count 0

// triple setState前的count 1
// triple setState后的count 1

// reduce setState前的count 2
// reduce setState后的count 1

异步的动机和原理 - 批量更新的艺术

  • setState调用之后,都发生了什么?

  • 从上图可以看出,一个完整的更新流程, 涉及了包括 re-render在内的多个步骤, re-render本身涉及对DOM的操作, 带来的开销非常大,假如说"一次 setState 就触发一个完整的更新流程"这个结论成立, 那么每一次 setState 的调用都会触发一次 re-render, 我们的视图很可能没刷新几次就卡死了.
this.setState({
  count: this.state.count + 1    ===>    shouldComponentUpdate->componentWillUpdate->render->componentDidUpdate
});
this.setState({
  count: this.state.count + 1    ===>    shouldComponentUpdate->componentWillUpdate->render->componentDidUpdate
});
this.setState({
  count: this.state.count + 1    ===>    shouldComponentUpdate->componentWillUpdate->render->componentDidUpdate
});
  • 事实上, setState异步的一个重要的动机 - 避免频繁的re-render
  • 在实际的React运行时, setState异步的实现方式有点类似于Vue$nextTick和浏览器的Event-Loop: 每来一个setState, 就把它塞进一个队列里"攒起来",等到时机成熟,再把攒起来的state结果做合并,最后只针对最新的state走一次更新流程 ===> 批量更新
this.setState({
    count: this.state.count + 1 // 入队, [count+1的任务]
})
this.setState({
    count: this.state.count + 1 // 入队, [count+1的任务,count+1的任务]
})
this.setState({
    count: this.state.count + 1 // 入队, [count+1的任务,count+1的任务,count+1的任务]
})
// 合并 state, [count+3]的任务 => 执行 count+3的任务
  • 只要同步代码还在执行, "攒起来"这个动作就不会停止.

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

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);
}
  • 从题目上看, setState 似乎是在 setTimeout 函数的"保护"之下, 才有了同步这一"特异功能". 事实也的确如此, 假如我们把 setTimeout 摘掉, setState 前后的 console 表现将会与 increment 方法中无异
  • 这里先给出一个结论, 并不是 setTimeout改变了setState, 而是 setTimeout 帮助 setState "逃脱"了 React 对它的管控, 只要是在React管控下的setState, 一定是异步的

解读 setState 工作流

// 入口函数
ReactComponent.prototype.setState = function(partialState, callback){
    this.updater.enqueueSetState(this,partialState)
    if(callback){
        this.updater.enqueueCallback(this, callback, 'setState')
    }
}
/*
入口函数在这里就是充当一个分发器的角色, 根据入参的不同, 将其分发到不同的功能函数中去. 
这里我们以对象形式的入参为例, 可以看到它直接调用了 this.updater.enqueueSetState 这个方法
*/

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

const enqueueUpdate = function(component){
    ensureInjected();
    // 注意这一句是问题的关键, isBatchingUpdates标识着当前是否处于批量创建/更新组件的阶段
    if (!batchingStrategy.isBatchingUpdates) {
        // 若当前没有处于批量创建/更新组件的阶段, 则立即更新组件
        batchingStrategy.batchedUpdates(enqueueUpdate, component);
        return;
    }
    // 否则.现把组件塞入 dirtyComponents队列里, 让它再等等
    dirtyComponents.push(component)
    if(component._updateBatchNumber === null){
        component._updateBatchNumber = updateBatchNumber  + 1
    }
}
/*
     enqueueUpdate 引出了一个关键的对象——batchingStrategy,该对象所具备的 isBatchingUpdates 属性直接决定了当下是要走更新流程, 还是应该排队等待
     其中的 batchedUpdates 方法更是能够直接发起更新流程
     由此我们可以大胆推测, batchingStrategy 或许正是 React 内部专门用于管控批量更新的对象
*/

// batchingStrategy 源码

var ReactDefaultBatchingStrategy = {
  // 全局唯一的锁标识
  isBatchingUpdates: false,
  // 发起更新动作的方法
  batchedUpdates: function(callback, a, b, c, d, e) {
    // 缓存锁变量
    var alreadyBatchingStrategy = ReactDefaultBatchingStrategy.isBatchingUpdates
    // 把锁"锁上"
    ReactDefaultBatchingStrategy.isBatchingUpdates = true
    if (alreadyBatchingStrategy) {
      callback(a, b, c, d, e)
    } else {
      // 启动事务, 将 callback 放进事务里执行
      transaction.perform(callback, null, a, b, c, d, e)
    }
  }
}
/*
    batchingStrategy 对象并不复杂, 你可以理解为它是一个"锁管理器"
    这里的"锁", 是指 React 全局唯一的 isBatchingUpdates 变量, isBatchingUpdates 的初始值是 false, 意味着"当前并未进行任何批量更新操作". 每当 React 调用 batchedUpdate 去执行更新动作时, 会先把这个锁给"锁上"(置为 true), 表明"现在正处于批量更新过程中". 当锁被"锁上"的时候, 任何需要更新的组件都只能暂时进入 dirtyComponents 里排队等候下一次的批量更新, 而不能随意"插队". 此处体现的"任务锁"的思想, 是 React 面对大量状态仍然能够实现有序分批处理的基石
*/

理解 React 中的 Transaction(事务) 机制

  • TransactionReact 源码中的分布可以说非常广泛. 如果你在 Debug React 项目的过程中, 发现函数调用栈中出现了 initialize、perform、close、closeAll 或者 notifyAll 这样的方法名, 那么很可能你当前就处于一个 Transaction 中.
  • TransactionReact 源码中表现为一个核心类, React 官方曾经这样描述它:Transaction 是创建一个黑盒, 该黑盒能够封装任何的方法. 因此, 那些需要在函数运行前、后运行的方法可以通过此方法封装(即使函数运行中有异常抛出, 这些固定的方法仍可运行), 实例化 Transaction 时只需提供相关的方法即可.

  • 说白了, Transaction 就像是一个'壳子', 它首先会将目标函数用 wrapper(一组 initializeclose 方法称为一个 wrapper) 封装起来, 同时需要使用 Transaction 类暴露的 perform 方法去执行它. 如上面的注释所示, 在 anyMethod 执行之前, perform 会先执行所有 wrapperinitialize 方法, 执行完后, 再执行所有 wrapperclose 方法. 这就是 React 中的事务机制.

同步现象 的本质

  • ReactDefaultBatchingStrategy 对象: 其实就是一个批量更新策略事务, 它的 wrapper有两个, FLUSH_BATCHED_UPDATESRESET_BATCHED_UPDATES
var RESET_BATCHED_UPDATES = {
  initialize: emptyFunction,
  close: function () {
    ReactDefaultBatchingStrategy.isBatchingUpdates = false;
  }
};

var FLUSH_BATCHED_UPDATES = {
  initialize: emptyFunction,
  close: ReactUpdates.flushBatchedUpdates.bind(ReactUpdates)
};
var TRANSACTION_WRAPPERS = [FLUSH_BATCHED_UPDATES, RESET_BATCHED_UPDATES];
  • 把这两个 wrapper 套进 Transaction 的执行机制里, 不难得出一个这样的流程

  • 到这里, 相信你对 isBatchingUpdates 管控下的批量更新机制已经了然于胸. 但是 setState 为何会表现同步这个问题, 似乎还是没有从当前展示出来的源码里得到根本上的回答. 这是因为 batchingUpdates 这个方法, 不仅仅会在 setState 之后才被调用. 若我们在 React 源码中全局搜索 batchingUpdates, 会发现调用它的地方很多, 但与更新流有关的只有这两个地方
// ReactMount.js
_renderNewRootComponent: function( nextElement, container, shouldReuseMarkup, context ) {
  // 实例化组件
  var componentInstance = instantiateReactComponent(nextElement);
  // 初始渲染直接调用 batchedUpdates 进行同步渲染
  ReactUpdates.batchedUpdates(
    batchedMountComponentIntoNode,
    componentInstance,
    container,
    shouldReuseMarkup,
    context
  );
//   ...
}
/**
 * 这段代码是在首次渲染组件时会执行的一个方法, 
 * 我们看到它内部调用了一次 batchedUpdates, 这是因为在组件的渲染过程中, 会按照顺序调用各个生命周期函数. 
 * 开发者很有可能在声明周期函数中调用 setState. 因此, 我们需要通过开启 batch 来确保所有的更新都能够进入 dirtyComponents 里去, 
 * 进而确保初始渲染流程中所有setState 都是生效的
*/

//下面代码是 React 事件系统的一部分.当我们在组件上绑定了事件之后, 事件中也有可能会触发 setState. 为了确保每一次 setState 都有效, React 同样会在此处手动开启批量更新
// ReactEventListener.js
dispatchEvent: function (topLevelType, nativeEvent) {
  ...
  try {
    // 处理事件
    ReactUpdates.batchedUpdates(handleTopLevelImpl, bookKeeping);
  } finally {
    TopLevelCallbackBookKeeping.release(bookKeeping);
  }
}
/**
 * 到这里, 一切都变得明朗了起来:
 *  isBatchingUpdates 这个变量, 在 React 的生命周期函数以及合成事件执行前, 已经被 React 悄悄修改为了 true, 这时我们所做的 setState 操作自然不会立即生效. 
 *  当函数执行完毕后, 事务的 close 方法会再把 isBatchingUpdates 改为 false
*/
// increment 方法为例
increment = () => {
  // 进来先锁上
  isBatchingUpdates = true
  console.log('increment setState前的count', this.state.count)
  this.setState({
    count: this.state.count + 1
  });
  console.log('increment setState后的count', this.state.count)
  // 执行完函数再放开
  isBatchingUpdates = false
}
// 很明显, 在 isBatchingUpdates 的约束下, setState 只能是异步的
// reduce 方法为例
reduce = () => {
  // 进来先锁上
  isBatchingUpdates = true
  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);
  // 执行完函数再放开
  isBatchingUpdates = false
}
/**
 * 会发现, 咱们开头锁上的那个 isBatchingUpdates, 对 setTimeout 内部的执行逻辑完全没有约束力. 因为 isBatchingUpdates 是在同步代码中变化的, 而 setTimeout 的逻辑是异步执行的. 当 this.setState 调用真正发生的时候, isBatchingUpdates 早已经被重置为了 false, 这就使得当前场景下的 setState 具备了立刻发起同步更新的能力. 所以咱们前面说的没错——setState 并不是具备同步这种特性, 只是在特定的情境下, 它会从 React 的异步管控中'逃脱'掉. 
 * **/

总结

  • 道理很简单,原理却很复杂。最后,我们再一次面对面回答一下标题提出的问题,对整个 setState 工作流做一个总结
  • setState 并不是单纯同步/异步的,它的表现会因调用场景的不同而不同:
    # 在 React 钩子函数及合成事件中,它表现为异步
    # 而在 setTimeout、setInterval 等函数中,包括在 DOM 原生事件中,它都表现为同步
  • 这种差异,本质上是由 React 事务机制和批量更新机制的工作方式来决定的
posted @ 2020-11-17 17:27  荣光无限  阅读(140)  评论(0)    收藏  举报