14 min read

Talking about React 18

img


TL;DR

  • Concurrent Mode is an opt-in feature
  • New SSR Architecture
    • Behavioral changes to Suspense
  • State updates in two categories
  • Adding Strict Effects to StrictMode
  • Built-in Suspense Cache
  • Two separate rendering algorithms

Not the Concurrent Mode anymore

Now,the Concurrent Mode is an opt-in feature,called Concurrent Rendering

这也是比较稳妥的渐进方案,毕竟 并发模式 带来的 breaking changes 可能会让社区头痛一阵,反而不利于 React 18 的推广

另外 Concurrent Rendering 不再是全局的改变 React 调度方式,而是需要搭配使用 ReactDOM.createRoot + startTransition API

“简单”来说,React 18 内部存在两种渲染算法:由新 feature 触发的更新会使用并发渲染,而对其他内容的更新会使用旧的渲染算法。在后面会解释这两种算法。

那么利用 Concurrent Rendering 细粒度控制能力,React 带来以下特性:

真实世界的使用方式,例如 Next


New SSR Architecture

React Server Component 和 SSR 是不一样概念,React 18 也不会包含任何这个实验性 Feature

先来看一下常规 SSR 的瀑布流:

fetch data (server) → render to HTML (server) → load code (client)hydrate (client)

整个过程看起来很美好。在 Server 获取需要的数据(更快),将 Data 填充到 HTML 之后发送到 Client,Client 加载所需要的 JS 后进行 hydrate ,为页面添加交互。

但其中也有瑕疵:

  1. 在 Server 端需要等到(所需) Data 全部获得之后才能发送 HTML
  2. 在 Client 端需要加载完成所有 JS 之后才能开始 hydrate
  3. 在整个 Hydration 过程完成后,页面才能交互

React 新的 SSR 架构诞生的背景来自于此,让我们看看 React 是怎么解决的。



2018 年,React 提出了 Suspense ,从那时到 React 18 之前。Suspense 仅仅作为 Client 端的 Lazy-loading 方式被使用,但这显然不是它设计之初的全部目的

  1. delayed transitions
  2. placeholder throttling
  3. SuspenseList

有多少人认为 Suspense 仅仅是一个 fancy loading spinner 😂


React 18 提出了 New Suspense,以取代 Legacy Suspense。

只是在内部讨论的代称。简单来说 React 18 的 Suspense 对比之前是更大的概念。

New Suspense 带来了更细粒度的控制机制,被 <suspense> 包裹的内容将变为一个独立单元,由 React 生态进行控制


而 React 关于 SSR 的 API 也有对应修改,其中增加了 pipeToNodeWritable ,以支持更细粒度的 Suspense 控制以及 Streaming of HTML

新的 SSR 架构源自内部 Fizz 架构:Basic Fizz Architecture

import { pipeToNodeWritable } from 'react-dom/unstable-fizz';

简单来说,新 SSR 架构通过几个方式解决了之前的瑕疵:

  1. Streaming of HTML:可以更早地传送 HTML,而不必等到所有 Data 全部获得之后。简而言之将传送 HTML 这个过程拆分,由编码去决定分成逐个顺序流进行(通过 <Suspense> boundaries)
    • 传送剩余的 HTML 将携带一个 tiny <script> 去操作替换的 Dom 节点
  2. Selective Hydration:包含了 更早地进行 hydrate 以及 hydrate 时选择目标的优先级
    • 通过 React.lazy 将页面内容代码量较多的部分进行拆分,而页面的其余部分可以先进行 hydrate,待这部代码加载完成再进行相应的 hydrate
    • 一般来说需要整个 Hydration 完成之后,页面才能进行交互。但 React 将与用户交互的部分定义为高优先级,会优先进行 hydrate,从而让页面达到更早地可交互状态。

但这里可能会有一个问题,当页面 A 与 B 有联系,maybe Pub/Sub 之类的,A 已完成 hydrate,但 B 正在 hydrate。相关讨论

BTW,如果你对 SSR 的概念没有很好理解的话,可以翻阅 Dan 的这篇讲解 New Suspense SSR Architecture in React 18


Behavioral changes to Suspense

一个 suspended component <ComponentThatSuspends> 的兄弟节点 <Sibling> 的行为在 React 18 之后产生了变化

<Suspense fallback={<Loading />}>
<ComponentThatSuspends />
<Sibling />
</Suspense>
  • 在 React 17,<Sibling> 将会立即被渲染到DOM,触发相应的 effects/lifecycles(但节点会被 React 隐藏)

  • 在 React 18,<Sibling> 不会被渲染也不会出发相应的 effects/lifecycles,直到 <ComponentThatSuspends> 获得了所需的数据

这是因为在之前的 React 版本中,有一个隐含的表现是 组件一旦开始渲染,最终都会完成渲染。

但这在 CM 下并不是这样,组件的渲染是可以被暂停的。而在 CM 之前的 Suspense 实现就包含了一个 React 会跳过 suspended component 去渲染它的兄弟组件,并渲染到真实 Dom Tree,然后在浏览器绘制之前,CSS 设置 display: hidden (:XD)。

这是一种兼容方案,而在 CM 下,这种 trick 就变得多余了,因为 React 可以暂停 Sibling 组件的渲染,直到 suspended data 完成。


Automatic batching for fewer renders

Batching 其实是 React 优化性能的一个手段,将多个状态更新(groups multiple states updates)在单次渲染中批量处理

