从 React15 到 React18:一文读懂 React 批处理机制变更

lxf2023-04-17 09:59:01

从 React15 到 React18:一文读懂 React 批处理机制变更

2023年已经过去 1/3,但是在开始本文之前,我们还是先来看看2022年前端框架开发:使用留存率-感兴趣程度-使用度-熟知度这张图可以与下面 React 和 Vue 最近几年的关键点时间轴图结合起来看,或许可以看出一些信息.....

对于各个框架的性能对比,有兴趣的可以看看这篇文章。 这里提一点这篇文章里面我印象比较深刻的。 我们经常听到一句话:大型应用选React,小型应用选Vue。 这篇文章从 Google Chrome User Experience Report 的报告进行分析,发现 Vue 构建的网站性能并不比 React 差。

引言

从 React15 到 React18:一文读懂 React 批处理机制变更

如上图,绘制了 React 和 Vue 各个大版本发布的时间点以及这个大版本当前最新的 Release 版本的发布时间。我们可以看到,在最近几年 React 大版本更新的频率和 Vue 相比,更为频繁些。

从 React 15 到 React 18 更新的新特性不少,本文主要讲从 React 15 到 React 18 批处理实现上的变化,以及不同版本 React 涉及到的一些常见的开发问题。什么是批处理? 为了方便理解,我们先简单地对批处理/批更新下个定义:与一次状态更新触发一次重渲染不同,批处理是指为了更好的性能,组合多个状态更新进行一次重渲染。

有句话概括得很好:批处理在数据层将多个状态更新批量处理,合并成一次更新;在视图层将多个渲染合并成一次渲染。

从 React15 到 React18:一文读懂 React 批处理机制变更

在开始本文之前,我们先通过上图对 React 17 中提出的React 应用启动模式有个了解,因为不同的启动模式下,React 的批处理方式和支持的特性也有所不同。基于 React 渐进的迁移策略,legacy 模式ReactDOM.render(<App />, rootNode) 在 React 18 上虽然控制台会如下图报错提示,但是也可以正常运行。正如报错所说,在 React 18 使用旧的渲染 API, React 的运行机制就如同使用 React 17。

从 React15 到 React18:一文读懂 React 批处理机制变更

避免混淆,我们下文主要以模式作为区分。我们可以简单粗暴的理解为,legacy 模式其实就是 React 18 以前的主要版本,而 concurrent 模式就是 React 18 的版本。

这里先提出一个疑问,不同模式下的setState有何不同?下文将会解答这个问题

不同模式支持的特性对比

从 React15 到 React18:一文读懂 React 批处理机制变更

React 15: setState 什么时候是同步的,什么时候是异步的?

这是一个老生常谈的问题,为了方便理解下文,这里主要做一个简单的复习。

首先理清一个概念,“同步异步”的定义。从 JavaScript 事件循环看,setState肯定是同步执行,所以这里讨论的“同步和异步” 并不是指 JavaScript 线程中的同步和异步任务,而是指调用setState之后能否立即更新数据。

执行上下文 executionContext

从 React15 到 React18:一文读懂 React 批处理机制变更

executionContext是 react 内部控制的属性。当通过生命周期函数和合成事件触发 setState 时会改变executionContext的值, 此时setState为异步更新。通过原生事件或 JavaScript 异步代码触发 setState 时,会立即进行scheduler调度,进行 fiber构建循环,此时setState为同步更新。

批处理机制 isBatchingUpdates

concurrent 模式下,我们常常听到 Automatic batching,所以为了方便下文介绍和理解,这里我们也可以通过 isBatchingUpdates 这个变量来理解下这个问题。通过生命周期函数和合成事件触发 setState 时,会触发 React 批处理机制,isBatchingUpdates 更新为true, 此时为异步更新。而原生事件不会触发 React 的批处理机制,因而调用 setState 会直接更新。

从 React15 到 React18:一文读懂 React 批处理机制变更

