在 2021 年 6 月 8 号,React 公布了 v18 版本的发布计划,并发布了 alpha 版本。经过将近一年的发布前准备,在 2022 年 3 月 29 日,React 18 正式版终于和大家见面了。
React 18 应该是最近几年的一个重磅版本,React 官方对它寄予了厚望。不然也不会将 React 17 作为一个过渡版本,也不会光发布准备工作就做了一年。
在过去一年,我们已经或多或少了解到一些 React 18 的新功能。这篇文章我会通过丰富的示例,向大家系统的介绍 React 18 带来的改变。当然本文融入了很多个人理解,如有不对,烦请指正。
Concurrent Mode
Concurrent Mode(以下简称 CM)翻译叫并发模式,这个概念我已经听了好多年了,并且一度非常担忧
- React 官方憋了好多年的大招,会不会是一个破坏性不兼容的超级大版本?就像 VUE v3 和 v2。
- 现有的生态是不是都得跟着大版本升级?比如 ant design,ahooks 等。
随着对 CM 的了解,我发现它其实是人畜无害的。
CM 本身并不是一个功能,而是一个底层设计,它使 React 能够同时准备多个版本的 UI。
在以前,React 在状态变更后,会开始准备虚拟 DOM,然后渲染真实 DOM,整个流程是串行的。一旦开始触发更新,只能等流程完全结束,期间是无法中断的。
在 CM 模式下,React 在执行过程中,每执行一个 Fiber,都会看看有没有更高优先级的更新,如果有,则当前低优先级的的更新会被暂停,待高优先级任务执行完之后,再继续执行或重新执行。
CM 模式有点类似计算机的多任务处理,处理器在同时进行的应用程序之间快速切换,也许 React 应该改名叫 ReactOS 了。
这里举个例子:我们正在看电影,这时候门铃响了,我们要去开门拿快递。在 React 18 以前,一旦我们开始看电影,就不能被终止,必须等电影看完之后,才会去开门。而在 React 18 CM 模式之后,我们就可以暂停电影,等开门拿完快递之后,再重新继续看电影。
不过对于普通开发者来说,我们一般是不会感知到 CM 的存在的,在升级到 React 18 之后,我们的项目不会有任何变化。
我们需要关注的是基于 CM 实现的上层功能,比如 Suspense、Transitions、streaming server rendering(流式服务端渲染), 等等。
React 18 的大部分功能都是基于 CM 架构实现出来的,并且这这是一个开始,未来会有更多基于 CM 实现的高级能力。
startTransition
我们如果要主动发挥 CM 的优势,那就离不开 startTransition。
React 的状态更新可以分为两类:
- 紧急更新(Urgent updates):比如打字、点击、拖动等,需要立即响应的行为,如果不立即响应会给人很卡,或者出问题了的感觉
- 过渡更新(Transition updates):将 UI 从一个视图过渡到另一个视图。不需要即时响应,有些延迟是可以接受的。
我以前会认为,CM 模式会自动帮我们区分不同优先级的更新,一键无忧享受。很遗憾的是,CM 只是提供了可中断的能力,默认情况下,所有的更新都是紧急更新。
这是因为 React 并不能自动识别哪些更新是优先级更高的。
const [inputValue, setInputValue] = useState();
const onChange = (e)=>{
setInputValue(e.target.value);
// 更新搜索列表
setSearchQuery(e.target.value);
}
return (
<input value={inputValue} onChange={onChange} />
)
比如以上示例,用户的键盘输入操作后,setInputValue会立即更新用户的输入到界面上,是紧急更新。而setSearchQuery是根据用户输入,查询相应的内容,是非紧急的。
但是 React 确实没有能力自动识别。所以它提供了 startTransition让我们手动指定哪些更新是紧急的,哪些是非紧急的。
// 紧急的
setInputValue(e.target.value);
startTransition(() => {
setSearchQuery(input); // 非紧急的
});
如上代码,我们通过 startTransition来标记一个非紧急更新,让该状态触发的变更变成低优先级的。
光用文字描述大家可能没有体验,接下来我们通过一个示例来认识下可中断渲染对性能的爆炸提升。
示例页面:https://react-fractals-git-react-18-swizec.vercel.app/[1]。
如下图,我们需要画一个毕达哥拉斯树,通过一个 Slider 来控制树的倾斜。
那我们的代码会很简单,如下所示,我们只需要一个 treeLeanstate 来管理状态。
const [treeLean, setTreeLean] = useState(0)
function changeTreeLean(event) {
const value = Number(event.target.value);
setTreeLean(value);
}
return (
<>
<input type="range" value={treeLean} onChange={changeTreeLean} />
<Pythagoras lean={treeLean} />
</>
)
在每次 Slider 拖动后,React 执行流程大致如下:
- 更新 treeLean。
- 渲染 input,填充新的 value。
- 重新渲染树组件 Pythagoras。
每一次用户拖动 Slider,都会同步执行上述三步。但当树的节点足够多的时候,Pythagoras 渲染一次就非常慢,就会导致 Slider 的 value 回填变慢,用户感觉到严重的卡顿。如下图。
当数的节点足够大时,已经卡到爆炸了。在 React 18 以前,我们是没有什么好的办法来解决这个问题的。但基于 React 18 CM 的可中断渲染机制,我们可以将树的更新渲染标记为低优先级的,就不会感觉到卡顿了。
const [treeLeanInput, setTreeLeanInput] = useState(0);
const [treeLean, setTreeLean] = useState(0);
function changeTreeLean(event) {
const value = Number(event.target.value);
setTreeLeanInput(value)
// 将 treeLean 的更新用 startTransition 包裹
React.startTransition(() => {
setTreeLean(value);
});
}
return (
<>
<input type="range" value={treeLeanInput} onChange={changeTreeLean} />
<Pythagoras lean={treeLean} />
</>
)
以上代码,我们通过 startTransition 标记了非紧急更新,让树的更新变成低优先级的,可以被随时中止,保证了高优先级的 Slider 的体验。
此时更新流程变为了:
input 更新:
- treeLeanInput 状态变更。
- 准备新的 DOM。
- 渲染 DOM。
树更新(这一次更新是低优先级的,随时可以被中止):
- treeLean 状态变更。
- 准备新的 DOM。
- 渲染 DOM。
React 会在高优先级更新渲染完成之后,才会启动低优先级更新渲染,并且低优先级渲染随时可被其它高优先级更新中断。
当然,在低优先状态等待更新过程中,如果能有一个 Loading 状态,那就更好了。React 18 提供了 useTransition来跟踪 transition 状态。
const [treeLeanInput, setTreeLeanInput] = useState(0);
const [treeLean, setTreeLean] = useState(0);
// 实时监听 transition 状态
const [isPending, startTransition] = useTransition();
function changeTreeLean(event) {
const value = Number(event.target.value);
setTreeLeanInput(value)
React.startTransition(() => {
setTreeLean(value);
});
}
return (
<>
<input type="range" value={treeLeanInput} onChange={changeTreeLean} />
<Spin spinning={isPending}>
<Pythagoras lean={treeLean} />
</Spin>
</>
)
自动批处理 Automatic Batching
批处理是指 React 将多个状态更新,聚合到一次 render 中执行,以提升性能。比如:
function handleClick() {
setCount(c => c + 1);
setFlag(f => !f);
// React 只会 re-render 一次,这就是批处理
}
在 React 18 之前,React 只会在事件回调中使用批处理,而在 Promise、setTimeout、原生事件等场景下,是不能使用批处理的。
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// React 会 render 两次,每次 state 变化更新一次
}, 1000);
而在 React 18 中,所有的状态更新,都会自动使用批处理,不关心场景。
function handleClick() {
setCount(c => c + 1);
setFlag(f => !f);
// React 只会 re-render 一次,这就是批处理
}
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// React 只会 re-render 一次,这就是批处理
}, 1000);
如果你在某种场景下不想使用批处理,你可以通过 flushSync来强制同步执行(比如:你需要在状态更新后,立刻读取新 DOM 上的数据等。)
import { flushSync } from 'react-dom';
function handleClick() {
flushSync(() => {
setCounter(c => c + 1);
});
// React 更新一次 DOM
flushSync(() => {
setFlag(f => !f);
});
// React 更新一次 DOM
}
React 18 的批处理在绝大部分场景下是没有影响,但在 Class 组件中,如果你在两次 setState 中间读取了 state 值,会出现不兼容的情况,如下示例。
handleClick = () => {
setTimeout(() => {
this.setState(({ count }) => ({ count: count + 1 }));
// 在 React17 及之前,打印出来是 { count: 1, flag: false }
// 在 React18,打印出来是 { count: 0, flag: false }
console.log(this.state);
this.setState(({ flag }) => ({ flag: !flag }));
});
};
当然你可以通过 flushSync来修正它。
handleClick = () => {
setTimeout(() => {
ReactDOM.flushSync(() => {
this.setState(({ count }) => ({ count: count + 1 }));
});
// 在 React18,打印出来是 { count: 1, flag: false }
console.log(this.state);
this.setState(({ flag }) => ({ flag: !flag }));
});
};
流式 SSR
SSR 一次页面渲染的流程大概为:
- 服务器 fetch 页面所需数据。
- 数据准备好之后,将组件渲染成 string 形式作为 response 返回。
- 客户端加载资源。
- 客户端合成(hydrate)最终的页面内容。
在传统的 SSR 模式中,上述流程是串行执行的,如果其中有一步比较慢,都会影响整体的渲染速度。
而在 React 18 中,基于全新的 Suspense,支持了流式 SSR,也就是允许服务端一点一点的返回页面。
假设我们有一个页面,包含了 NavBar、Sidebar、Post、Comments 等几个部分,在传统的 SSR 模式下,我们必须请求到 Post 数据,请求到 Comments 数据后,才能返回完整的 HTML。
<main>
<nav>
<!--NavBar -->
<a href="/">Home</a>
</nav>
<aside>
<!-- Sidebar -->
<a href="/profile">Profile</a>
</aside>
<article>
<!-- Post -->
<p>Hello world</p>
</article>
<section>
<!-- Comments -->
<p>First comment</p>
<p>Second comment</p>
</section>
</main>
但如果 Comments 数据请求很慢,会拖慢整个流程。
在 React 18 中,我们通过 Suspense包裹,可以告诉 React,我们不需要等这个组件,可以先返回其它内容,等这个组件准备好之后,单独返回。
<Layout>
<NavBar />
<Sidebar />
<RightPane>
<Post />
<Suspense fallback={<Spinner />}>
<Comments />
</Suspense>
</RightPane>
</Layout>
如上,我们通过 Suspense包裹了 Comments 组件,那服务器首次返回的 HTML 是下面这样的,<Comments />组件处通过 loading进行了占位。
<main>
<nav>
<!--NavBar -->
<a href="/">Home</a>
</nav>
<aside>
<!-- Sidebar -->
<a href="/profile">Profile</a>
</aside>
<article>
<!-- Post -->
<p>Hello world</p>
</article>
<section id="comments-spinner">
<!-- Spinner -->
<img width=400 src="spinner.gif" alt="Loading..." />
</section>
</main>
当<Comments /> 组件准备好之后,React 会通过同一个流(stream)发送给浏览器(res.send 替换成 res.socket),并替换到相应位置。
<div hidden id="comments">
<!-- Comments -->
<p>First comment</p>
<p>Second comment</p>
</div>
<script>
// This implementation is slightly simplified
document.getElementById('sections-spinner').replaceChildren(
document.getElementById('comments')
);
</script>
更多关于流式 SSR 的讲解可见:https://github.com/reactwg/react-18/discussions/37[2]
Server Component
Server Component 叫服务端组件,目前还在开发过程中,没有正式发布,不过应该很快就会和我们见面的。
Server Component 的本质就是由服务端生成 React 组件,返回一个 DSL 给客户端,客户端解析 DSL 并渲染该组件。
Server Component 带来的优势有:
零客户端体积,运行在服务端的组件只会返回最终的 DSL 信息,而不包含其他任何依赖。
// NoteWithMarkdown.js
import marked from 'marked'; // 35.9K (11.2K gzipped)
import sanitizeHtml from 'sanitize-html'; // 206K (63.3K gzipped)
function NoteWithMarkdown({text}) {
const html = sanitizeHtml(marked(text));
return (/* render */);
}
假设我们有一个 markdown 渲染组件,以前我们需要将依赖 marked和 sanitize-html打包到 JS 中。如果该组件在服务端运行,则最终返回给客户端的是转换完成的文本。
组件拥有完整的服务端能力。
由于 Server Component 在服务端执行,拥有了完整的 NodeJS 的能力,可以访问任何服务端 API。
// Note.server.js - Server Component
import fs from 'react-fs';
function Note({id}) {
const note = JSON.parse(fs.readFile(`${id}.json`));
return <NoteWithMarkdown note={note} />;
}
组件支持实时更新
由于 Server Component 在服务端执行,理论上支持实时更新,类似动态 npm 包,这个还是有比较大的想象空间的。也许 React Component as a service 时代来了。
当然说了这么多好处,Server Component 肯定也是有一些局限性的:
不能有状态,也就是不能使用 state、effect 等,那么更适合用在纯展示的组件,对性能要求较高的一些前台业务
- 不能访问浏览器的 API。
- props 必须能被序列化。
- OffScreen。
OffScreen
目前也在开发中,会在未来某个版本中发布。但我们非常有必要提前认识下它,因为你现在的代码很可能已经有问题了。
OffScreen 支持只保存组件的状态,而删除组件的 UI 部分。可以很方便的实现预渲染,或者 Keep Alive。比如我们在从 tabA 切换到 tabB,再返回 tabA 时,React 会使用之前保存的状态恢复组件。
为了支持这个能力,React 要求我们的组件对多次安装和销毁具有弹性。那什么样的代码不符合弹性要求呢?其实不符合要求的代码很常见。
async function handleSubmit() {
setPending(true)
await post('/someapi') // component might unmount while we're waiting
setPending(false)
}
在上面的代码中,如果发送请求时,组件卸载了,会抛出警告。
Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.
警告:不能在已经卸载的组件中更改 state。这是一个无用的操作,它表明你的项目中存在内存泄漏。要解决这个问题,请在 useEffect 清理函数中取消所有订阅和异步任务。
所以我们一般都会通过一个 unmountRef来标记当前组件是否卸载,以避免所谓的「内存泄漏」。
function SomeButton(){
const [pending, setPending] = useState(false)
const unmountRef = useUnmountedRef();
async function handleSubmit() {
setPending(true)
await post('/someapi')
if (!unmountRef.current) {
setPending(false)
}
}
return (
<Button onClick={handleSubmit} loading={pending}>
提交
</Button>
)
}
我们来模拟执行一次组件,看看组件的变化状态:
- 首次加载时,组件的状态为:pending = false。
- 点击按钮后,组件的状态会变为:pending = true。
- 假如我们在请求过程中卸载了组件,那此时的状态会变为:pending = true。
在 OffScreen 中,React 会保存住最后的状态,下次会用这些状态重新渲染组件。惨了,此时我们发现重新渲染组件一直在 loading。
怎么解决?解决办法很简单,就是回归最初的代码,删掉 unmountRef的逻辑。至于「内存泄漏」的警告,React 18 删除了,因为这里不存在内存泄漏(参考:https://mp.weixin.qq.com/s/fgT7Kxs_0feRx4TkBe6G5Q)。
async function handleSubmit() {
setPending(true)
await post('/someapi')
setPending(false)
}
为了方便排查这类问题,在 React 18 的 Strict Mode 中,新增了 double effect,在开发模式下,每次组件初始化时,会自动执行一次卸载,重载。
* React mounts the component.
* Layout effects are created.
* Effects are created.
* React simulates unmounting the component.
* Layout effects are destroyed.
* Effects are destroyed.
* React simulates mounting the component with the previous state.
* Layout effects are created.
* Effects are created.
这里还是要再提示下:开发环境,在 React 18 的严格模式下,组件初始化的 useEffect 会执行两次,也就是可能 useEffect 里面的请求被执行了两次等。
新 Hooks
useDeferredValue
const deferredValue = useDeferredValue(value);
useDeferredValue 可以让一个 state 延迟生效,只有当前没有紧急更新时,该值才会变为最新值。useDeferredValue 和 startTransition 一样,都是标记了一次非紧急更新。
之前 startTransition 的例子,就可以用 useDeferredValue来实现。
const [treeLeanInput, setTreeLeanInput] = useState(0);
const deferredValue = useDeferredValue(treeLeanInput);
function changeTreeLean(event) {
const value = Number(event.target.value);
setTreeLeanInput(value)
}
return (
<>
<input type="range" value={treeLeanInput} onChange={changeTreeLean} />
<Pythagoras lean={deferredValue} />
</>
)
useId
const id = useId();
支持同一个组件在客户端和服务端生成相同的唯一的 ID,避免 hydration 的不兼容。原理是每个 id 代表该组件在组件树中的层级结构。
useSyncExternalStore
const state = useSyncExternalStore(subscribe, getSnapshot[, getServerSnapshot]);
useSyncExternalStore 能够让 React 组件在 Concurrent Mode 下安全地有效地读取外接数据源。在 Concurrent Mode 下,React 一次渲染会分片执行(以 fiber 为单位),中间可能穿插优先级更高的更新。假如在高优先级的更新中改变了公共数据(比如 redux 中的数据),那之前低优先的渲染必须要重新开始执行,否则就会出现前后状态不一致的情况。useSyncExternalStore 一般是三方状态管理库使用,一般我们不需要关注。
useInsertionEffect
useInsertionEffect(didUpdate);
这个 Hooks 只建议 css-in-js库来使用。这个 Hooks 执行时机在 DOM 生成之后,useLayoutEffect 生效之前,一般用于提前注入<style>脚本。
如何升级到 React 18
参考:https://mp.weixin.qq.com/s/2QYEmFlIIMQkXR-Q9DVG2Q。
参考资料
[1]https://react-fractals-git-react-18-swizec.vercel.app/: https://react-fractals-git-react-18-swizec.vercel.app/。
[2]https://github.com/reactwg/react-18/discussions/37: https://github.com/reactwg/react-18/discussions/37。