React19 新功能
React Compiler
React19 最大的热度是支持使用 React Compiler。
比如经常看到这样的文章名:震惊!再也不用写 React.memo、useCallback、useMemo 了。
为什么取名为 React Compiler(编译器)
其实 React Compiler 原名是 React Forget(由黄玄在 React Conf 2021 介绍)。
Compiler 定义上指的是对 React 代码进行分析并生成不同但功能等效的代码的程序。
React Compiler 诞生背景
React 中的重新渲染是级联的。每次更改 React 组件中的状态时,都会触发该组件、其内部的每个组件、这些组件内部的组件等的重新渲染,直到组件树的末尾。
一般开发者需要手动告诉 React 哪些组件/函数应该重新渲染/调用来优化重新渲染。但对于一些经验不足的开发者并不一定会关注到或者说正确使用(React.memo、useMemo、useCallback)较难。
什么是 React Compiler
React Compiler 是一款构建时工具(目前以 babel 插件的形式集成在代码中使用,并配套对应的 eslint 插件检测代码转换合法性)。
简单来说,他是在保证渲染无异常下,提升更新性能的工具。React Compiler 目前已在一些生产界面(instagram.com)使用。
React Compiler 功能是自动记忆代码(类似 useMemo、useCallback、React.memo 效果,但是使用的是低级原语),来减少重复计算和渲染成本等。
React Compiler 的记忆化主要包括以下三种:优化重新渲染、记忆昂贵计算、记忆 effects。
优化重新渲染
1 | function FriendList({ friends }) { |
上述代码有三个问题:
- props friends 更新后,
会无意义重新渲染 - onlineCount 更新后,
、 被无意义重新渲染 - friends 改变后
不能复用
优化后的代码:
1 | const MemoizedMessageButton = React.memo(MessageButton); |
可以看到,在优化后代码变得比较混乱,同时给开发者带来了额外的成本和负担
React Compiler 实际编译
我们可以使用在线的代码编译转换工具:React Compiler Playground,来看 React Compiler 编译后的代码。可以看到 React Compiler 生成的仍然是 JSX,如下:
1 | function FriendList(t0) { |
记忆昂贵计算
1 | function expensivelyCalc() { |
注意:React Compiler 只记忆 React 组件和 hooks 且不会在组件和 hooks 间共享
记忆 effects(开放的研究领域)
当之前以某种方式记忆(手动记忆)的不再以完全相同的方式记忆(引入 React Compiler 后)时,这可能会导致问题。(即将记忆结果作为依赖项用于 useEffect、useLayoutEffect 的 dependency 中,这可能会影响 effect 执行过度/不足/甚至无限循环)
目前,React Compiler 会静态验证自动记忆是否与任何现有的手动记忆相匹配。如果无法证明它们相同,则会安全地跳过该组件或钩子。
因此,在实际开发中建议保留现存的 useMemo()、useCallback(),写新代码时建议不依赖任何 useMemo()、useCallback。
总结
React Compiler 建立的基础是 React 组件在相同输入(props)获得相同输出(JSX)。即每个组件都是可独立静态分析的模块,不需要考虑之间的联系。
通过以上分析可以发现 React Compiler 可能会存在一些问题:
- 内存占用。记忆化和缓存通常意味着用内存换取处理能力。如果在应用中处理大量数据,可能需要注意关注设备内存情况。
- 调试问题。编译是编写代码和实际运行代码的一个抽象,在 React Compiler 编译后会让我们的代码调试成本提升(需要充分了解 React Compiler 工作原理)。
同时 React Compiler 团队应该处理好性能优化和破坏性变更之间的平衡。就前所述,一些场景会跳过该组件或钩子,以保证原来代码的正确执行。程序员 Nadia Makarevich 有做过性能提升相关测试(详情可见文章),React Compiler 实际在性能优化上表现比较保守。
如果你想了解更多 React Compiler 相关的信息可以观看视频。
New Features
Client API
Actions
Client API 主要在引入 Actions(使用 async transitions 的方法)概念,核心场景是异步表单提交
1 | // Before |
Asynchronous transitions
可以在 startTransition 中使用 async
1
2
3startTransition(async () => {
await updateData();
};useActionState
新 hook,根据 actions 来更新 state,处理 Actions 的常见情况
1
2
3
4const [state, submitAction, isPending] = useActionState(
actionFunction,
initialState
);action and formAction props
自动管理表单项(inputs、buttons),将 action 整合于表单中
1
2<form action={action} >
<button formAction={action}>useFormStatus
获取表单 actions 的状态,有点类似 antd 的 const [form] = Form.useForm();1
const { data, pending, method, action } = useFormStatus();
useOptimistic
在 action 完成前更新 UI,即实现乐观更新(一种用户体验优化策略,常用于客户端应用中,在与服务器交互时立即更新 UI,假设操作会成功,从而提供即时反馈,即使实际的服务器响应可能稍后才到达。如果操作失败,再回滚到之前的状态)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25// 在updateName请求进行时立即渲染optimisticName。当更新完成或出错时,React将自动切换回currentName值。
function ChangeName({ currentName, onUpdateName }) {
const [optimisticName, setOptimisticName] = useOptimistic(currentName);
const submitAction = async (formData) => {
const newName = formData.get("name");
setOptimisticName(newName);
const updatedName = await updateName(newName);
onUpdateName(updatedName);
};
return (
<form action={submitAction}>
<p>Your name is: {optimisticName}</p>
<p>
<label>Change Name:</label>
<input
type="text"
name="name"
disabled={currentName !== optimisticName}
/>
</p>
</form>
);
}
其它更新:
use
非 hook(仅能在 render 中调用,不同于 hook 可以在条件判断中使用),用于消费资源(官方说未来会支持更多资源类型),目前在 react19 可以读 promise 和 context 的值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27// use promise
function Comments({ promise }) {
// 使用use会等待promise被resolve,在未被resolve前显示suspense
const resolvedData = use(promise);
return <span>{resolvedData}</span>;
}
function Page({ promise }) {
return (
<Suspense fallback={<div>Loading...</div>}>
<Comments promise={promise} />
</Suspense>
);
}
// use context
import ThemeContext from "./ThemeContext";
function Heading({ children }) {
if (children == null) {
return null;
}
// useContext在前置返回时异常
const theme = use(ThemeContext);
return <h1 style={{ color: theme.color }}>{children}</h1>;
}Preloading APIs
支持加载和预加载浏览器资源
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17// From
function Component() {
preinit("https://.../script.js", { as: "script" });
preload("https://.../stylesheet.css", { as: "style" });
prefetchDNS("https://../");
preconnect("https://.../");
return ...
}
// To
<head>
<link ref="prefetch-dns" href="https://..." />
<link ref="preconnect" href="https://..." />
<link ref="preload" as="font" href="https://.../font.woff" />
<script async="" src="http://.../script.js"></script>
</head>
Improvements(改进)
ref as a props
使用 ref 作为函数式组件的 props,未来将废除移除 forwardRef
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20// Before
import { forwardRef } from "react";
const Input = forwardRef((props, ref) => {
return <input ref={ref} ... />
})
// After
function Input({ ref }) {
return <input ref={ref} ... />
}
// 可以支持一些事件监听等,来优化内存
<div
ref={ref => {
...
retrun () => {
ref.removeEventHandler('change', handleInputChange)
}
}}
/>Document metadata & Stylesheet support
能够在组件中使用文档 metadata 的标签或者渲染样式表(stylesheet)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17// From
function BlogPost({ post }) {
return (
<title>{post.title}</title>
<meta name="author" content={post.author} />
<meta property="og:image" content={post.image} />
)
}
// To
<html>
<head>
<title>xxx</title>
<meta name="author" content="xxx" />
<meta name="og:image" content="xxx" />
</head>
</html>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24// From
function Component() {
return (
<div>
<link rel="stylesheet" href="/styles/styles.css" precedence="default" />
<link rel="stylesheet" href="/styles/button.css" precedence="default" />
<link rel="stylesheet" href="/styles/article.css" precedence="high" />
<article>...</article>
</div>
);
}
// To
<html>
<head>
<link ref="stylesheet" href="/style/styles.css" />
...
</head>
<body>
<div>
<article>...</article>
</div>
</body>
</html>;
Server API(略)
- React Server Component
- Server Actions
React19 总结
React19 的新主要体现在:
- useMemo, useCallback, memo -> React Compiler
- forwardRef -> ref is a prop
- useContext -> use(Context)
- throw promise -> use(promise)
- …
React19 开始支持 React Compiler,主要用于性能优化方面,它有很大的价值,但还有较长的路要走。
React19 围绕“Actions”和“html
”等新增了一些 API 来增加开发者的便利。React19 实际上调整的并不只有这些,如果想要了解 React19 更多内容可以阅读官方文章或者观看React Conf(2024)视频。
项目 React 大版本升级之路
React 各版本情况
目前 React 最新的版本是 React19 RC,最新的正式版是 React 18.3.1。
这里科普一下 React 的一些版本别名:
版本别名 | 解释 |
---|---|
RC | Release Candidate 版本,即发行候选版 |
Canary | 来源于煤矿中用于检测有毒气体的金丝雀,引申意为早期预警系统,即实验性版本 |
NPM 下载量参考(2024.08.12 数据):
https://www.npmjs.com/package/react?activeTab=versions
这里截取了16、17、18、19最受欢迎的三个版本下载量,可以看到React最新的正式版18.3.1是当下最受欢迎的版本,3、4年前的17.0.2和16.14.0仍有很大的使用量(即现有近一半的项目使用的是React18以下的版本)。
选择什么版本升级?
在选择具体版本前需要对 React 和其各版本有一定宏观的了解。
React 状态更新和渲染主要是以下模块实现的:
- Reconciler(协调器):处理虚拟 DOM 与实际 DOM 之间的差异(diff),以确保页面上的元素与虚拟 DOM 的状态保持一致。
- Renderer(渲染器):负责将虚拟 DOM 渲染替换成实际的 DOM,即将 UI 渲染到宿主环境(ReactDOM、ReactNative、ReactArt)。
- Scheduler(调度器):负责调度任务的执行顺序,确保任务按照优先级和合适的时间顺序进入 Reconciler,以实现最佳的性能和用户体验。(Scheduler 在 React16 引入,独立于 React 库https://github.com/facebook/react/blob/1fb18e22ae66fdb1dc127347e169e73948778e5a/packages/scheduler/README.md。类似浏览器requestIdleCallbackAPI,但是由于兼容性等考虑React自己实现了一套)
React15
无 Scheduler,核心是基于虚拟 DOM 进行高效的 UI 渲染
问题:mount 和 update 时会递归更新子组件,当层级深无法中断,造成卡顿(渲染线程和 JS 线程互斥)。
React16
引入了 Scheduler,并将Stack Reconciler ⇒ Fiber Reconciler(不可中断递归 ⇒ 可中断遍历)
核心是引入了 Fiber 架构(这是后续一切可中断的核心),它允许 React 实现优先级调度,以可中断的方式执行工作,从而达到更好的性能。
React17
官方称为”垫脚石”版本。无新架构调整,更多关注于稳定性和向后兼容。
React18
- 最主要更新是引入实验性功能并发模式(Concurrent Mode),Concurrent Mode 不是新功能而是个底层设计,将 React 的更新模式从同步不可中断 ⇒ 异步可中断。
- 引入了一些新功能,例如自动批处理(Automatic Batching),可以自动将多个状态更新批量处理为单个重新渲染,从而提高性能。这是一个 breaking change,但是能在一定程度上减少应用 render。
- 新 hooks:useId、useTransition、useDeferredValue…
- …
React19
如前文所述,目前是 RC 版本,未发布正式
版本升级评估
升级前需要对升级收益和代价进行综合考量,再去判断是否应该升级
- 升级收益: 评估 React 新版本是否提供了一些需要的新功能或性能改进。
- 升级代价: 对现有代码库的影响以及可能需要的迁移工作量。
升级收益
一、性能提升
升级的收益主要体现在性能提升上,而性能提升主要是 React18 自动批处理(Automatic Batching)和并发模式(Concurrent Mode)带来的。
那么 React18 是怎么带来性能提升的?
这里需要对浏览器原理有一定了解。
首先 JavaScript 引擎是在单线程环境(主线程)中执行的。主线程还负责用户交互、网络事件、计时器、动画以及重排和重绘等等。
单线程需要逐个处理任务,当处理某个任务时,所有其他任务都必须等待。这个时候如果遇到较长的长任务(执行时间超过 50ms 的任务都被称为“长任务”),用户则会产生卡顿(阻塞了一些优先级更高的如用户交互任务执行)。
下面有两个例子,一个是使用 useState 管理状态渲染的长列表;一个使用 useTransition(React18 并发 hook)管理状态渲染的长列表。
- 正常使用 useState 表现:https://w4zcct.csb.app/
在 performance 面板中可以看到,每次点击事件都是一个长任务,在点击后有明显阻塞。
React18 引入了一个后台运行的并发渲染器。此渲染器提供了一些方法(useTransition、useDeferredValue)让我们可以将某些渲染标记为非紧急。在非紧急渲染场景下 React 将每 5 ms 返回主线程一次(可用于处理更高优先级的任务,具体处理根据 Scheduler 调度的结果),以查看是否有更重要的任务需要处理,例如用户输入,甚至渲染另一个紧急的 React 组件状态更新。
- 使用 useTransition 的表现:https://r3shnj.csb.app/。
在 performance 面板中可以看到,任务被拆分较多单元,列上并发执行任务增加,阻塞时间明显减少。
在阅读一些国外的升级文章也可以看到从 React16 升级到 18(开启自动批处理和并发)性能提升效果不错:
- 纽约时报团队称在从 React16 升级 React18(开启自动批处理和并发)后重新渲染次数减少了一半,INP(用户首次与页面交互(如单击按钮)到此交互在屏幕上可见(即下一次绘制)的时间,衡量页面响应能)分数下降 30%。
- Airbnb(爱比迎)团队称从React16 升级到 React18,有很好的性能提升。
React18 同时也改进了内存使用:在卸载时清理更多的内部字段,从而减轻应用程序代码中可能存在的未修复内存泄漏的影响。
二、开发相关
- 无需显示导入 React
- React17 前,JSX 会被 Babel 转换成 React.createElement 函数调用
- React17 后,支持自动运行时绑定(Automatic Runtime)
- 新 hooks:userId、useTransition、useDeferredValue(React18)…
- 由于类型变更以及一些不合法的方法废弃,可以让整体项目代码可读、可维护性增强
升级方案 / 代价
React 团队的渐进升级方案
旧“渐进升级”(React17)方案
React 各版本的几种模式:
- Legacy 模式,通过 ReactDOM.render(
, rootNode)创建。 - Blocking 模式(未开启并发模式,但使用了新特性,如 Automatic Batching),通过 ReactDOM.createBlockingRoot(rootNode).render(
)创建。 - Concurrent 模式(开启并发模式),通过 ReactDOM.createRoot(rootNode).render(
)创建。
但是在社区中沟通发现旧“渐进升级”策略并不好:开发者从新架构收益主要由于并发特性(解决 CPU 瓶颈和 I/O 瓶颈),但模式变更影响整个应用,而且应用内也可以互相调用不同的 render 方法。如果将入口文件的 render 方法替换后,会使整个应用模式变更,触发很多并发不兼容的错误。
新“渐进升级”(React18)方案
默认场景使用同步更新,使用并发特性时再开启并发更新。
这对我们带来的好处是如果项目升级了 React18 但未使用并发更新的一些特性时,并不会对原有造成破坏性变更。如以下场景,会根据具体代码来判断运行模式。
1 | const App = () => { |
一、配置调整
升级 React 相关依赖(如 react、react-dom 等)
react-dom 导入变更(react18 变更)
1
2
3
4// From
import ReactDOM from "react-dom";
// To
import ReactDOM from "react-dom/client";
升级 ts 包,包括 @types/react 和 @types/react-dom
- 大概工作量:尝试将我们项目(大型 monorepo,大约 200w code lines)ts 升级后发现近 5000errors 需要处理
明显差异:组件 props 的 children 必须明确声明列出(具体详见https://github.com/DefinitelyTyped/DefinitelyTyped/pull/56210 )
1
2
3
4interface ButtonProps {
color: string;
children?: React.ReactNode;
}
二、调整高版本不兼容的代码
依赖调整:
需要处理直接或间接使用了 React 但与 React18 不兼容的第三方库,尝试升级新版本(需要保证功能可用),或者使用其它代替方案。
API 调整:
将项目升级到 React 高版本,部分 React API 不在高版本支持。需要对现在代码进行替换。
这里推荐使用官方推荐的自动化迁移工具 Codemods(https://github.com/reactjs/react-codemod)如移除 string refs 可执行:
1 | npx codemod@latest react/19/replace-string-ref |
这个工具对于前文提到的 ts 类型也同样适用
ReactDOM.render 和 unmountComponentAtNode
- 移除版本:React19
- 为什么被废弃?新 api 提供新的渲染模式(并发),并支持了一些新特性
解决方案:替换为 createRoot(),render 和 root.unmount()
1
2
3
4
5
6
7
8
9
10// Before
import { render } from "react-dom";
const container = document.getElementById("app");
render(<App tab="home" />, container);
// After
import { createRoot } from "react-dom/client";
const container = document.getElementById("app");
const root = createRoot(container);
root.render(<App tab="home" />);
componentWillMount
- 为什么被废弃?如果你的应用程序使用 Suspense等新式的 React 功能时,componentWillMount 不保证组件将被挂载。如果渲染尝试被中止,那么 React 将丢弃正在进行的树,并在下一次尝试期间尝试从头开始构建组件。
- 解决方案:替换为 componentDimMount 或者 constructor / UNSAFE_componentWillMount
- componentWillReceiveProps
- 为什么被废弃?原因同上。
- 解决方案:替换为 getDerivedStateFromProps、componentDidUpdate 或者将 class 组件重构为使用 React Hooks 的函数式组件。 / UNSAFE_componentWillReceiveProps
- componentWillUpdate
- 为什么被废弃?原因同上。
- 解决方案:替换为 getSnapshotBeforeUpdate / UNSAFE_componentWillUpdate
findDomNode
- 移除版本:React19
- 为什么被废弃?可以返回组件挂载到 dom 上的实例。 JSX 节点与操作相应的 DOM 节点的代码之间的联系不是显式的,因此使用 findDOMNode 的代码非常脆弱
解决方案:使用 refs 替代,可以通过 props 传递、Forwarding Refs…
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28// Before
class MyComponent extends React.Component {
componentDidMount() {
const domNode = ReactDOM.findDOMNode(this);
// 操作 DOM 节点
}
render() {
return <div>Example</div>;
}
}
// After
class MyComponent extends React.Component {
constructor(props) {
super(props);
this.myRef = React.createRef();
}
componentDidMount() {
const domNode = this.myRef.current;
// 操作DOM节点
}
render() {
return <div ref={this.myRef}>Example</div>;
}
}
String Refs
- 移除版本:React19
- 为什么被废弃?涉及到全局的字符串命名,影响共享 ref 和可维护性
解决方案:使用 React.createRef()替换
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28// Before
class MyComponent extends React.Component {
render() {
return <div ref="myDiv" />;
}
componentDidMount() {
const node = this.refs.myDiv;
// ...可以对DOM节点node做操作
}
}
// After
class MyComponent extends React.Component {
constructor(props) {
super(props);
this.myDiv = React.createRef();
}
render() {
return <div ref={this.myDiv} />;
}
componentDidMount() {
const node = this.myDiv.current;
// ...可以对DOM节点node做操作
}
}
Legacy Context
- 移除版本:React19
- 为什么被废弃?容易被误用(如错误传递数据),并且可能导致维护上的问题
解决方案:将过时的 apichildContextTypes 和 getChildContext 替换为新的 API React.createContext()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43// Before
class MyProvider extends React.Component {
getChildContext() {
return { value: this.props.value };
}
render() {
return this.props.children;
}
}
MyProvider.childContextTypes = {
value: PropTypes.string,
};
class MyConsumer extends React.Component {
render() {
return <div>{this.context.value}</div>;
}
}
MyConsumer.contextTypes = {
value: PropTypes.string,
};
// After
const MyContext = React.createContext(defaultValue);
class MyProvider extends React.Component {
render() {
return (
<MyContext.Provider value={this.props.value}>
{this.props.children}
</MyContext.Provider>
);
}
}
class MyConsumer extends React.Component {
render() {
return (
<MyContext.Consumer>{(value) => <div>{value}</div>}</MyContext.Consumer>
);
}
}
useRef()和 createContext()需要一个参数
- 变更版本:React19
- 为什么被变更?能够简化其类型签名。并且改变后所有 ref 都为 mutable,不会遇到无法更改 ref 的问题
解决方案:替换为 useRef(undefined)、createContext(undefined)。
1
2
3
4
5
6
7
8// @ts-expect-error: Expected 1 argument but saw none
useRef();
// Passes
useRef(undefined);
// @ts-expect-error: Expected 1 argument but saw none
createContext();
// Passes
createContext(undefined);
三、兼容性问题
浏览器兼容
React18 建立在现代浏览器功能之上,并引入了在 IE 中无法通过捆绑全局 polyfill 解决的功能。因此升级到 React18 以上版本将不再支持 IE。
四、测试成本
如果您项目的 React 版本在 16 并打算升级,那么升级 React 版本收益的核心是享受性能提升。而性能提升意味着需要引入 React18 中的自动批处理(Automatic Batching)和并发模式(Automatic Batching),这在官方描述下属于破坏性变更,所以项目需要尽可能全量回归(如果单次全量不允许,可以像Airbnb 团队升级 React 版本一样,通过模块别名分割版本来降低单次测试的成本)。
总结
React19 的更新主要是 React Compiler 带来的性能提升和一些便捷开发者的 API。
如果您最近打算升级项目的 React 版本,可以参考文章中的收益和代价综合考量。这里建议将项目的 React 版本升级到 18.3.1(使用人数最多,且收益最大化)。
最后,感谢您能看到这个地方,文章中如有不正确的内容欢迎指出。完结撒花~ 🎉
文章部分内容参考
- https://www.youtube.com/watch?v=lGEMwh32soc
- https://www.youtube.com/watch?v=PYHBHK37xlE
- https://www.youtube.com/watch?v=T8TZQ6k4SLE
- https://github.com/reactwg/react-compiler
- https://www.developerway.com/posts/i-tried-react-compiler
- https://tonyalicea.dev/blog/understanding-react-compiler/
- https://github.com/facebook/react/blob/main/CHANGELOG.md
- https://legacy.reactjs.org/blog
- https://react.dev/blog
- https://vercel.com/blog/how-react-18-improves-application-performance
- https://react.dev/reference/react/legacy
- 《React 设计原理》