|
| 1 | +React 18 带来了几个非常实用的新特性,同时也没有额外的升级成本,值得仔细看一看。 |
| 2 | + |
| 3 | +下面是几个关键信息: |
| 4 | + |
| 5 | +- [React 18 工作小组](https://github.com/reactwg/react-18)。利用社区讨论 React 18 发布节奏与新特性。 |
| 6 | +- [发布计划](https://reactjs.org/blog/2021/06/08/the-plan-for-react-18.html)。目前还没有正式发布,不过 `@alpha` 版已经可用了,[安装 alpha 版](https://github.com/reactwg/react-18/discussions/9)。 |
| 7 | +- [React 18 新特性介绍](https://github.com/reactwg/react-18/discussions/4)。虽然还未正式发布,但特性介绍可以先行,本周精读主要就是解读这篇文档。 |
| 8 | + |
| 9 | +## 精读 |
| 10 | + |
| 11 | +总的来说,React 18 带来了 3 大新特性: |
| 12 | + |
| 13 | +- Automatic batching。 |
| 14 | +- Concurrent APIS。 |
| 15 | +- SSR for Suspense。 |
| 16 | + |
| 17 | +同时为了开启新的特性,需要进行简单的 `render` 函数升级。 |
| 18 | + |
| 19 | +### Automatic batching |
| 20 | + |
| 21 | +batching 是指,React 可以将回调函数中多个 `setState` 事件合并为一次渲染。 |
| 22 | + |
| 23 | +也就是说,`setState` 并不是实时修改 State 的,而将多次 `setState` 调用合并起来仅触发一次渲染,既可以减少程序数据状态存在中间值导致的不稳定性,也可以提升渲染性能。可以理解为如下代码所示: |
| 24 | + |
| 25 | +```typescript |
| 26 | +function handleClick() { |
| 27 | + setCount((c) => c + 1); |
| 28 | + setFlag((f) => !f); |
| 29 | + // 仅触发一次渲染 |
| 30 | +} |
| 31 | +``` |
| 32 | + |
| 33 | +但可惜的是,React 18 以前,如果在回调函数的异步调用中执行 `setState`,由于丢失了上下文,无法做合并处理,所以每次 `setState` 调用都会立即触发一次重渲染: |
| 34 | + |
| 35 | +```typescript |
| 36 | +function handleClick() { |
| 37 | + // React 18 以前的版本 |
| 38 | + fetch(/*...*/).then(() => { |
| 39 | + setCount((c) => c + 1); // 立刻重渲染 |
| 40 | + setFlag((f) => !f); // 立刻重渲染 |
| 41 | + }); |
| 42 | +} |
| 43 | +``` |
| 44 | + |
| 45 | +而 React 18 带来的优化便是,任何情况都可以合并渲染了!即使在 `promise`、`timeout` 或者 `event` 回调中调用多次 `setState`,也都会合并为一次渲染: |
| 46 | + |
| 47 | +```typescript |
| 48 | +function handleClick() { |
| 49 | + // React 18+ |
| 50 | + fetch(/*...*/).then(() => { |
| 51 | + setCount((c) => c + 1); |
| 52 | + setFlag((f) => !f); |
| 53 | + // 仅触发一次渲染 |
| 54 | + }); |
| 55 | +} |
| 56 | +``` |
| 57 | + |
| 58 | +当然如果你非要 `setState` 调用后立即重渲染也行,只需要用 `flushSync` 包裹: |
| 59 | + |
| 60 | +```typescript |
| 61 | +function handleClick() { |
| 62 | + // React 18+ |
| 63 | + fetch(/*...*/).then(() => { |
| 64 | + ReactDOM.flushSync(() => { |
| 65 | + setCount((c) => c + 1); // 立刻重渲染 |
| 66 | + setFlag((f) => !f); // 立刻重渲染 |
| 67 | + }); |
| 68 | + }); |
| 69 | +} |
| 70 | +``` |
| 71 | + |
| 72 | +开启这个特性的前提是,将 `ReactDOM.render` 替换为 `ReactDOM.createRoot` 调用方式。 |
| 73 | + |
| 74 | +### 新的 ReactDOM Render API |
| 75 | + |
| 76 | +升级方式很简单: |
| 77 | + |
| 78 | +```typescript |
| 79 | +const container = document.getElementById("app"); |
| 80 | + |
| 81 | +// 旧 render API |
| 82 | +ReactDOM.render(<App tab="home" />, container); |
| 83 | + |
| 84 | +// 新 createRoot API |
| 85 | +const root = ReactDOM.createRoot(container); |
| 86 | +root.render(<App tab="home" />); |
| 87 | +``` |
| 88 | + |
| 89 | +API 修改的主要原因还是语义化,即当我们多次调用 `render` 时,不再需要重复传入 `container` 参数,因为在新的 API 中,`container` 已经提前绑定到 `root` 了。 |
| 90 | + |
| 91 | +`ReactDOM.hydrate` 也被 `ReactDOM.hydrateRoot` 代替: |
| 92 | + |
| 93 | +```typescript |
| 94 | +const root = ReactDOM.hydrateRoot(container, <App tab="home" />); |
| 95 | +// 注意这里不用调用 root.render() |
| 96 | +``` |
| 97 | + |
| 98 | +这样的好处是,后续如果再调用 `root.render(<Appx />)` 进行重渲染,我们不用关心这个 `root` 来自 `createRoot` 或者 `hydrateRoot`,因为后续 API 行为表现都一样,减少了理解成本。 |
| 99 | + |
| 100 | +### Concurrent APIS |
| 101 | + |
| 102 | +首先要了解 Concurrent Mode 是什么。 |
| 103 | + |
| 104 | +简单来说,Concurrent Mode 就是一种可中断渲染的设计架构。什么时候中断渲染呢?当一个更高优先级渲染到来时,通过放弃当前的渲染,立即执行更高优先级的渲染,换来视觉上更快的响应速度。 |
| 105 | + |
| 106 | +有人可能会说,不对啊,中断渲染后,之前渲染的 CPU 执行不就浪费了吗,换句话说,整体执行时常增加了。这句话是对的,但实际上用户对页面交互及时性的感知是分为两种的,第一种是即时输入反馈,第二种是这个输入带来的副作用反馈,比如更新列表。其中,即使输入反馈只要能优先满足,即便副作用反馈更慢一些,也会带来更好的体验,更不用说副作用反馈大部分情况会因为即使输入反馈的变化而作废。 |
| 107 | + |
| 108 | +由于 React 将渲染 DOM 树机制改为两个双向链表,并且渲染树指针只有一个,指向其中一个链表,因此可以在更新完全发生后再切换指针指向,而在指针切换之前,随时可以放弃对另一颗树的修改。 |
| 109 | + |
| 110 | +以上是背景输入。React 18 提供了三个新的 API 支持这一模式,分别是: |
| 111 | + |
| 112 | +- startTransition。 |
| 113 | +- useDeferredValue。 |
| 114 | +- <SuspenseList>。 |
| 115 | + |
| 116 | +后两个文档还未放出,所以本文只介绍第一个 API:startTransition。首先看一下用法: |
| 117 | + |
| 118 | +```typescript |
| 119 | +import { startTransition } from "react"; |
| 120 | + |
| 121 | +// 紧急更新: |
| 122 | +setInputValue(input); |
| 123 | + |
| 124 | +// 标记回调函数内的更新为非紧急更新: |
| 125 | +startTransition(() => { |
| 126 | + setSearchQuery(input); |
| 127 | +}); |
| 128 | +``` |
| 129 | + |
| 130 | +简单来说,就是被 `startTransition` 回调包裹的 `setState` **触发的渲染** 被标记为不紧急的渲染,这些渲染可能被其他紧急渲染所抢占。 |
| 131 | + |
| 132 | +比如这个例子,当 `setSearchQuery` 更新的列表内容很多,导致渲染时 CPU 占用 100% 时,此时用户又进行了一个输入,即触发了由 `setInputValue` 引起的渲染,此时由 `setSearchQuery` 引发的渲染会立刻停止,转而对 `setInputValue` 渲染进行支持,这样用户的输入就能快速反映在 UI 上,代价是搜索列表响应稍慢了一些。而一个 `transition` 被打断的状态可以通过 `isPending` 访问到: |
| 133 | + |
| 134 | +```typescript |
| 135 | +import { useTransition } from "react"; |
| 136 | +const [isPending, startTransition] = useTransition(); |
| 137 | +``` |
| 138 | + |
| 139 | +其实这比较符合操作系统的设计理念,我们知道在操作系统是通过中断响应底层硬件事件的,中断都非常紧急(因为硬件能存储的消息队列非常有限,操作系统不能即使响应,硬件的输入可能就丢失了),因此要支持抢占式内核,并在中断到来时立刻执行中断(可能把不太紧急的操作放到下半部执行)。 |
| 140 | + |
| 141 | +对前端交互来说,用户角度发出的 “中断” 一般来自键盘或鼠标的操作,但不幸的是,前端框架甚至是 JS 都过于上层,它们无法自动识别: |
| 142 | + |
| 143 | +1. 哪些代码是紧急中断产生的。比如 `onClick` 就一定是用户鼠标点击产生的吗?不一定,可能是 `xxx.onClick` 主动触发的,而非用户触发。 |
| 144 | +2. 用户触发的就一定是紧急中断吗?不一定,比如键盘输入后,`setInputValue` 是紧急的,而更新查询列表的 `setSearchQuery` 就是非紧急的。 |
| 145 | + |
| 146 | +我们要理解到前端场景对用户操作感知的局限性,才能理解为什么必须手动指定更新的紧急程度,而不能像操作系统一样,上层程序无需感知中断的存在。 |
| 147 | + |
| 148 | +### SSR for Suspense |
| 149 | + |
| 150 | +完整名称是:Streaming SSR with selective hydration。 |
| 151 | + |
| 152 | +即像水流一样,打造一个从服务端到客户端持续不断的渲染管线,而不是 `renderToString` 那样一次性渲染机制。selective hydration 表示选择性水合,水合指的是后端内容打到前端后,JS 需要将事件绑定其上,才能响应用户交互或者 DOM 更新行为,而在 React 18 之前,这个操作必须是整体性的,而水合过程可能比较慢,会引起全局的卡顿,所以选择性水合可以按需优先进行水合。 |
| 153 | + |
| 154 | +所以这个特性其实是转为 SSR 准备的,而功能启用载体就是 Suspense(所以以后不要再认为 Suspense 只是一个 loading 作用)。其实在 Suspense 设计之初,就是为了解决服务端渲染问题,只是一开始只实装了客户端测的按需加载功能,后面你会逐渐发现 React 团地逐渐赋予了 Suspense 更多强大能力。 |
| 155 | + |
| 156 | +SSR for Suspense 解决三个主要问题: |
| 157 | + |
| 158 | +- SSR 模式下,如果不同模块取数效率不同,会因为最慢的一个模块拖慢整体 HTML 吞吐时间,这可能导致体验还不如非 SSR 来的好。举一个极端情况,假设报表中一个组件依赖了慢查询,需要五分钟数据才能出来,那么 SSR 的后果就是白屏时间拉长到 5 分钟。 |
| 159 | +- 即便 SSR 内容打到了页面上,由于 JS 没有加载完毕,所以根本无法进行 hydration,整个页面处于无法交互状态。 |
| 160 | +- 即便 JS 加载完了,由于 React 18 之前只能进行整体 hydration,可能导致卡顿,导致首次交互响应不及时。 |
| 161 | + |
| 162 | +在 React 18 的 server render 中,只要使用 `pipeToNodeWritable` 代替 `renderToString` 并配合 `Suspense` 就能解决上面三个问题。 |
| 163 | + |
| 164 | +使用 `pipeToNodeWriteable` 可以看 [这个例子](https://codesandbox.io/s/festive-star-9hfqt?file=/server/render.js:1043-1575)。 |
| 165 | + |
| 166 | +最大的区别在于,服务端渲染由简单的 `res.send` 改成了 `res.socket`,这样渲染就从单次行为变成了持续性的行为。 |
| 167 | + |
| 168 | +那么 React 18 的 SSR 到底有怎样的效果呢?[这篇介绍文档](https://github.com/reactwg/react-18/discussions/37) 的图建议看一看,非常直观,这里我简要描述一下: |
| 169 | + |
| 170 | +1. 被 `<Suspense>` 包裹的区块,在服务端渲染时不会阻塞首次吞吐,而且在这个区块准备完毕后(包括异步取数)再实时打到页面中(以 HTML 模式,此时还没有 hydration),在此之前返回的是 `fallback` 的内容。 |
| 171 | +2. hydration 的过程也是逐步的,这样不会导致一下执行所有完整的 js 导致页面卡顿(hydration 其实就是 React 里写的回调注册、各类 Hooks,整个应用的量非常庞大)。 |
| 172 | +3. hydration 因为被拆成多部,React 还会提前监听鼠标点击,并提前对点击区域优先级进行 hydration,甚至能抢占已经在其他区域正在进行中的 hydration。 |
| 173 | + |
| 174 | +那么总结一下,新版 SSR 性能提高的秘诀在于两个字:按需。 |
| 175 | + |
| 176 | +而这个难点在于,SSR 需要后端到前端的配合,在 React 18 之前,后端到前端的过程完全没有优化,而现在将 SSR HTML 的吞吐改成多次,按需,并且水合过程中还支持抢占,因此性能得到进一步提升。 |
| 177 | + |
| 178 | +## 总结 |
| 179 | + |
| 180 | +结合起来看,React 18 关注点在于更快的性能以及用户交互响应效率,其设计理念处处包含了中断与抢占概念。 |
| 181 | + |
| 182 | +以后提起前端性能优化,我们就多了一些应用侧的视角(而不仅仅是工程化视角),从以下两个应用优化视角有效提升交互反馈速度: |
| 183 | + |
| 184 | +1. 随时中断的框架设计,第一优先级渲染用户最关注的 UI 交互模块。 |
| 185 | +2. 从后端到前端 “顺滑” 的管道式 SSR,并将 hydration 过程按需化,且支持被更高优先级用户交互行为打断,第一优先水合用户正在交互的部分。 |
| 186 | + |
| 187 | +> 讨论地址是:[精读《React 18》· Issue #336 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/336) |
| 188 | +
|
| 189 | +**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** |
| 190 | + |
| 191 | +> 关注 **前端精读微信公众号** |
| 192 | +
|
| 193 | +<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg"> |
| 194 | + |
| 195 | +> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) |
0 commit comments