setState 是异步的吗?| 掘金年度征文

508 阅读13分钟

前言

头图是一张海森堡不确定原理的图,setState 在 react 社区中的地位和这个原理一样,隔段时间就要有人出来问一下。

从 react 出道开始,就一直流传着这样的一种说法:setState 是异步的。那么问题来了:setState 真的是异步的吗?如果是异步的,那么异步的机制是什么样的?如果不是异步的,那么为什么这么多年异步的说法为什么又一直流传?如果是有异步也有同步,那么什么时候异步,什么时候同步?从 v15.x 到 v16.x React 的 setState 机制有变化吗?本文带大家一探究竟。

一道经典的面试题

class App extends React.Component {
  state = { val: 0 }

  componentDidMount() {
    this.setState({ val: this.state.val + 1 })
    console.log(this.state.val)

    this.setState({ val: this.state.val + 1 })
    console.log(this.state.val)

    setTimeout(_ => {
      this.setState({ val: this.state.val + 1 })
      console.log(this.state.val);

      this.setState({ val: this.state.val + 1 })
      console.log(this.state.val)
    }, 0)
  }

  render() {
    return <div>{this.state.val}</div>
  }
}

上面的代码会打印:0 0 2 3,为什么?

这道题当时在 react 刚开始流行的时候很多面试官都会问到。印象中当时看过一篇文章,结论是 setState 是异步的,但文章没有从源码的层面去做解释,也没有深入去探究,只是说这个异步的策略可以优化 react 的性能,我们只需要知道这个事实,在使用的时候注意,不要信任立即更新的内容,避免类似上面这种代码发生,所以当时就也没有深入理解 setState 的更新机制,只是记住了这个事实。 可是……再仔细一看,如果真的是异步的,按照 js 的异步执行机制,打印出来的内容不应该是 0 0 3 4 吗?呃……

从 react v15.x 说起

关于 setState 到底是同步还是异步的说法,从 react v15.x 开始就有很多的文章论述。那么我们就从 v15.x 开始看起。我们看看在 v15.x 中 setState 的执行流程和机制是什么,是如何导致 setState ‘异步执行’的?

下面是一张 v15.x 中 setState 的执行流程图。

setState 调用之后,会把 newState 通过 enqueueSetState 放入更新队列中,并没有立即执行更新。在 enqueueSetState 中会对 isBatchingUpdates 进行判断,如果为 true,表示还在积攒 setState 任务,把 component 放入 dirtyComponents 中;如果为 false,表示可以直接执行,这个时候就会执行 batchUpdates。batchUpdates 调用的是 transaction.perform 方法,然后执行真正的更新流程。 也就是说决定 setState 是同步还是“异步”的关键在于 isBatchingUpdates 的判断,如果是 false,就是同步的,如果是 true 就是“异步”的。 上面代码中前面两次的 setState 时,isBatchingUpdates 都是 true,所以 setState 都延迟执行了,所以前两次才会打印 0。当 batchUpdates 的时候,state 会以类似下面的方式对 state 进行合并。

Object.assign(
  previousState,
  {quantity: state.quantity + 1},
  {quantity: state.quantity + 1},
  ...
)

所以执行完前两次 setState 之后,state.val 的值是 1。

而 setTimeout 中的两次 setState,isBatchingUpdates 都是 false,所以都立即执行了变更 state 的流程,所以会打印出来 2 和 3。 为什么前面两次 setState 的 isBatchingUpdates 是 true,而后面两次 setState 的 isBatchingUpdate 是 false 呢?上图中有一个 transaction 的概念?那么什么是 transaction 呢?

v15 中的 transaction

从上面的流程图中我们也看到了,batchUpdates 最终调用 transaction.perform 方法。这里面有个新的概念:transaction。我们看一看 transaction 的原理和内容。

源码 中我们能看到如下注释:

 * <pre>
 *                       wrappers (injected at creation time)
 *                                      +        +
 *                                      |        |
 *                    +-----------------|--------|--------------+
 *                    |                 v        |              |
 *                    |      +---------------+   |              |
 *                    |   +--|    wrapper1   |---|----+         |
 *                    |   |  +---------------+   v    |         |
 *                    |   |          +-------------+  |         |
 *                    |   |     +----|   wrapper2  |--------+   |
 *                    |   |     |    +-------------+  |     |   |
 *                    |   |     |                     |     |   |
 *                    |   v     v                     v     v   | wrapper
 *                    | +---+ +---+   +---------+   +---+ +---+ | invariants
 * perform(anyMethod) | |   | |   |   |         |   |   | |   | | maintained
 * +----------------->|-|---|-|---|-->|anyMethod|---|---|-|---|-|-------->
 *                    | |   | |   |   |         |   |   | |   | |
 *                    | |   | |   |   |         |   |   | |   | |
 *                    | |   | |   |   |         |   |   | |   | |
 *                    | +---+ +---+   +---------+   +---+ +---+ |
 *                    |  initialize                    close    |
 *                    +-----------------------------------------+
 * </pre>
 
 *
 * Use cases:
 * - Preserving the input selection ranges before/after reconciliation.
 *   Restoring selection even in the event of an unexpected error.
 * - Deactivating events while rearranging the DOM, preventing blurs/focuses,
 *   while guaranteeing that afterwards, the event system is reactivated.
 * - Flushing a queue of collected DOM mutations to the main UI thread after a
 *   reconciliation takes place in a worker thread.
 * - Invoking any collected `componentDidUpdate` callbacks after rendering new
 *   content.
 * - (Future use case): Wrapping particular flushes of the `ReactWorker` queue
 *   to preserve the `scrollTop` (an automatic scroll aware DOM).
 * - (Future use case): Layout calculations before and after DOM updates.
 *
 
 * Transactional plugin API:
 * - A module that has an `initialize` method that returns any precomputation.
 * - and a `close` method that accepts the precomputation. `close` is invoked
 *   when the wrapped process is completed, or has failed.

简单来说就是一个函数 anyMethod 可以被 wrapper 包裹,每个 wrapper 需要提供 initialize 和 close 两个方法。通过 transaction.perform 调用这个 anyMethod 的时候,会先执行 wrapper 的 initialize 函数,然后再执行 anyMethod,最后再执行 wrapper 的 close 函数。感觉有点想设计模式里的 AOP (面向切面编程)。一个 anyMethod 可以添加多个 wrapper。上面的图示已经很形象了。

那么为什么说 transaction 和 setState 相关呢?我们通过打印 setState 的调用栈来看。在 componentDidMount 中的调用栈如下图所示:

而 setTimeout 中的 setState 调用栈如下如所示:

在 componentDidMount 中的 setState,其调用栈更加复杂;而 setTimeout 中调用的两次 setState,调用栈则简单很多。我们会发现 componentDidMount 中 setState 调用栈中有 batchedUpdates 方法,原来早在 setState 调用前,已经处于 batchedUpdates 执行的 transaction 中了。

batchingStrategy 的 isBatchingUpdates 已经被设为 true,所以两次 setState 的结果并没有立即生效,而是被放进了 dirtyComponents 中。

再反观 setTimeout 中的两次 setState,因为没有前置的 batchedUpdate 调用,所以 batchingStrategy 的 isBatchingUpdates 标志位是 false,也就导致了新的 state 马上生效,没有走到 dirtyComponents 分支。也就是,setTimeout 中第一次 setState 时,this.state.val 为 1,而 setState 完成后打印时 this.state.val 变成了 2。第二次 setState 同理。

实际上 setState 的要更新的 state 都会被 transaction 放入到 close 函数中,最后在执行完之后再统一在 close 中去执行。

结论:

  1. setState 的实现并没有涉及到任何的异步 api。
  2. 真正更新组件 state 的是 batchedUpdates 函数,而 setState 不一定会调用这个函数。
  3. setState 会不会立刻更新 state 取决于调用 setState 时是不是已经处于批量更新事务中。
  4. 组件的生命周期函数和绑定的事件回调函数都是在批量更新事务中执行的。

也就是说题目中的的 setState 用一张图来表示的话大概是下面这个样子:

v16.x 的 setState

react 的 setState 执行机制,或者说调用栈随着 react 的升级是一直在变化的,从没有 fiber 的 react 15.x 到出现 fiber 之后的 v16.x,setState 的调用方式一直都在不断地发生着变化,但是 setState ‘异步’ 这个事实却一直没有变化,也就是说从 v15.x 到 v16.x 上面的代码执行结果都是一样的。只不过调用过程更复杂了,调用栈也更深了。我们摘录一张你真的理解setState吗? 中 react v16.x 中 componentDidMount 调用 setState 的调用栈。

调用更加复杂了,但是核心的思想还是一样的,积攒在一起之后再去更新。

那为什么要这么做呢?为什么不直接更新呢? react 官网中也给出了 Dan 的解释。

为什么要设计成‘异步’的机制?

官方给出了两篇文章来解释这件事情

In depth: When and why are setState() calls batched?

In depth: Why isn’t this.state updated immediately?

其中第二篇 github 的 issue 中 Dan 也说了:

So here’s a few thoughts. This is not a complete response by any means, but maybe this is still more helpful than saying nothing.

总结一下,就是说:虽然说了和没说一样,也并不能解决 setState 有时候同步有时候异步的这种不确定性,但是总比不说强。这篇 issue 只能算是从性能和 react 其他 api 设计的角度给出了解释和一个交代,大家也不要再去深究了,就好像很多事情没有什么为什么一样,setState 就是这个样子。

reactjs.org/docs/faq-st… 也给出了相似的解释。

但是有人不服啊,什么时候同步什么时候异步搞不清楚,不放心啊……

什么时候同步?什么时候“异步”?

setState 全都是“异步”的吗?结论是并不是!实际上,setState 这个 api 的使用和设计,从开始就很受争议,因为作为开发者(react 的使用者),我们并不了解 setState 的运行机制和实现细节,而且setState 的文档并没有明确的告诉大家同步和异步的场景,导致开发者在开发的过程中会对于 setState 无法完全信任,偶然的情况下你就会触发同步的场景,而在另外的情况下你触发的却是异步的场景。当然,大多数情况下其实都是异步的场景,同步的场景很少。其实我们对于 setState 之所以异步或者同步的原因有了了解之后,我们就只需要记住哪些场景是同步的,哪些场景是异步的就可以了,深入其中的细节只是作为了解,其实对于大部分的开发者而言没有太大的意义。

你真的理解setState吗? 这篇文章中,对于同步和异步的场景以及调用栈(导致同步异步的原因)有了很好的论述,有兴趣的诸位可以去详细了解。我这里只列出结论:原生事件中的 setState 和 setTimeout 中的 setState 是同步更新的,而除此之外的生命周期事件、合成事件等都是异步的。

大家只需要知道这个就行了,实际的项目中很少有真正需要区分 setState 异步还是同步的场景。大家只要正常使用都没有问题。

如何正确获取到更新后的 state

我们都知道 setState 中直接传递 Object,可能会导致错误,这是因为如果在“异步” setState 的情况下,Object 以类似 Object.assign(prevState, newState1, newState2, ...) 的方式被合并了,所以可能会导致错误的发生,假设 newState 中有相同的 key 的话,后面的 key 会覆盖前面的 key。那么为了避免这种情况的发生,或者说彻底规避这种情况的发生,react 官方推荐我们传递函数来实现同样的效果。

setState 中传入函数和传入 Object 的不同可以用下面这段代码来展示。

Passing an update function allows you to access the current state value inside the updater. Since setState calls are batched, this lets you chain updates and ensure they build on top of each other instead of conflicting:

incrementCount() {
  this.setState((state) => {
    // Important: read `state` instead of `this.state` when updating.
    return {count: state.count + 1}
  });
}

handleSomething() {
  // Let's say `this.state.count` starts at 0.
  this.incrementCount();
  this.incrementCount();
  this.incrementCount();

  // If you read `this.state.count` now, it would still be 0.
  // But when React re-renders the component, it will be 3.
  // 如果是三次传入对象的操作,最后的结果是 1
}

因为 setState 有可能是“异步”的,所以如果你直接在 setState 后面想获取 newState 是无法获取到的,那么 react 也提供了 setState 后获取 newState 的两种方式,就是通过 callback 和 componentDidUpdate 来获取,并且官方文档推荐使用 componentDidUpdate 来获取。但是通过 callback 的方式获取也没有问题,而且因为有关联关系的代码放在一起可读性可能更强,所以个人还是推荐用 callback 的方式。

// callback 的伪代码
setState(newState, callback);
callback() {console.log(this.state)} // newState

后记

对 setState 了解的这么清楚有意义吗?既有意义也没有意义。有意义是以为你对于 setState 的执行机制更清楚了,而且能更深刻的理解 setState 了,但是没有意义的在于,实际中你几乎不需要考虑 setState 同步和异步的情况,对于日常的开发来说,传入 Object 和 Function 真的没有区别,大可不必研究的这么深,更何况大家都用 hooks 了,setState 还有未来吗?[手动狗头]

setState 和 fiber

本来以为 setState 的内容就结束了,没想到 setState 传入函数的方式在 react 使用 fiber reconciliation 之后,成为了标准使用方式,而传入对象的方式反而不被建议使用了。

通常我们现在在调用setState传入的是一个对象,但在使用fiber conciler时,必须传入一个函数,函数的返回值是要更新的state。react从很早的版本就开始支持这种写法了,不过通常没有人用。在之后的react版本中,可能会废弃直接传入对象的写法。 juejin.cn/post/684490…

还有这篇文章中提到的:

接下来进入处理List的work loop,List中包含更新,因此此时react会调用setState时传入的updater funciton获取最新的state值,此时应该是[1,4,9]。通常我们现在在调用setState传入的是一个对象,但在使用fiber conciler时,必须传入一个函数,函数的返回值是要更新的state。react从很早的版本就开始支持这种写法了,不过通常没有人用。在之后的react版本中,可能会废弃直接传入对象的写法

结论

  1. setState 是同步的(不接受反驳)。
  2. 所谓 setState ‘异步’的说法,其实只是让 setState 推迟执行了而已,并不是真正的异步。
  3. setState设计成‘异步’的原因不只是从提高 react 性能方面考虑,同时还牵扯到 react 的很多 api 设计,保证生命周期的正常运转。
  4. setState 除了原生事件和 setTimeout 之外,都是“异步”的。

参考

reactjs.org/docs/react-…

stackoverflow.com/a/48610973/…

github.com/facebook/re…

zhuanlan.zhihu.com/p/20328570

reactjs.org/docs/faq-st…

zhuanlan.zhihu.com/p/57748690

zhuanlan.zhihu.com/p/25882602

zhuanlan.zhihu.com/p/82089614

掘金年度征文 | 2020 与我的技术之路 征文活动正在进行中......

招聘

最后发个招聘链接,前后端都招,长期有效。

感兴趣可以在留言里用 base64 的方式留下邮箱,我会主动联系你。


前端开发工程师

岗位职责:

1.负责产品前端功能开发,优化页面性能,提升用户体验

2.负责数据可视化及通用组件开发

任职要求:

1、全日制统招本科,计算机相关专业优先;

2、2年以上PC/移动端前端开发经验,能独立完成前端开发工作

3、熟练掌握this、作用域、事件、闭包、原型等JS基础知识

4、扎实css基础,了解css3动画,可以高水平的还原设计与交互形式

5、熟悉react hook的用法及原理,善用高阶组件来解决问题

6、了解浏览器的工作原理,方便调优性能

7、计算机基础扎实,尤其是计算机网络与数据结构(非强制性要求)

8、了解必要的计算机网络协议及性能优化方法


java开发工程师

工作职责:

1、负责产品服务器端的设计,开发,优化和持续迭代

2、负责提高服务端的运行性能,稳定性和高可靠性

3、负责服务端大数据处理设计和性能调优

职位要求:

1、统招本科以上学历,计算机相关专业,1年以上开发经验;

2、至少熟悉Java当中的一种开发工具,功底扎实,并熟悉主流开发框架及原理;

3、熟悉Apache、Nginx、Tomcat等Web应用服务器,熟悉HTTP协议;

4、熟悉MySQL、Redis、MongoDB等常用组件的系统部署和自动化监控运维;

5、熟悉git版本控制工具,有相关应用部署经验,能够使用常见Linux命令进行部署和排错;

6、熟悉掌握各类常用数据结构和相关算法;

7、工作积极主动,有责任心,能够承担压力,持续学习。具有认真的技术态度,良好的团队沟通和协作

能力

加分项:

1、熟悉Hadoop, Kafka,Spark,ES等大数据组件的开发和维护者优先;

2、有网络安全知识或从业背景者优先。