总结

  1. 同步更新:通过原生事件、异步代码更新

  2. 异步更新:使用 React 事件(合成事件),在原生事件和异步代码中使用 ReactDOM.unstable_batchedUpdates API时。

React 16.8+: useState

React 16+ 是一个比较重要的版本,因为其中的Hook 是 React 最近版本中非常重要的特性之一。例如我们开发中常用的组件ant-design 最近几个版本 peerDependencies 基本也是最低指定到这个版本:antd3+: react >= 16.0.0antd4+ & antd5: react >= 16.9.0

Hook 是 React 16.8 的新增特性。

setState 与 useState 的区别?

useState 其实与setState是一样的,更新状态方法是异步的。与 setState 不同的是,useState 对 state 的读取没有通过 this. 的方式, 使得使用 setTimeout 等异步方法时形成了闭包,所以是读取了当时渲染闭包环境的数据,虽然最新的值跟着最新的渲染变了,但是状态数据依然是旧值。

const HookDemo = () => {
  const [count1, changeCount1] = useState(0);

  console.log('render')
  console.log(count1) // 新值

  const handleChange = () => {
    changeCount1(count1+1)
    setTimeout(() => {
      console.log('2', count1) // 旧值
    }, 2000)
  }

  return (
    <div>
      <button onClick={handleChange}>改变count1</button>
    </div>
  );
}

如上代码,页面第一次渲染,count1 = 0;点击按钮,事件处理器拿到count1 = 0(即setTimeout里面count1的值), 同时将 count1 变为 1;第二次渲染,count1 = 1 函数组件 HookDemo 每次渲染,调用函数组件,setTimeout 的回调函数通过闭包获取到的是本次渲染前stateprop, 所以拿到的是旧的值。

而类组件通过 this 访问,this 在整个类组件生命周期中,都指向自身。所以类组件在setTimeout 通过 this.state 拿到的是新值。

React 17

React 17 is a “stepping stone” release. React 官网将此版本称为“垫脚石"版本。

这个版本主要是为平滑升级到 React 18 做准备的。我们前面提到的三种启动模式,就是在 React 17 提出来的。

Shadow DOM

从 React15 到 React18:一文读懂 React 批处理机制变更

这里既然提到了 React 17,就乱入提一个 React 17 合成事件的变更Changes to Event Delegation。 如下图,React 16 中的合成事件是绑定在 HTML 的根节点(document对象)执行的。React 17 将合成事件绑定到 React tree 挂载的 DOM 元素,也就是 ReactDOM.render 方法的第二个参数。

const rootNode = document.getElementById('root');
ReactDOM.render(<App />, rootNode);

这个变更主要是为了修复一个 Shadow DOM 上的问题。在 React 16 中,我们给 Shadow DOM 内部的一个元素绑定一个 click 监听事件,执行时,会发现这个 click 事件并没有执行。这是因为:

  1. 我们知道,原生事件是先捕获再冒泡的。React 的合成事件是委托在冒泡阶段执行的。

  2. 在事件捕获阶段,Shadow DOM 内部元素的 event target 会被重定向到 Shadow DOM 挂载的 host 元素。

  3. 由于 Shadow DOM 内部事件被重定向,在 React 16 中 document 对象被认为是事件源而使得该内部事件没有执行。

为啥这里乱入提了这个问题呢?

因为如果你的项目中用了 Shadow DOM 又或者你项目中用了基于 Shadow DOM 去实现沙箱的框架,如 qiankun,那你最好升级到 React 17 去修复这个问题。*

qiankun2.0 沙箱提供的默认 CSS 隔离方案就是采用 Shadow DOM,这可能会带来一些坑。 有兴趣可以看看这篇文章--微前端方案 qiankun 的样式隔离能不用就别用吧。 qiankun3.0 Roadmap 也计划将默认的 CSS 隔离方案换为scoped-style

React 18: 批处理 && 并发机制

Until React 18, we only batched updates during the React event handlers. Updates inside of promises, setTimeout, native event handlers, or any other event were not batched in React by default.