function App() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
function handleClick() {
setCount(c => c + 1); // Does not re-render yet
setFlag(f => !f); // Does not re-render yet
// React will only re-render once at the end (that's batching!)
}
return (
<div>
<button onClick={handleClick}>Next</button>
<h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
</div>
);
}

这在 React 18 之前已经是一种默认行为,但有经验的 React 开发者应该知道,Batching Update 行为只会发生在触发源来自 React 的情况下

而 React 18 带来了 Automatic Batching ,意味所有的状态更新将会被 Batch,无论触发源是什么

这意味着 Timeouts/Promises/Native events/Any other events 内的状态更新行为将会与 React events 是同样的状态处理方式

Automatic Batching 对于使用 Hooks 的开发者来说并没有影响,因为 state 其实都是当时的 snapshot

对于 Classes 使用者来说,需要注意的一点是:

如果在 React Events 之外的情况进行状态更新,Class 组件会默认的同步进行更新状态,这意味着在 setState 之后将可以获取到更新完成后的状态

handleClick = () => {
setTimeout(() => {
this.setState(({ count }) => ({ count: count + 1 }));
// { count: 1, flag: false }
console.log(this.state);
this.setState(({ flag }) => ({ flag: !flag }));
});
};

而在 React 18 下的行为,如果之前编写的逻辑对这部分有依赖就会有问题,在这个例子中,setTimeout 也将会被 bached,读取状态将会是和 Hooks 中一样的结果

有一个 escape-hatch 可以解决这部分 legacy 的问题,使用 ReactDOM.flushSync 去强制更新

handleClick = () => {
setTimeout(() => {
ReactDOM.flushSync(() => {
this.setState(({ count }) => ({ count: count + 1 }));
});
// { count: 1, flag: false }
console.log(this.state);
this.setState(({ flag }) => ({ flag: !flag }));
});
};

State updates in two categories

React 18 将反应 UI 的状态更新分为两种:

  1. Urgent updates 紧急更新,比如 Typing/dragging/hover
  2. Transition updates 过渡更新,比如 UI 之间的过渡状态

简单说 React 区分不同优先级的 UI 状态更新,是为了让页面反馈在实践取舍下更加自然。

startTransition 可以避免当组件再次被 suspends 时隐藏已存在的内容。换句话说就是 在重新获取新数据时同时展示旧数据的策略模型

import { startTransition } from 'react'
// or import { useTransition } from 'react';
// const [isPending, startTransition] = useTransition();
// Urgent: Show what was typed
setInputValue(input)
// Mark any state updates inside as transitions
startTransition(() => {
// Transition: Show the results
setSearchQuery(input)
})

Adding Strict Effects to StrictMode

简单说 StrictMode 支持了 double-invokes effects (mount -> unmount -> mount),方便开发者提早发现 Effect 中逻辑的缺陷

值得注意的是: double-invokes effects 指的是调用 Effect 后,要先清理第一次的 Effect 后再调用第二次 Effect


Built-in Suspense Cache

使用起来就像这样:

<>
<Cache>
<Toolbar>
<CurrentUserProfilePic />
</Toolbar>
</Cache>
<Cache>
<MessageThread>
<CurrentUserProfilePic />
<CurrentUserProfilePic />
</MessageThread>
</Cache>
</>

简单说 Cache 允许了 状态 和 UI 的不一致性,就像是目前很流行的 Data-fetching Lib 所基于的 stale-while-revalidate 思想

在工作中我也有过类似的需求,最后利用了 react-query 的 cache 机制实现了跨组件的状态同步。但这局限在 state 来自于 Data-fetching,而 Suspense Cache 看起来要将所有的 state 进行缓存。

目前 React 18 alpha 是没有包含 Cache 的,应该是不会被包含在正式的 18版本内,是一个早期的实验性 Feature

有点期待 @sebmarkbage 的分享 "Here and There"

Two separate rendering algorithms

开头提到了 React 18 内部具有两种独立的渲染算法

  • 并发渲染是 React 18 主要的算法。通过使用 startTransition 包裹的更新、使用 useDeferredValue<SuspenseList> 以及 resloved 的 <Suspense> 来触发。这是一种 interruptible 算法,渲染组件树的过程中遇到 suspends 的组件,将跳过这部分(渲染 fallback 内容)继续渲染。同时每隔 5ms 交还控制权给浏览器(查看是否有更高优先级任务需要去做),当渲染结束时,渲染算法去决定及时更新到屏幕 or 稍后更新。
  • 同步渲染是并发渲染的不可中断版本,意味着一旦开始渲染过程将不会被停止,直到更新到屏幕(这在内部被称为 Urgent updates)。这就是 React 应用于所有“其他”更新的算法。

However, there’s a nuance. If you haven’t yet migrated to createRoot and are using the deprecated render which warns, you get the third algorithm:

Temporary synchronous algorithm with additional legacy behavior. It’s the same for all updates. New features don’t work at all in it (they are ignored—for example, all transitions become urgent). This one almost exactly matches what React 17 and earlier are doing. It has no automated batching. It also does many weird contortions for <Suspense> specifically in order to preserve all the legacy guarantees (like that UNSAFE_componentWillMount corresponds 1:1 to componentDidMount). It prevents us from throwing away suspended trees, and prevents us from fixing a number of common application-level Suspense bugs.

Conclusion

React 18 所包含的内容是这几年 React Team 的主要工作内容,CM 作为一个 opt-in 的 feature 被正式引入,与之而来的借助 CM 能力的各种特性,可以看到大多数特性都是围绕 UX 进行设计和作出取舍。

在 React 18 未来 release 之后,React 的重心将转向混合渲染,进一步提升 UX。

Reference