React 18 带来了几个非常实用的新特性，同时也没有额外的升级成本，值得仔细看一看。

下面是几个关键信息：

- [React 18 工作小组](https://github.com/reactwg/react-18)。利用社区讨论 React 18 发布节奏与新特性。
- [发布计划](https://reactjs.org/blog/2021/06/08/the-plan-for-react-18.html)。目前还没有正式发布，不过 `@alpha` 版已经可用了，[安装 alpha 版](https://github.com/reactwg/react-18/discussions/9)。
- [React 18 新特性介绍](https://github.com/reactwg/react-18/discussions/4)。虽然还未正式发布，但特性介绍可以先行，本周精读主要就是解读这篇文档。

## 精读

总的来说，React 18 带来了 3 大新特性：

- Automatic batching。
- Concurrent APIS。
- SSR for Suspense。

同时为了开启新的特性，需要进行简单的 `render` 函数升级。

### Automatic batching

batching 是指，React 可以将回调函数中多个 `setState` 事件合并为一次渲染。

也就是说，`setState` 并不是实时修改 State 的，而将多次 `setState` 调用合并起来仅触发一次渲染，既可以减少程序数据状态存在中间值导致的不稳定性，也可以提升渲染性能。可以理解为如下代码所示：

```typescript
function handleClick() {
  setCount((c) => c + 1);
  setFlag((f) => !f);
  // 仅触发一次渲染
}
```

但可惜的是，React 18 以前，如果在回调函数的异步调用中执行 `setState`，由于丢失了上下文，无法做合并处理，所以每次 `setState` 调用都会立即触发一次重渲染：

```typescript
function handleClick() {
  // React 18 以前的版本
  fetch(/*...*/).then(() => {
    setCount((c) => c + 1); // 立刻重渲染
    setFlag((f) => !f); // 立刻重渲染
  });
}
```

而 React 18 带来的优化便是，任何情况都可以合并渲染了！即使在 `promise`、`timeout` 或者 `event` 回调中调用多次 `setState`，也都会合并为一次渲染：

```typescript
function handleClick() {
  // React 18+
  fetch(/*...*/).then(() => {
    setCount((c) => c + 1);
    setFlag((f) => !f);
    // 仅触发一次渲染
  });
}
```

当然如果你非要 `setState` 调用后立即重渲染也行，只需要用 `flushSync` 包裹：

```typescript
function handleClick() {
  // React 18+
  fetch(/*...*/).then(() => {
    ReactDOM.flushSync(() => {
      setCount((c) => c + 1); // 立刻重渲染
      setFlag((f) => !f); // 立刻重渲染
    });
  });
}
```

开启这个特性的前提是，将 `ReactDOM.render` 替换为 `ReactDOM.createRoot` 调用方式。

### 新的 ReactDOM Render API

升级方式很简单：

```typescript
const container = document.getElementById("app");

// 旧 render API
ReactDOM.render(<App tab="home" />, container);

// 新 createRoot API
const root = ReactDOM.createRoot(container);
root.render(<App tab="home" />);
```

API 修改的主要原因还是语义化，即当我们多次调用 `render` 时，不再需要重复传入 `container` 参数，因为在新的 API 中，`container` 已经提前绑定到 `root` 了。

`ReactDOM.hydrate` 也被 `ReactDOM.hydrateRoot` 代替：

```typescript
const root = ReactDOM.hydrateRoot(container, <App tab="home" />);
// 注意这里不用调用 root.render()
```

这样的好处是，后续如果再调用 `root.render(<Appx />)` 进行重渲染，我们不用关心这个 `root` 来自 `createRoot` 或者 `hydrateRoot`，因为后续 API 行为表现都一样，减少了理解成本。

### Concurrent APIS

首先要了解 Concurrent Mode 是什么。

简单来说，Concurrent Mode 就是一种可中断渲染的设计架构。什么时候中断渲染呢？当一个更高优先级渲染到来时，通过放弃当前的渲染，立即执行更高优先级的渲染，换来视觉上更快的响应速度。

有人可能会说，不对啊，中断渲染后，之前渲染的 CPU 执行不就浪费了吗，换句话说，整体执行时常增加了。这句话是对的，但实际上用户对页面交互及时性的感知是分为两种的，第一种是即时输入反馈，第二种是这个输入带来的副作用反馈，比如更新列表。其中，即使输入反馈只要能优先满足，即便副作用反馈更慢一些，也会带来更好的体验，更不用说副作用反馈大部分情况会因为即使输入反馈的变化而作废。

由于 React 将渲染 DOM 树机制改为两个双向链表，并且渲染树指针只有一个，指向其中一个链表，因此可以在更新完全发生后再切换指针指向，而在指针切换之前，随时可以放弃对另一颗树的修改。

以上是背景输入。React 18 提供了三个新的 API 支持这一模式，分别是：

- startTransition。
- useDeferredValue。
- &lt;SuspenseList&gt;。

后两个文档还未放出，所以本文只介绍第一个 API：startTransition。首先看一下用法：

```typescript
import { startTransition } from "react";

// 紧急更新：
setInputValue(input);

// 标记回调函数内的更新为非紧急更新：
startTransition(() => {
  setSearchQuery(input);
});
```

简单来说，就是被 `startTransition` 回调包裹的 `setState` **触发的渲染** 被标记为不紧急的渲染，这些渲染可能被其他紧急渲染所抢占。

比如这个例子，当 `setSearchQuery` 更新的列表内容很多，导致渲染时 CPU 占用 100% 时，此时用户又进行了一个输入，即触发了由 `setInputValue` 引起的渲染，此时由 `setSearchQuery` 引发的渲染会立刻停止，转而对 `setInputValue` 渲染进行支持，这样用户的输入就能快速反映在 UI 上，代价是搜索列表响应稍慢了一些。而一个 `transition` 被打断的状态可以通过 `isPending` 访问到：

```typescript
import { useTransition } from "react";
const [isPending, startTransition] = useTransition();
```

其实这比较符合操作系统的设计理念，我们知道在操作系统是通过中断响应底层硬件事件的，中断都非常紧急（因为硬件能存储的消息队列非常有限，操作系统不能即使响应，硬件的输入可能就丢失了），因此要支持抢占式内核，并在中断到来时立刻执行中断（可能把不太紧急的操作放到下半部执行）。

对前端交互来说，用户角度发出的 “中断” 一般来自键盘或鼠标的操作，但不幸的是，前端框架甚至是 JS 都过于上层，它们无法自动识别：

1. 哪些代码是紧急中断产生的。比如 `onClick` 就一定是用户鼠标点击产生的吗？不一定，可能是 `xxx.onClick` 主动触发的，而非用户触发。
2. 用户触发的就一定是紧急中断吗？不一定，比如键盘输入后，`setInputValue` 是紧急的，而更新查询列表的 `setSearchQuery` 就是非紧急的。

我们要理解到前端场景对用户操作感知的局限性，才能理解为什么必须手动指定更新的紧急程度，而不能像操作系统一样，上层程序无需感知中断的存在。

### SSR for Suspense

完整名称是：Streaming SSR with selective hydration。

即像水流一样，打造一个从服务端到客户端持续不断的渲染管线，而不是 `renderToString` 那样一次性渲染机制。selective hydration 表示选择性水合，水合指的是后端内容打到前端后，JS 需要将事件绑定其上，才能响应用户交互或者 DOM 更新行为，而在 React 18 之前，这个操作必须是整体性的，而水合过程可能比较慢，会引起全局的卡顿，所以选择性水合可以按需优先进行水合。

所以这个特性其实是转为 SSR 准备的，而功能启用载体就是 Suspense（所以以后不要再认为 Suspense 只是一个 loading 作用）。其实在 Suspense 设计之初，就是为了解决服务端渲染问题，只是一开始只实装了客户端测的按需加载功能，后面你会逐渐发现 React 团地逐渐赋予了 Suspense 更多强大能力。

SSR for Suspense 解决三个主要问题：

- SSR 模式下，如果不同模块取数效率不同，会因为最慢的一个模块拖慢整体 HTML 吞吐时间，这可能导致体验还不如非 SSR 来的好。举一个极端情况，假设报表中一个组件依赖了慢查询，需要五分钟数据才能出来，那么 SSR 的后果就是白屏时间拉长到 5 分钟。
- 即便 SSR 内容打到了页面上，由于 JS 没有加载完毕，所以根本无法进行 hydration，整个页面处于无法交互状态。
- 即便 JS 加载完了，由于 React 18 之前只能进行整体 hydration，可能导致卡顿，导致首次交互响应不及时。

在 React 18 的 server render 中，只要使用 `pipeToNodeWritable` 代替 `renderToString` 并配合 `Suspense` 就能解决上面三个问题。

使用 `pipeToNodeWriteable` 可以看 [这个例子](https://codesandbox.io/s/festive-star-9hfqt?file=/server/render.js:1043-1575)。

最大的区别在于，服务端渲染由简单的 `res.send` 改成了 `res.socket`，这样渲染就从单次行为变成了持续性的行为。

那么 React 18 的 SSR 到底有怎样的效果呢？[这篇介绍文档](https://github.com/reactwg/react-18/discussions/37) 的图建议看一看，非常直观，这里我简要描述一下：

1. 被 `<Suspense>` 包裹的区块，在服务端渲染时不会阻塞首次吞吐，而且在这个区块准备完毕后（包括异步取数）再实时打到页面中（以 HTML 模式，此时还没有 hydration），在此之前返回的是 `fallback` 的内容。
2. hydration 的过程也是逐步的，这样不会导致一下执行所有完整的 js 导致页面卡顿（hydration 其实就是 React 里写的回调注册、各类 Hooks，整个应用的量非常庞大）。
3. hydration 因为被拆成多部，React 还会提前监听鼠标点击，并提前对点击区域优先级进行 hydration，甚至能抢占已经在其他区域正在进行中的 hydration。

那么总结一下，新版 SSR 性能提高的秘诀在于两个字：按需。

而这个难点在于，SSR 需要后端到前端的配合，在 React 18 之前，后端到前端的过程完全没有优化，而现在将 SSR HTML 的吞吐改成多次，按需，并且水合过程中还支持抢占，因此性能得到进一步提升。

## 总结

结合起来看，React 18 关注点在于更快的性能以及用户交互响应效率，其设计理念处处包含了中断与抢占概念。

以后提起前端性能优化，我们就多了一些应用侧的视角（而不仅仅是工程化视角），从以下两个应用优化视角有效提升交互反馈速度：

1. 随时中断的框架设计，第一优先级渲染用户最关注的 UI 交互模块。
2. 从后端到前端 “顺滑” 的管道式 SSR，并将 hydration 过程按需化，且支持被更高优先级用户交互行为打断，第一优先水合用户正在交互的部分。

> 讨论地址是：[精读《React 18》· Issue #336 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/336)

**如果你想参与讨论，请 [点击这里](https://github.com/dt-fe/weekly)，每周都有新的主题，周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**

> 关注 **前端精读微信公众号**

<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">

> 版权声明：自由转载-非商用-非衍生-保持署名（[创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)）