上面关于 setState 什么时候是同步的,什么时候是异步的? 这个问题的解答可以说是基于 legacy 模式的,那对于 concurrent 模式,这个问题又有不一样的解答。

BTW: React 18 放弃对 ie11 的支持, 因为微软从2022年6月15日起已经停止支持 IE 11 浏览器

在 ReactDOM 创建过程中, React18 无论在什么模式下,其实都会调用createContainer函数, 最后都会创建 fiberRoot 对象。如下图,在 React 18 中,不同模式启动应用区别在于传入createContainer 函数的 tag 值不同。

从 React15 到 React18:一文读懂 React 批处理机制变更

从 React15 到 React18:一文读懂 React 批处理机制变更

到这里你会不会有个疑问:在 React 18 blocking 模式哪里去了? 已经被删了,这也解释了为啥 legacy 模式传入的tag0,而在 concurrent 模式传入的tag1

从 React15 到 React18:一文读懂 React 批处理机制变更

自动批处理:concurrent 模式下的 setState 更新流程

从 React15 到 React18:一文读懂 React 批处理机制变更

如上图为一次 setState 同步更新 过程图,即 legacy 模式下同步更新的大致流程。我们可以看到一次 fiber 构造循环会有一次 render 和一次 commit。那在两次 setState 一起发生的时候,legacy 模式和 concurrent 模式在批处理上又有啥不同呢?

const CountUpdate = () => {
  const [count, setCount] = useState(0)
  const [count2, setCount2] = useState(0);
  
  // legacy
  const handleNativeChange = () => {
    setCount(c => c + 1)
    setCount2(c => c + 1)
  }

  useEffect(() => {
    const btnEl = document.getElementById('btn')
    btnEl.addEventListener('click', handleNativeChange)
    return () => {
      btnEl.removeEventListener('click', handleNativeChange)
    }
  }, [])

  
  return (
    <div>
      <p>count: {count}</p>
      <p>count2: {count2}</p>
      <button id="btn">计数器</button>
    </div>
  )
}

如上示例代码,在 React 18 中采用 legacy 模式(即ReactDOM.render(<App />, document.getElementById('root')))。原生事件中两次 setState 更新会触发两次 fiber 构造循环(即两次render、两次commit)。

从 React15 到 React18:一文读懂 React 批处理机制变更

在 concurrent 模式下,所有的 setState 都是自动批处理的, 即异步更新。 同样的代码,在 concurrent 模式下(即ReactDOM.createRoot(document.getElementById('root')).render(<App />)),原生事件中两次 setState 更新则只会触发一次 fiber 构造循环(即一次render、一次commit)。

从 React15 到 React18:一文读懂 React 批处理机制变更

前面提到 legacy 模式下,非 React 事件想进行批处理可以使用unstable_batchedUpdatesAPI。如下代码,我们在 legacy 模式下,使用unstable_batchedUpdates API,同样两次 setState 更新也只会触发一次 fiber 构造循环。

import { unstable_batchedUpdates } from 'react-dom'

const CountUpdate = () => {
  // ...
  const handleNativeChange = () => {
   unstable_batchedUpdates(() => {
      setCount(c => c + 1)
      setCount2(c => c + 1)
    })
  }
}

从 React15 到 React18:一文读懂 React 批处理机制变更

而在 concurrent 模式下我们也可以采用 ReactDOM.flushSync() 使其跳出批处理。如下代码,在 concurrent 模式下,使用了 flushSync API,则两次 setState 更新触发了两次 fiber 构造循环。

import { flushSync } from 'react-dom'

const CountUpdate = () => {
  // ...
  const handleNativeChange = () => {
    flushSync(() => {
      setCount(c => c + 1)
    })
    setCount2(c => c + 1)
  }
}

从 React15 到 React18:一文读懂 React 批处理机制变更

最后引用一个通俗易懂的类比作为总结:

