目录

  1. 一道说起 setState 必考的面试题
  2. 异步的魅力,批量操作的艺术
  3. 合成事件
  4. 同步背后的故事

    前言

    在上一篇文章【React setState 异步真的只是为了性能吗?】中为大家简述了 React setState 异步的一些更较深层次原因,保持一致性为以后需的架构升级启动并发更新。文章发出之后,也收到了一位学长的思考,原话是“ 除了这个还可以思考什么是 Web,从最初的顶层设计就不可能是同步的,之后的 Fiber 也是要解决 idle 的问题,最后完成资源的完美调度 ”。这一句话也给出了更深次的见解,在这里感恩,后续会从这些角度深层次的挖掘。昨天聊完 React setState 异步的原因,今天我们来聊聊 React setState 同步异步的魅力。什么时候同步?什么时候异步?为什么会有同步?

1. 一道说起 setState 必考的面试题

这道面试题,不仅仅经常出现在 BAT 大厂的面试中,也出现在各类文章中,如下:

  1. import React from "react";
  2. import "./styles.css";
  3. export default class App extends React.Component{
  4. state = {
  5. count: 0
  6. }
  7. // count +1
  8. increment = () => {
  9. console.log('increment setState前的count', this.state.count)
  10. this.setState({
  11. count: this.state.count + 1
  12. });
  13. console.log('increment setState后的count', this.state.count)
  14. }
  15. // count +1 三次
  16. triple = () => {
  17. console.log('triple setState前的count', this.state.count)
  18. this.setState({
  19. count: this.state.count + 1
  20. });
  21. this.setState({
  22. count: this.state.count + 1
  23. });
  24. this.setState({
  25. count: this.state.count + 1
  26. });
  27. console.log('triple setState后的count', this.state.count)
  28. }
  29. // count - 1
  30. reduce = () => {
  31. setTimeout(() => {
  32. console.log('reduce setState前的count', this.state.count)
  33. this.setState({
  34. count: this.state.count - 1
  35. });
  36. console.log('reduce setState后的count', this.state.count)
  37. }, 0);
  38. }
  39. render(){
  40. return <div>
  41. <button onClick={this.increment}> +1 </button>
  42. <button onClick={this.triple}> +1 三次 </button>
  43. <button onClick={this.reduce}> -1 </button>
  44. </div>
  45. }
  46. }

测试代码地址:https://codesandbox.io/s/setstate-test-0dzxw

从左往右依次点击三个按钮,如果你能在脑海中快速的得出结果,哪恭喜你,你对 setState 的同步和异步有了一个了解。在我们最开始学习这个 API 的时候就能清楚的知道 setState 是一个异步的方法,当我们执行完 setState 时,并不会马上去触发状态的更新,所以在 increment 函数中两次输出都是 0 。在 triple 函数中虽然执行了三次 setState,但是批量更新收集三次相同的操作,变成了一个更新操作,在加上 setState 的异步,所以 triple 输出的值,只是在第一步最后变更后的值 1。在来看看第三个函数 reduce ,如果你是一个 React 初学者你可能有一点困惑 setState 竟然是同步更新。不要怀疑他就是同步更新了。哪对于一个老手来说,可能了然于胸,哪为什么会出现有时候是同步更新,有时候又是异步更新,今天我们就来聊聊 setState 异步同步更新的魅力(原理)。
setState-test.gif

2. 异步的魅力,批量操作的艺术

不管同步异步,setState 在被调用过后, React 做了什么?如果你对 React 版本的更新的历史比较了解,在不同 React 版本 setSatate 触发之后可能会存在一些小的差异,但是整体的思路是一样的。

  • React15
    1. 触发 setState
    2. shouldComponentUpdate
    3. componentWillUpdate
    4. render
    5. componentDidUpdate
  • React16.3
    1. 触发 setState
    2. shouldComponentUpdate
    3. render
    4. getSnpshotBeforeUpdate
    5. componentDidUpdate
  • React16.4
    1. 触发 setState
    2. getDerivedStateFromProps
    3. shouldComponentUpdate
    4. render
    5. getSnapshotBeforeUpdate
    6. componentDidUpdate

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

  1. this.setState({
  2. count: this.state.count + 1
  3. }); // 触发re-render
  4. this.setState({
  5. count: this.state.count + 1
  6. }); // 触发re-render
  7. this.setState({
  8. count: this.state.count + 1
  9. }); // 触发re-render
  10. ...
  11. // 页面卡死

说到这里你可能就知道为什么 setState 需要批量操作了。一个重要的原因就是,避免频繁的重渲染。他内部的机制和 Vue 的 $nextTick 和浏览器的Event-loop 有点类似,多个 setState 执行,就把它塞进一个队列里存储起来,等到这次操作(当前的同步操作)完成,在将在队列中存储的状态(state)做合并,所以无论你执行多少次 setState ,最后只会针对最新的 state 值走一次更新流程,这就是批量操作更新。

  1. this.setState({
  2. count: this.state.count + 1
  3. });
  4. // 进入队列[count + 1]
  5. this.setState({
  6. count: this.state.count + 1
  7. });
  8. // 进入队列[count + 1, count + 1]
  9. this.setState({
  10. count: this.state.count + 1
  11. });
  12. // 进入队列[count + 1, count + 1, count + 1]
  13. ...
  14. // 合并state[count + 1]
  15. // 执行count + 1

看到这里你可能已经对 React 的异步更新、批量更新有一定的了解,接着往下看,好戏还在后面。

3. 合成事件

在分析同步场景之前,需要先补充一个很重要的知识点,即React 的合成事件,同样它也是 React 面试中很容易被考察的点,本文只是抛砖引玉简述React 合成事件,后面会专门写一篇文章来说说 React 的合成事件。

在说合成事件之前,我们先说说我们最原始的事件委托,事件委托出现的目的更多的是为了性能考虑,举个例子:

  1. <div>
  2. <div onclick="geText(this)">text 1</div>
  3. <div onclick="geText(this)">text 2</div>
  4. <div onclick="geText(this)">text 3</div>
  5. <div onclick="geText(this)">text 4</div>
  6. <div onclick="geText(this)">text 5</div>
  7. // ... 16~9999
  8. <div onclick="geText(this)">text 10000</div>
  9. </div>

假设一个大的 div 标签下面有 10000 个 小的 div 标签。现在需要添加点击事件,通过点击获取当前 div 标签中的文本。那该如何操作?最简单的操作就是为每一个 内部div 标签添加 onclick 事件。有 10000 个 div 标签,则会添加 10000 个事件。这是一种非常不友好的方式,会对页面的性能产生影响。所以事件委托起了大作用。通过将事件绑定在 外面大的div 标签上这样的方式来解决。当 内部 div 点击时,由事件冒泡到父级的标签去触发,并在标签的 onclick 事件中,确认是哪一个标签触发的点击事件。

无独有偶,React 的合成事件也是如此,React 给 document 挂上事件监听;DOM 事件触发后冒泡到 document;React 找到对应的组件,造出一个合成事件出来;并按组件树模拟一遍事件冒泡。这样就有一个问题,就是在一个页面中,只能有一个版本的 React。如果有多个版本,事件就乱套了。
image.png
但是在17版本之后,这个问题得到了解决,事件委托不在挂载到 document 上,而是挂在 DOM 容器上,也就是 ReactDom.Render 所调用的节点上。
image.png

合成事件与 setState 的触发更新有千丝万缕的关系,也只有在了解合成事件后,我们才能继续聊 同步 setState。

4. 同步背后的故事

回到之前的例子,setState 在 setTimeout 函数的“包谷”之下,有了同步这一“功能”。为什么 setTimeout 可以将 setState 的执行顺序从异步变为同步?

setState 的工作机制

从源码的角度,我们来看看 setState 到底怎么工作的,注意源码 React 版本是 React 15(明古通今)。

  1. // 入口
  2. ReactComponent.prototype.setState = function (partialState, callback) {
  3. this.updater.enqueueSetState(this, partialState);
  4. if (callback) {
  5. this.updater.enqueueCallback(this, callback, 'setState');
  6. }
  7. };
  1. enqueueSetState: function (publicInstance, partialState) {
  2. // 获取组件实例
  3. var internalInstance = getInternalInstanceReadyForUpdate(publicInstance, 'setState');
  4. // 这个 queue 对应的就是一个组件实例的 state 数组
  5. var queue = internalInstance._pendingStateQueue || (internalInstance._pendingStateQueue = []);
  6. // 将新的 state 放进组件的状态队列里
  7. queue.push(partialState);
  8. // enqueueUpdate 用来处理当前的组件实例
  9. enqueueUpdate(internalInstance);
  10. }
  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. }
  1. var ReactDefaultBatchingStrategy = {
  2. // 全局唯一的锁标识
  3. isBatchingUpdates: false,
  4. // 发起更新动作的方法
  5. batchedUpdates: function(callback, a, b, c, d, e) {
  6. // 缓存锁变量
  7. var alreadyBatchingStrategy = ReactDefaultBatchingStrategy. isBatchingUpdates
  8. // 把锁“锁上”
  9. ReactDefaultBatchingStrategy. isBatchingUpdates = true
  10. if (alreadyBatchingStrategy) {
  11. callback(a, b, c, d, e)
  12. } else {
  13. // 启动事务,将 callback 放进事务里执行
  14. transaction.perform(callback, null, a, b, c, d, e)
  15. }
  16. }
  17. }

源码中 isBatchingUpdates 属性直接决定了当下是要走更新流程,还是应该排队等待;其中的 batchedUpdates 方法更是能够直接发起更新流程。由此我们可以大胆推测,batchingStrategy 或许正是 React 内部专门用于管控批量更新的对象

isBatchingUpdates 上” 锁 “

isBatchingUpdates 默认是 false ,意味着“当前并未进行任何批量更新操作”。每当 React 调用 batchedUpdate 去执行更新动作时,会先把这个锁给“锁上”(置为 true),表明“现在正处于批量更新过程中”。当锁被“锁上”的时候,任何需要更新的组件都只能暂时进入 dirtyComponents 里排队等候下一次的批量更新,而不能随意“插队”。此处体现的“任务锁”的思想,是 React 面对大量状态仍然能够实现有序分批处理的基石。
image.png
在 onClick、onFocus 等事件中,由于合成事件封装了一层,所以可以将 isBatchingUpdates 的状态更新为 true;在 React 的生命周期函数中,同样可以将 isBatchingUpdates 的状态更新为 true。那么在 React 自己的生命周期事件和合成事件中,可以拿到 isBatchingUpdates 的控制权,将状态放进队列,控制执行节奏。而在外部的原生事件中,并没有外层的封装与拦截,无法更新 isBatchingUpdates 的状态为 true。这就造成 isBatchingUpdates 的状态只会为 false,且立即执行。所以在 addEventListener 、setTimeout、setInterval 这些原生事件中都会同步更新。

总结

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

参考