MUI 组件库迁移 React 19 实战指南:两阶段策略、测试适配与类型兼容方案
本文基于 MUI 官方博客中关于将 MUI X 代码库迁移至 React 19 的完整复盘(原始文档位于 docs/pages/blog/react-19-update.md),梳理其"先兼容、后迁移"的两阶段策略,并结合当前 Material UI 仓库源码(含 React 19 类型兼容、测试工具与依赖声明)展开讲解。读完本文,你将掌握:如何在不破坏 React 18 用户的前提下接入 React 19、如何借助 reactMajor 做分版本测试断言、如何用 forwardRef 兼容垫片与条件类型 RefObject 解决 ref 作为 prop 与 useRef 签名变化带来的破坏性影响。
背景:为什么必须"两条腿走路"
React 19 于 2024 年底正式发布。作为被大量生产应用依赖的 React 组件库,MUI 团队面临一个现实矛盾:一方面,许多用户仍运行着无法立即升级的 React 18 大型应用;另一方面,React 19 的早期采用者也不应该被组件库的兼容进度阻塞。因此,在任何 React 版本上都不能"一刀切"。
这一点在当前仓库的依赖声明中可以直接印证:@mui/material 最新版本(9.4.0)在 packages/mui-material/package.json 中把 peerDependencies 声明为 "react": "^17.0.0 || ^18.0.0 || ^19.0.0",即同一发布线同时支持 React 17、18、19。而它的 devDependencies 中则使用 React 19("react": "19.2.8")进行开发与测试——这正是"用新版本开发、向旧版本兼容"思路在依赖层面的体现。
核心策略:拆成两个阶段推进
为了让 React 19 兼容版本尽快发布,MUI 团队把整体迁移拆成了两个阶段:
- Phase 1:先让组件库在代码库仍基于 React 18 的情况下"能用"于 React 19 应用——即只做兼容性补丁。
- Phase 2:再把整个代码库迁移到 React 19,同时继续保持对旧版本 React 的兼容。
这种拆分显著缩短了交付 React 19 兼容版本所需的时间:兼容层先行解除了早期用户的阻塞,代码库自身的迁移则可以随后从容完成。下面分别展开两个阶段中遇到的关键问题与解决手段。
Phase 1:在 React 18 代码库上添加 React 19 兼容
从 React 19 破坏性变更清单入手
第一项工作是核对 React 19 官方升级指南中列出的破坏性变更。幸运的是,源码层面的改动并不多,但测试层面受冲击很大,主要来自两类变更:
- Strict Mode 的改进:React 19 中严格模式的双调用行为发生变化,导致测试中 spy(间谍函数)的调用次数不同;
- 渲染期错误的报告方式变化:React 19 中 render 阶段的错误不再被重新抛出,导致 console 输出内容不同。
于是测试期望值必须根据 React 主版本号条件化处理。文章示例中针对 UseSkipAnimation 组件的错误断言同时覆盖了两种输出格式:React 18 下错误被逐条分拆成数组,而 React 19 下两条错误被合并为一个字符串(errorMessage1 + '\n' + errorMessage2)。
测试工具:@mui/internal-test-utils 的 reactMajor
为按 React 版本设置不同的断言,MUI 团队在 @mui/internal-test-utils 中提供了 reactMajor 导出。其实现非常简洁,位于 packages-internal/test-utils/src/reactMajor.ts:
import * as React from 'react';
const [reactMajor, reactMinor] = React.version.split('.').map((n) => parseInt(n, 10));
export default reactMajor;
export { reactMajor, reactMinor };
即通过解析 React.version 字符串拿到当前测试环境运行的主/次版本号,测试中据此书写条件断言,例如文档中给出的错误信息适配写法:
const errorMessage1 = 'MUI X: Could not find the animation ref context.';
const errorMessage2 =
'It looks like you rendered your component outside of a ChartsContainer parent component.';
const errorMessage3 =
'The above error occurred in the <UseSkipAnimation> component:';
const expectedError =
reactMajor < 19
? [errorMessage1, errorMessage2, errorMessage3]
: `${errorMessage1}\n${errorMessage2}`;
Strict Mode 下的 spy 调用次数适配
Strict Mode 在 React 18 与 React 19 之间行为差异,直接体现在测试 spy 的期望调用次数上。文档中给出了一个针对排序逻辑的推算过程:
// Spy call count
// 1x during state initialization
// + 1x during state initialization (StrictMode)
// + 1x when sortedRowsSet is fired
// + 1x when sortedRowsSet is fired (StrictMode) = 4x
// Because of https://react.dev/blog/2024/04/25/react-19-upgrade-guide#strict-mode-improvements
// from React 19 it is:
// 1x during state initialization
// + 1x when sortedRowsSet is fired
const expectedCallCount = reactMajor >= 19 ? 2 : 4;
React 18 下 Strict Mode 会让状态初始化与事件触发各自多执行一次(共 4 次),而 React 19 起不再重复,因此期望值收敛为 2 次。这类"按版本断言"的写法,是兼容双版本测试中的常见模式。
性能隐患:ref 变为普通 prop 带来的引用不稳定
React 19 允许函数组件直接通过 props 接收 ref,forwardRef 不再是必须的。这带来一个隐蔽问题(由社区成员在 MUI X 仓库中报告):
- 因为
ref现在也是普通 prop,如果在ref之后展开 props,就可能意外覆盖ref; - 更微妙的是:在一个
ForwardRef组件上,只要 props 里"存在"ref属性——哪怕是undefined——就会使组件 props 对象引用不稳定(referentially unstable),从而破坏下游基于 memo 的优化。
从当前仓库源码也可看到 React.forwardRef 仍是组件库大量使用的写法(例如 packages/mui-material/src/utils/useSlot.test.tsx 中对测试组件的包装),因此这套兜底方案在 Material UI 生态内具有普遍参考价值。
解决方案:自定义 forwardRef 兼容垫片
针对上述问题,MUI 团队实现了一个 forwardRef 垫片(shim),在类型层面强制 props 的正确书写顺序:
// Compatibility shim that ensures stable props object for forwardRef components
// Fixes https://github.com/react/react/issues/31613
// We ensure that the ref is always present in the props object (even if that's not the case for older versions of React) to avoid the footgun of spreading props over the ref in the newer versions of React.
export const forwardRef = <T, P = {}>(
render: React.ForwardRefRenderFunction<T, P & { ref: React.Ref<T> }>,
) => {
if (reactMajor >= 19) {
const Component = (props: any) => render(props, props.ref ?? null);
Component.displayName = render.displayName ?? render.name;
return Component as React.ForwardRefExoticComponent<P>;
}
return React.forwardRef(
render as React.ForwardRefRenderFunction<T, React.PropsWithoutRef<P>>,
);
};
其实现要点:在 React 19 下不再调用 React.forwardRef,而是直接包一层普通函数组件,并把 ref 显式取出后传给 render;同时保留 displayName 以便调试。render 函数的类型被强约束为 (props, ref) => ...,且 props 中始终要求存在 ref 字段。
该垫片带来两个关键收益:
- 类型安全:如果开发者把 props 展开在
ref之前,TypeScript 会直接报错; - 前向兼容:使用该垫片的组件在所有受支持的 React 版本中都能正确工作。
用法上,文档给出了"迁移前/迁移后"的对照:
// Before
const GridRoot = React.forwardRef((props, ref) => {
const state = useGridState();
return <div ref={ref} {...props} {...state} />;
});
// After
const GridRoot = forwardRef((props, ref) => {
const state = useGridState();
return <div {...props} {...state} ref={ref} />;
});
注意顺序差异:改造后 ref 被放在 props 展开之后传入元素,从而杜绝 props 展开覆盖 ref 的可能。这是一处非常值得在生产组件库中借鉴的细节。
Phase 2:代码库整体迁移到 React 19
在兼容性验证通过后,代码库本身的迁移主要包含五件事:
- 把所有依赖更新到兼容 React 19 的版本(含将文档站点迁移到 Next.js 15);
- 迁移测试工具使其在 React 19 下正常工作;
- 确保所有组件适配 React 19 的新特性;
- 更新 CI,让其同时用 React 18 跑测试(守护向后兼容);
- 更新类型引用:React 19 使用
RefObject,早期版本继续使用MutableRefObject。
最大的变更点:useRef() 必须传参
这一阶段最大的改动集中在 useRef() hook 的签名变化上——React 19 中 useRef() 不再允许无参调用。这对 Data Grid 组件的 apiRef 影响最大:其类型从 MutableRefObject 变为仅存在于 React 19 语义下的 RefObject,而尚未升级的用户依然依赖旧的 MutableRefObject 类型。
类型层解法:自建条件类型 RefObject
为了让同一份代码在不同 React 版本下展开成正确的类型,MUI 团队定义了自己的 RefObject 条件类型。其巧妙之处在于:利用 React 19 中 useRef 必须带参数这一签名差异来做类型探测。
// in React 19 useRef requires a parameter, so `() => any` will not match anymore
export type RefObject<T> = typeof React.useRef extends () => any
? React.MutableRefObject<T>
: React.RefObject<T>;
推演如下:
- 在 React 19 中,
useRef的类型是"需要一个参数"的函数,因此它不再能赋值给() => any,条件类型走false分支,得到React.RefObject<T>; - 在 React 18 及更早版本中,
useRef可以被无参调用,条件成立,走true分支,得到React.MutableRefObject<T>。
这样 apiRef 便能在不改动业务代码的前提下,按编译时的 React 版本自动呈现正确的类型,既不让旧用户遇到类型错误,又完整享受 React 19 的类型语义。
从当前仓库看这套兼容方案的落地效果
这份博客虽然由 MUI X 团队主导,但 Material UI 维护者在其中承担了大量工作:既为 @mui/material v5、v6 添加了 React 19 支持,也升级了两个仓库共同依赖的构建与测试内部工具。作为佐证,当前 packages/mui-material/package.json(版本 9.4.0)已经把 React 19 列入 devDependencies(19.2.8),同时 peerDependencies 保持 ^17.0.0 || ^18.0.0 || ^19.0.0 的多版本共存格局,说明"开发用最新版、发布兼容多版本"的策略已成为仓库的常态。
也就是说,你在阅读或贡献本仓库时看到的 React.forwardRef 使用、分版本的条件测试以及多版本 peer 声明,都是这篇博客所描述迁移策略持续运转的结果。若有兴趣把整套机制应用到自己的库中,可直接对照上述源码文件学习:reactMajor 的实现可看 packages-internal/test-utils/src/reactMajor.ts,而博客页本身的渲染入口(通过 ?muiMarkdown 加载 Markdown)在 docs/pages/blog/react-19-update.js。
总结:给同类迁移的三条可复用经验
- 分阶段释放:先以最小代价发布"兼容版本"解除早期用户的阻塞,再从容完成代码库自身的升级;两阶段共享同一套"新版本开发 + 旧版本守护"的 CI 测试矩阵。
- 把版本差异收敛到工具层:用
reactMajor统一处理测试断言的分歧;用forwardRef垫片把ref传递方式收敛到一个函数;用条件类型RefObject让类型随 React 版本自动切换——这些改动都集中在单一位置,未来维护向后兼容的成本极低。 - 警惕隐形破坏:
ref变为 prop 带来的引用不稳定、Strict Mode 调用次数变化、render 期错误报告差异,都不在编译期暴露,只能依靠"按 React 主版本设置期望值"的测试体系来兜底。
MUI 团队的这次迁移说明:一个成熟的组件库完全可以做到"自己的代码跑在最新 React 上,同时让用户安心留在旧版本",关键就在于上述这些可落地的垫片、工具与分阶段策略。如果你也正在面对 React 18 到 React 19 的兼容压力,这套方法论可以显著缩短你的迁移时间。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00