TS 项目升级 React 18 到 19 的一些事情

🎙️ 前言

React 19 出来已经快有一年了,公告 在 24 年 12 月就出了,然即使身为「升级狂魔」的我,也没有第一时间进行升级。只因为我深知 React 的每次大版本升级,都将在社区掀起一阵腥风血雨,从构建到组件库再到各种 Linter 都需要紧跟其脚步,只好一等再等。

其实主要是因为项目依赖了 Antd,而 Antd 5 并没有官方宣称支持 React 19,所以只好竭力按耐住渴望升级的欲火。昨天(2025/11/23),距离 React 19 第一版过去了将近整整一年(真墨迹...),看到 Antd 推了 6.0.0,时机终于到了。

React 19 升级公告的具体内容这里就不啰嗦了,已经有好多人聊过,个人比较关注的有以下几个:

  1. forwardRef 终于开始要退出历史舞台 🎉🎉🎉
  2. Context.Provider 可以直接用 Context 代替
  3. use,注意它不是 hook
  4. Ref 相关的类型变更

🪁 如何升级

官方提供了 升级文档,提到升级工具:

shell 复制代码
npx codemod@latest react/19/migration-recipe

以我的项目来讲,可能由于一直关注依赖升级,这一步并没有动任何代码。

作为 TS 项目,还需要:

shell 复制代码
npx types-react-codemod@latest preset-19 ./path-to-app

然而它会给所有的 ReactElement 改成 ReactElement<any>,还得叫我替换还原回来。

👻 实际问题

接下来主要来讲一下我在升级中遇到的问题,主要是 TS 的类型问题。

forwardRef

终于可以用 props.ref 代替臭名昭著的 forwardRef,我认为这是最令人欣喜的新特性。被 forwardRef 折磨了这许久,终于 React 要干掉它了。

但对于 TS 代码来说,可能会遇到一些问题,比如我之前这么写(ref 没有写成可选参数):

ts 复制代码
function MyComp(props: MyCompProps, ref: Ref<MyCompRef>): ReactElement;

升级 19 后会报错签名不符,如下图:

这种情况,如果是 NPM 包,建议先发个兼容包,改 ref 为可选即可(然后再发最低依赖为 19 的大版本包):

ts 复制代码
function MyComp(props: MyCompProps, ref?: Ref<MyCompRef>): ReactElement;

useReducer

会写 TS 的人都知道,同一个方法,使用泛型的话,可以有多种不同的定义方式,好的定义能让开发者事半功倍。

useReducer 的类型定义就变了,其实升级文档里有提到,但心急的开发者估计会看漏,Better useReducer typings,而且 Codemod 工具并不会修正这里的问题。

所以对于类型定义完整的 TS 项目,会有冲击,导致构建失败。

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)。

于是之前能跑通的构建失败了:

这种情况就得自己改了:

diff 复制代码
-const [state, dispatch] = useReducer<TModelReducer, null>(reducer, null, createInitialState);
+const [state, dispatch] = useReducer<IModelState, null, [TModelAction]>(reducer, null, createInitialState);

注意,泛型第三个参数必须是元组,需要中括号括起来(个人认为他们可以优化更彻底些,不需要是元组)。

useRef

useRef 类型定义也变了,参数变成了必填,也会导致构建失败:

ts 复制代码
// 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:

ts 复制代码
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:

ts 复制代码
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;

变化:

  1. RefObject 不再只读(即不再需要 MutableRefObject),可以全局替换 MutableRefObjectRefObject
  2. ForwardedRef 虽然未标记 deprecated,但从其实现来看,可以全局替换 ForwardedRefRef

终于不再纠结那么多的 Ref 类型了。

☄️ 从 useImperativeHandle 再看 forwardRef

useImperativeHandle 是一个可能相对比较冷门的 Hook,但它真的很管用,可以让父组件调用子组件提供的方法,从而避免非常不 React 的写法(比如使用事件通知这种耦合性、不确定性比较强的写法)。

当一个组件复杂到一定程度的时候,为了避免 Props Drilling 的问题,我通常会用 Context 作为组件内全局数据状态管理的工具。所有与 propsstateeffect 相关的逻辑通通在一个不涉及 UI 的「Model」层进行封装,UI 与 Model 之间惟一的桥梁就是 Hooks。

React ≤ 18 的绕脑写法

React 19 之前,这种模式在使用 useImperativeHandle 的时候会写出很绕的代码,为了 ref 能够最终有效,我需要至少写两次 forwardRef,而且比较晦涩,这让我苦不堪言(所以我之前对于写 useImperativeHandle 多少是有些惧怕的)。

React 18 及之前,可能需要这么写。

组件 Model + UI:

tsx 复制代码
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 层:

tsx 复制代码
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 的写法

React 19 变得相当简单,forwardRef 全部干掉后,改造 Model 内部的 useRefImperative,使 Model 更内聚:

ts 复制代码
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 即可:

tsx 复制代码
import {
  ReactElement
} from 'react';

import {  
  useRefImperative
} from '../model';

export default function Ui(): ReactElement {
  useRefImperative();
  
  return <... />;
});

至此,我们再也不需要惧怕写 useImperativeHandle 了。

🐌 组件库怎么办

React 目前尚未对 forwardRef 标记 deprecated,但也说不久的将来会这么做。

组件库就会比较尴尬,考虑到存量应用,组件库没法在升级 React 19 后直接废弃 forwardRef。也就是说,组件库一时间还没办法享受弃用 forwardRef 的带来的红利,除非组件库启用大版本不兼容升级。

拿 Antd 举例,Antd 5 支持的最小 React 版本是 16.9.0,Antd 6 却并没有直接提升到 React 19,而仅仅声明最小 React 版本是 18。所以,它的实现依然使用了 forwardRef(而且只能用 forwardRef)。

🪭 总结

这次的升级,BREAK CHANGE 不多,总的来说比较平滑顺畅,我用了 1 天的时间升级完了五个项目,总结下来就这些:

  1. 所有的 forwardRef 可以改成 props.ref(如果是 NPM 包,需要先兼容,后发大版本)
  2. useReducer 的类型为 BREAK CHANGE,但也只需要改类型,修改相对简单
  3. useRef<T>() 传空将导致构建失败,可改成 useRef<T>(null)useRef<T>(undefined)
  4. MutableRefObjectRefObject
  5. ForwardedRefRef
  6. Context.ProviderContext
  7. 组件库,除非声明支持最小 React 版本为 19,不要杀 fowardRef(也不要用 props.ref
相关推荐
禁止摆烂_才浅1 小时前
React - 【useEffect 与 useLayoutEffect】 区别 及 使用场景
前端·react.js
5***o5002 小时前
React安全
前端·安全·react.js
喵个咪2 小时前
Qt 6 实战:C++ 调用 QML 回调方法(异步场景完整实现)
前端·c++·qt
F***c3252 小时前
React自然语言处理应用
前端·react.js·自然语言处理
1***Q7842 小时前
React项目
前端·javascript·react.js
幸福专买店2 小时前
【Flutter】flutter 中 包裹内容显示 的设置方式
前端·javascript·flutter
U***49833 小时前
React Native性能分析
javascript·react native·react.js
和和和3 小时前
🗣️面试官: 那些常见的前端面试场景问题
前端·javascript·面试
lxp1997413 小时前
vue笔记摘要-更新中
前端·vue.js·笔记