向五岁小孩解释自动批处理?你打算做早餐,你去超市买了牛奶,回到家发现你需要麦片,你又去超市买了麦片, 再次回到家又发现你还需要饼干,然后你又去了超市买饼干。最后你才回家开始吃早餐。这里“去超市”就是一次重渲染。批处理就是在去超市前,先列好需要买的东西,再出发。(在一次重渲染前,先记录下所有需要更新的状态。)

并发模式 vs 并发更新

从 React15 到 React18:一文读懂 React 批处理机制变更

回顾一下,在 legacy 模式下,vdom 转 fiber 的大致过程,如上图,vdom 转 fiber 链表实际一个childsiblingreturn的循环遍历过程,其中用 workInProgress 作为指针记录当前的正在处理的节点,当workInProgressnull时表示全部 fiber 节点都渲染完。legacy 模式下的 Stack Reconciler 采用不可中断的递归方式更新,而 concurrent 模式 Fiber Reconciler 采用可中断的遍历方式更新。 上图在并发模式下被中断时的情况,类似如下图:

从 React15 到 React18:一文读懂 React 批处理机制变更

如下图源码所示,并发模式比起不可中断模式多了一个是否中断 shouldYield() 的判断。

从 React15 到 React18:一文读懂 React 批处理机制变更

并发模式的大致逻辑如下:

  1. Scheduler 调度里的任务队列是按优先级来排序。

  2. 设置时间分片frameInterval,任务超过设置的时间分片则打断

  3. 每处理一个 fiber 节点,都通过 shouldYield 判断下是否打断。

  4. 如果当前任务队列被打断,则作为新任务加到队列中。

优先级的规则是什么? 优先级大致分为下面三种。

  • Lane 优先级(31种,基于二进制方式保存)是 fiber 树构造过程相关的优先级。
  • 事件优先级(4种)是 Scheduler 优先级和 Lane 优先级相互转换的桥梁。
  • Scheduler 优先级(5种)是 scheduler 调度中心相关的优先级。

这里还需要关注一个细节,使用并发模式(concurrent 模式)是否就是开启了并发更新? 并没有,下面盗用这篇文章的一张图,就能明白这个问题。

从 React15 到 React18:一文读懂 React 批处理机制变更

就好比你想开空调(开启并发更新),首先你打开屋子的总开关(使用 concurrent 模式),然后你再去打开空调的开关(使用并发特性)。

下面我们通过使用并发特性 useTransition 来对优先级和并发更新有更好的理解。如下代码,在 concurrent 模式使用startTransition API 延迟更新其中一个 state。

import { useState, useTransition } from "react"

const CountUpdateWithPriority = () => {
  const [count, setCount] = useState(0)
  const [count2, setCount2] = useState(0)
  const [isPending, startTransition] = useTransition()

  const handleClick = () => {
    startTransition(() => {
      // 优先级低
      setCount(c => c + 1)
    });
    // 优先级高
    setCount2(c => c + 1)
  }

  return (
    <div>
      <h1>慢更新的state count: <span style={{fontSize:'50px', color: 'green'}}>{count}</span></h1>
      <h1>快更新的state count2: <span style={{fontSize:'50px', color: 'red'}}>{count2}</span></h1>
      <button onClick={handleClick}>更新</button>
    </div>
  );
}

如下图,通过 Chrome Screenshots 我们可以清楚地看出优先级高的count2更快被渲染更新。

从 React15 到 React18:一文读懂 React 批处理机制变更

最后同样引用一个通俗易懂的类比作为总结:

向五岁小孩解释并发更新?如下图,我只有一个电话,没法同时和两个人打电话。非并发更新指,我先挂断了 Alice 的电话,再开始和 Bob 打电话。并发更新指,我中断和 Alice 的通话,但是不挂断电话,和 Bob 聊一会,再切换回去和 Alice 通话。

从 React15 到 React18:一文读懂 React 批处理机制变更

从 React15 到 React18:一文读懂 React 批处理机制变更


参考资料

Automatic batching for fewer renders in React 18

react官网

当 Shadow Dom 遇上 React event

彻底搞懂 React 18 并发机制的原理

React18 新特性解读 & 完整版升级指南