2

精读《React 18》

 3 years ago
source link: https://zhuanlan.zhihu.com/p/388891604
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

精读《React 18》

前端开发话题下的优秀答主

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

下面是几个关键信息:

精读

总的来说,React 18 带来了 3 大新特性:

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

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

Automatic batching

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

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

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

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

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

而 React 18 带来的优化便是,任何情况都可以合并渲染了!即使在 promisetimeout 或者 event 回调中调用多次 setState,也都会合并为一次渲染:

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

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

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

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

新的 ReactDOM Render API

升级方式很简单:

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 代替:

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。

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

import { startTransition } from "react";

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

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

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

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

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 可以看 这个例子

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

那么 React 18 的 SSR 到底有怎样的效果呢?这篇介绍文档 的图建议看一看,非常直观,这里我简要描述一下:

  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

如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。

关注 前端精读微信公众号

v2-d46b608443dd2afa3dea21b96236fa06_720w.jpg

版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK