🎙️ 前言
React 19 出来已经快有一年了,公告 在 24 年 12 月就出了,然即使身为「升级狂魔」的我,也没有第一时间进行升级。只因为我深知 React 的每次大版本升级,都将在社区掀起一阵腥风血雨,从构建到组件库再到各种 Linter 都需要紧跟其脚步,只好一等再等。
其实主要是因为项目依赖了 Antd,而 Antd 5 并没有官方宣称支持 React 19,所以只好竭力按耐住渴望升级的欲火。昨天(2025/11/23),距离 React 19 第一版过去了将近整整一年(真墨迹...),看到 Antd 推了 6.0.0,时机终于到了。
React 19 升级公告的具体内容这里就不啰嗦了,已经有好多人聊过,个人比较关注的有以下几个:
forwardRef终于开始要退出历史舞台 🎉🎉🎉Context.Provider可以直接用Context代替- use,注意它不是
hook - Ref 相关的类型变更
TL;DR
本文主要讲了 TS 项目在升级 React18 到 19 的过程中,可能遇到的一些问题,并在文章最末给出了相关总结。
编辑历史
| 日期 | 版本说明 | |
|---|---|---|
| 2025/12/08 | V2 | peerDependencies |
| 2025/11/24 | V1 | 这次写得比较快 |
🪁 如何升级
官方提供了 升级文档,提到升级工具:
npx codemod@latest react/19/migration-recipe
以我的项目来讲,可能由于一直关注依赖升级,这一步并没有动任何代码。
作为 TS 项目,还需要:
npx types-react-codemod@latest preset-19 ./path-to-app
然而它会给所有的 ReactElement 改成 ReactElement<any>,还得叫我替换还原回来。
👻 实际问题
接下来主要来讲一下我在升级中遇到的问题,主要是 TS 的类型问题。
forwardRef
终于可以用 props.ref 代替臭名昭著的 forwardRef,我认为这是最令人欣喜的新特性。被 forwardRef 折磨了这许久,终于 React 要干掉它了。
但对于 TS 代码来说,可能会遇到一些问题,比如我之前这么写(ref 没有写成可选参数):
function MyComp(props: MyCompProps, ref: Ref<MyCompRef>): ReactElement;
升级 19 后会报错签名不符,如下图:
这种情况,如果是 NPM 包,建议先发个兼容包,改 ref 为可选即可(然后再发最低依赖为 19 的大版本包):
function MyComp(props: MyCompProps, ref?: Ref<MyCompRef>): ReactElement;
useReducer
会写 TS 的人都知道,同一个方法,使用泛型的话,可以有多种不同的定义方式,好的定义能让开发者事半功倍。
useReducer 的类型定义就变了,其实升级文档里有提到,但心急的开发者估计会看漏,Better useReducer typings,而且 Codemod 工具并不会修正这里的问题。
所以对于类型定义完整的 TS 项目,会有冲击,导致构建失败。
// React 18 的 `useReducer` 5 个重载
function useReducer<R extends ReducerWithoutAction<any>, I>(
reducer: R,
initializerArg: I,
initializer: (arg: I) => ReducerStateWithoutAction<R>
): [ReducerStateWithoutAction<R>, DispatchWithoutAction];
function useReducer<R extends ReducerWithoutAction<any>>(
reducer: R,
initializerArg: ReducerStateWithoutAction<R>,
initializer?: undefined
): [ReducerStateWithoutAction<R>, DispatchWithoutAction];
function useReducer<R extends Reducer<any, any>, I>(
reducer: R,
initializerArg: I & ReducerState<R>,
initializer: (arg: I & ReducerState<R>) => ReducerState<R>
): [ReducerState<R>, Dispatch<ReducerAction<R>>];
function useReducer<R extends Reducer<any, any>, I>(
reducer: R,
initializerArg: I,
initializer: (arg: I) => ReducerState<R>
): [ReducerState<R>, Dispatch<ReducerAction<R>>];
function useReducer<R extends Reducer<any, any>>(
reducer: R,
initialState: ReducerState<R>,
initializer?: undefined
): [ReducerState<R>, Dispatch<ReducerAction<R>>];
// React 19 的 `useReducer` 只剩下了两种
function useReducer<S, A extends AnyActionArg>(
reducer: (prevState: S, ...args: A) => S,
initialState: S
): [S, ActionDispatch<A>];
function useReducer<S, I, A extends AnyActionArg>(
reducer: (prevState: S, ...args: A) => S,
initialArg: I,
init: (i: I) => S
): [S, ActionDispatch<A>];
主要区别是,19 把之前的 R(Reducer)拆成了 S(State) 和 A(Action)。
于是之前能跑通的构建失败了:
这种情况就得自己改了:
-const [state, dispatch] = useReducer<TModelReducer, null>(reducer, null, createInitialState);
+const [state, dispatch] = useReducer<IModelState, null, [TModelAction]>(reducer, null, createInitialState);
注意,泛型第三个参数必须是元组,需要中括号括起来(个人认为他们可以优化更彻底些,不需要是元组)。
useRef
useRef 类型定义也变了,参数变成了必填,也会导致构建失败:
// React 18
function useRef<T>(initialValue: T): MutableRefObject<T>;
function useRef<T>(initialValue: T | null): RefObject<T>;
function useRef<T = undefined>(initialValue?: undefined): MutableRefObject<T | undefined>;
// React 19
function useRef<T>(initialValue: T): RefObject<T>;
function useRef<T>(initialValue: T | null): RefObject<T | null>;
function useRef<T>(initialValue: T | undefined): RefObject<T | undefined>;
这意味着,需要将所有的空 useRef<T>() 加上默认值,哪怕传的是 undefined 也要写一下。
不过有一个好处,就是以前写成 useRef<T | null>(null) 的,现在只需要写 useRef<T>(null),见下图 React 18 和 19 下 IDE 类型推导的区别:
以前的 RefObject<T> 其实是现在的 RefObject<T | null>,现在的 RefObject<T> 是真的 RefObject<T>。
Ref 的各种类型
上面的 useRef 类型定义,你可能已经注意到 MutableRefObject 的地方都被换成了 RefObject。以下是两个版本跟 Ref 有关的类型定义剪影:
React 18:
interface RefObject<T> {
readonly current: T | null;
}
interface MutableRefObject<T> {
current: T;
}
type Ref<T> = RefCallback<T> | RefObject<T> | null;
type ForwardedRef<T> = ((instance: T | null) => void) | MutableRefObject<T | null> | null;
React 19:
interface RefObject<T> {
current: T; // 🎉 去掉 readonly
}
interface MutableRefObject<T> { // 💥 deprecated
current: T;
}
type Ref<T> = RefCallback<T> | RefObject<T | null> | null;
type ForwardedRef<T> = ((instance: T | null) => void) | RefObject<T | null> | null;
变化:
RefObject不再只读(即不再需要MutableRefObject),可以全局替换MutableRefObject→RefObjectForwardedRef虽然未标记deprecated,但从其实现来看,可以全局替换ForwardedRef→Ref
终于不再纠结那么多的 Ref 类型了。
peerDependencies
在我本地调试完成,摩拳擦掌准备开始云上构建时,不巧又碰到了新的问题。
发过 React 组件 npm 包的,对需要把 react、react-dom、react-router、styled-components 这些基础包(用到的话)写进 peerDependencies 而不是 dependencies 里这个不成文的规定应该不会陌生。
本地开发我们都是用的 pnpm,然云端只有 npm install,于是报错了。
这也暴露了一个我一直以来忽略掉的一个问题:peerDependencies 的最佳实践应该是什么。
临时的解法是 npm install --legacy-peer-deps,不过还是应该先发一版兼容,把所有的 peerDependencies 下的 ^n.x 改成 >=n.0.0。
组件库怎么办
React 目前尚未对 forwardRef 标记 deprecated,但也说不久的将来会这么做。
组件库就会比较尴尬,考虑到存量应用,组件库没法在升级 React 19 后直接废弃 forwardRef。也就是说,组件库一时间还没办法享受弃用 forwardRef 的带来的红利,除非组件库启用大版本不兼容升级。
拿 Antd 举例,Antd 5 支持的最小 React 版本是 16.9.0,Antd 6 却并没有直接提升到 React 19,而仅仅声明最小 React 版本是 18。所以,它的实现依然使用了 forwardRef(而且只能用 forwardRef)。
🪳 forwardRef 为何讨人厌
上边说了,React 19 最令我心动的改进是可以将 ref 写进 props 里,因为我真的非常讨厌 forwardRef。相信很多人都同样曾经被 forwardRef 困扰过。
被报错
你写了一个 FC 组件,但和三方组件库一起用的时候,会「被报错」,找了半天原因,结果就是自定义组件少了 forwardRef,简直就是烦不胜烦。
很明显的一个例子就是,当你把没有 forwardRef 的组件放到 Antd 的 Tooltip 下,就会看到这样的报错。
有同样问题的还有 Fusion,它会要求所有用在 Form 下的输入组件都必须接受 ref。
而在 React 19,我们只需要做到 props 透传即可。
泛型丢失
另外一个被 forwardRef 恶心到的问题是组件的泛型无法被保留,而泛型组件是很常规的诉求,是组件得以被正确使用的必要条件。
比如我之前写的 IconBase 组件,为了让 props.type 的值能被正确推导,之前必须这么写:
function IconBase<T extends string>(props: IIconBaseProps<T>, ref?: TIconBaseRef): ReactElement {
...
}
export default forwardRef(IconBase) as typeof IconBase;
以上得到的类型构建产物如下:
declare function IconBase<T extends string>(props: IIconBaseProps<T>, ref?: TIconBaseRef): ReactElement;
declare const _default: typeof IconBase;
export default _default;
而如果你去掉 as typeof IconBase,得到的类型产物就是这样:
declare const _default: import("react").ForwardRefExoticComponent<IIconBaseProps<string> & import("react").RefAttributes<HTMLDivElement>>;
export default _default;
且不论那个 ForwardRefExoticComponent 看着让人头疼,你会发现 IIconBaseProps 丢失了泛型。虽然 forwardRef 本身是个泛型函数,但是要让它能够按原定义输出,貌似并不可能(它无法处理泛型的泛型)。
为了保留泛型,你甚至都必须分开写组件和导出,且必须 as,如果不然,组件的使用者将得到一个不带泛型的组件,怎么用都不顺。
现在好了,撇去了 forwardRef 这个中奸商,类型输出非常直观:
export default function IconBase<T extends string>(props: IIconBaseProps<T>): ReactElement;
export {};
useImperativeHandle
useImperativeHandle 是一个可能相对比较冷门的 Hook,但它真的很管用,可以让父组件调用子组件提供的方法,从而避免非常不 React 的写法(比如使用事件通知这种耦合性、不确定性比较强的写法)。
当一个组件复杂到一定程度的时候,为了避免 Props Drilling 的问题,我通常会用 Context 作为组件内全局数据状态管理的工具。所有与 props、state、effect 相关的逻辑通通在一个不涉及 UI 的「Model」层进行封装,UI 与 Model 之间惟一的桥梁就是 Hooks。
React 18 及以前,这种模式在使用 useImperativeHandle 的时候会写出很绕的代码,为了 ref 能够最终有效,我需要至少写两次 forwardRef,而且比较晦涩,这让我苦不堪言(所以我之前对于写 useImperativeHandle 多少是有些惧怕的)。
组件 Model + UI:
import {
ReactElement,
ForwardedRef,
forwardRef
} from 'react';
import Model, {
ModelProps,
ImperativeRef
} from '../model';
import Ui from '../ui';
export default forwardRef(function TheComponent(props: ModelProps, ref: ForwardedRef<ImperativeRef>): ReactElement {
return <Model {...props}>
<Ui ref={ref} />
</Model>;
});
UI 层:
import {
ReactElement,
ForwardedRef,
useImperativeHandle,
forwardRef
} from 'react';
import {
ImperativeRef,
useRefImperative
} from '../model';
export default forwardRef(function Ui(_props: unknown, ref: ForwardedRef<ImperativeRef>): ReactElement {
const imperativeRef = useRefImperative();
useImperativeHandle(ref, () => imperativeRef, [imperativeRef]);
return <... />;
});
很绕,是不是?需要在组件最外层,把 ref 传递到 UI 层,然后再在 UI 层 useImperativeHandle,为此,原本可以无参的 UI 组件,甚至还要写个 _props: unknown。
但凡脑子不好一点都想不出这么绕的法子 😳。但为了能够在正确的位置使用 useImperativeHandle,这的确是我能想到的比较「高明」的办法了。
到了 React 19,事情变得相当简单,forwardRef 全部干掉后,改造 Model 内部的 useRefImperative,使 Model 更内聚:
import {
useImperativeHandle
} from 'react';
import {
IImperativeRef
} from '../types';
import useModelProps from './_use-model-props';
export default function useRefImperative(): void {
const {
ref
} = useModelProps();
useImperativeHandle(ref, (): IImperativeRef => ({
...
}), [...]);
}
使用的话,就只需要在 UI 组件,简单调用一下 useRefImperative 即可:
import {
ReactElement
} from 'react';
import {
useRefImperative
} from '../model';
export default function Ui(): ReactElement {
useRefImperative();
return <... />;
});
至此,我再也不需要惧怕写 useImperativeHandle 了。
🪭 总结
这次的升级,BREAK CHANGE 不多,总的来说比较平滑顺畅,我用了 1 天的时间升级完了五个项目,总结下来就这些:
- 所有的
forwardRef可以改成props.ref(如果是 NPM 包,需要先兼容,后发大版本) useReducer的类型为 BREAK CHANGE,但也只需要改类型,修改相对简单useRef<T>()传空将导致构建失败,可改成useRef<T>(null)或useRef<T>(undefined),同时原来的useRef<T | null>(null)可以改成useRef<T>(null)MutableRefObject→RefObjectForwardedRef→RefContext.Provider→Context- 组件库,除非声明支持最小 React 版本为 19,不要杀
fowardRef(也不要用props.ref) - 组件库,如果在
peerDependencies下用^或~的,趁早改成>=