React 19 修复了一个遗留多年的类型乌龙,过程竞如此曲折

前言

在 React 19 的更新列表中,useRef 的变化看起来极不起眼:

  • useRef() 不再允许无参调用
  • RefObject.current 不再是 readonly
  • MutableRefObject 被删除

乍一看,这似乎只是一次 TypeScript 类型整理。

但如果你回顾这个改动的来龙去脉,就会发现:

它实际上是 React 对"引用"这个概念的一次根本性修正。

回顾:React 18 中 useRef 的类型魔法

在 React 18 + TypeScript 开发中,你肯定认为下面的代码是很正常的:

jsx 复制代码
const ref = useRef<HTMLInputElement>(null);

return (
  <input ref={ref} />
)

这里必须传 null,否则 TypeScript 会报错。

背后的原因是:useRef 在 React 18 中有一组非常特殊的重载定义:

typescript 复制代码
/**
 * React 18 中 useRef 的 3 种 overload
**/
 
// ① 传入非 null
function useRef<T>(initialValue: T): MutableRefObject<T>

// ② 传入 T | null
function useRef<T>(initialValue: T | null): RefObject<T>

// ③ 什么都不传
function useRef<T = undefined>(): MutableRefObject<T | undefined>

/**
 * 两种 ref 类型
 **/
interface RefObject<T> {
  readonly current: T | null
}

interface MutableRefObject<T> {
  current: T
}

TypeScript 会根据你传入的初始值来决定:

  • useRef(null) → 你在创建一个 JSX ref(只读)
  • useRef(0) → 你在创建一个实例变量(可写)

也就是说:

React 用 null 这个值,去推断一个 ref 的语义

为什么需要 readonly

这个设计早在 2018 年 12 月就被 一篇 issue 质疑:

现在可以给 ref.current 赋值了,示例参见:reactjs.org/docs/hooks-...

尝试给它赋值时会出现错误:无法赋值给 'current' ,因为它是常量只读属性

我尝试使用 @types/react 包,但遇到了问题。

一开始有人指出了是 readonly 导致的只读问题,并考虑是否将其删除。但一名仓库成员 支持了 readonly 的设计,并做出如下解释:

他认为 readonly 的设计是在确保正确使用 ref,尽管确实 ref 是可写的(没有被冻结)。

当 Hooks 刚出现时,useRef 必须同时承担两种历史角色:

在 class 中 在 Hooks 中
this.input = createRef() useRef(null)
this.timer = 0 useRef(0)

他认为应当在类型系统中保留这层区分:

JSX ref 是 React 拥有的,理应只读 普通 ref 是用户拥有的,所以可写

这是一个 Rust / C++ 风格的"指针所有权 "模型,通过这种方式来明确 ref 的所有权,防止意外行为。

另外他也给出了当时 想要一个可写 ref 的解决方案(因为确实有需要初始值为 null普通 ref 的情况),那就是在泛型中添加 | null,此时 TypeScript 会根据 "最具匹配原则",命中 mutableRefObject 给你。例如:

ts 复制代码
const value = useRef<number | null>(null) // MutableRefObject
const mutableDom = useRef<HTMLDivElement | null>(null) // MutableRefObject

如果你还没理解这个原理,可以通过下面的表格思考:

overload 是否匹配
useRef<T>(T)MutableRefObject<T> nullHTMLDivElement
`useRef<T>(T null)RefObject<T>`

因为此时 T 包含了 | null,所以 null 初始值命中了两个重载, 但由于 TT | null 更具体,所以命中了第一个,返回 MutableRefObject。通过这样的方式,可以稳定获得可写的 ref,不管其是 JSX ref 还是 普通 ref

又有人提出,为何不设置成当 useRef()参数缺省时 当做是对 JSX 的引用(由 React 管理) ,如果有初始值 则被当做是一个实例变量(由用户管理)

这样设计看似语义上更加符合直觉,因为当是个实例变量时,用户一般都会将其进行初始化(提供初始值)。反而不提供意味着可能是 JSX 引用。但这个方案很快也被否定:

总而言之分为以下几点:

  • ref 在被当成引用时理应被初始化为 null :因为 React 在释放 ref 时(例如,当卸载一个有条件挂载的组件时)会为 ref 设置为 null。如果 useRef 没有传递初始值,它会以 undefined 开始,这样一来,你就不得不给原本只需要考虑 | null 的东西加上 | undefined,不适合类型推导。

  • useRef 始终是可变的且不可为 null :前文提到,如果你需要一个可变的 ref,就一定要给一个含 | null 的联合类型作为泛型。如果你同时给它一个不包含 | null 的泛型参数和一个 null 的初始值才会引发不可用的问题,而这个情况也是不符合常理的,因为你自己就违背了自己设置的泛型 ------ 它不应该为 null

  • useRef 应当必填初始值 :早期 React 文档有使用不带初始值的 useRef,这虽然可以工作但完全不利于类型推导。

    • 如果这个 ref 作为 JSX 引用 ,那么上面提到它可能为 null。而缺省的含义为 undefined,所以你还要手动添加一个联合类型 | null 在泛型中。
    • 如果这个 ref 作为实例变量,它的初始值就更加重要。

另外他也给了一些代码示例来佐证这些观点:

ts 复制代码
// 你应该永远提供初始值,尽管有时文档没有这么做
// ref1:这应该被视作一个错误
const ref1 = useRef() // $ExpectError
// ref2:这是一个可写的 ref,但它只能被赋值为 null(你没有指定泛型,类型推导为 null)
const ref2 = useRef(null)
// ref3:这依然是一个可写的 ref,但它只能被赋值为 undefined,原理同上
const ref3 = useRef(undefined)
// ref4:这是一个可写的 ref,类型为 number
const ref4 = useRef(0)
// ref5:这是一个可写的 ref,类型为 number | null
const ref5 = useRef<number | null>(null)
// ref6:这是一个可写的 ref,类型是一个对象
const ref6 = useRef<React.CSSProperties>({})
// ref7:这是一个可写的 ref,类型为一个 JSX 引用或 null
const ref7 = useRef<HTMLElement | null>(null)
// ref8:这是唯一一个 ref 只读的例子
// 你没有在泛型参数中说明你希望能够向其中写入 null,却还是传入了一个 null。  
// 我认为这表明该引用旨在用作元素引用(即由 React 拥有,你只是共享它)
const ref8 = useRef<HTMLElement>(null)
// ref9:这也不应该被允许,因为你没有在泛型里写到它可能是一个 undefined
// 这本质上就是如果我们允许不带参数的 useRef 会发生的情况  
// 更糟糕的是,你不能把它用作元素的 ref,因为 React 可能无论如何都会往里面写入一个 null。
const ref9 = useRef<HTMLElement>(undefined) // $ExpectError

其实到这里我已经被说服了,这个 readonly 设计可谓非常优雅且理性。因此在 2019 年 5 月,大家决定不对这个类型定义做出修改。

转折:readonly 真的守护了我们吗?

依旧是上面谈论的那篇 issue。尽管人们终止了讨论,但有一位开发者提出了他的场景:

tsx 复制代码
export const useCombinedRefs = <T>(...refs: Ref<T>[]) =>
    useCallback(
        (element: T) =>
            refs.forEach(ref => {
                if (!ref) {
                    return;
                }

                if (typeof ref === 'function') {
                    ref(element);
                } else {
                    ref.current = element; // this line produces error
                }
            }),
        refs,
    );

这个函数的作用是将多个 ref 合并成一个 ref callback,从而让一个 组件/JSX 同时写入多个 ref。

这个场景是有价值的,因为在一些场景中,你可能需要使用 forwardRef 将一个 JSX 暴露给父组件,但自己也想拥有这个 JSX 的 ref:

tsx 复制代码
const ref1 = useRef<HTMLInputElement>(null);
const ref2 = someForwardedRef;

<input ref={useCombinedRefs(ref1, ref2)} />

这段代码的逻辑在很多组件库(Radix UI、MUI ...)都是核心基础设施。但很遗憾,就如此常用的用法,在上面看似为你好readonly 方案中会报错。因为你无法保证外部传来的 ref 是可写的,而且你也不能要求所有调用方都写成 useRef<HTMLElement | null>(null)

人们被逼无奈要写一个类型断言来规避问题:

ts 复制代码
(ref as React.MutableRefObject<T | null>).current = element;

但仅因发现这一个问题,人们依旧没有意识到这个设计有问题,所以选择自适应。

直到 2023 年 5 月,一篇推文又把这个议题拉入大众视野:

随之当天创建了 Pull Request,并开启了二度讨论。

Matt 的那条推文并没有提出一个新 bug。

它真正做的事情是把一个已经被生态长期用类型断言绕过的问题,公开化、显性化。

在那条推文下,最典型的反应不是"这是 bug",而是:

"这不是设计如此吗?你可以用 | null 解决。"

这正是 2018 年那套 ownership 模型的遗产。

但这一次不同的地方在于:
React 自己已经进入了一个"ref 会被反复重写"的时代。

  • StrictMode 下的 mount → unmount → mount
  • Suspense / Offscreen 的 detach → attach
  • Streaming hydration
  • Server Components 的 DOM 复用
  • forwardRef 与 imperativeHandle 的广泛使用
  • ...

而这些写入,一半来自 React,一半来自于开发者 。所以这更加意味着 JSX ref 不应是 React 独占管理的内容

正是因为这样,"让所有 ref 可写" 变成了 RFC 项目。始于 18 年的话题终在 23 年再次深入讨论并计划于 React 19 上线。

在另一篇讨论 到底是删除 RefObject 还是删除 MutableRefObjectPull Request 中,对这个历史问题留下了总结:

RefObject 这个类型来自 createRef(class 时代),

但我们错误地把它套在了 useRef 上。

function 组件里的 ref 从来就不是 React 独占的。

最后他们选择了删除 MutableRefObject,并为 RefObject 正名 ------ ref 就是可写的。

diff 复制代码
-interface RefObject<T> {
-  readonly current: T | null
-}
+interface RefObject<T> {
+  current: T
+}

ref 类型纠正背后的意义

这一改动不仅是减少了几行类型定义代码,它标志着 React 团队在API 设计哲学开发体验之间做出的重要权衡。

1. 心智模型的回归:从"所有权"到"容器"

React 18 的类型系统试图通过 readonly 强加一种Rust/C++ 风格的"指针所有权"模型

  • 只读 Ref:意味着"React 拥有这个 DOM,你只能看"。
  • 可变 Ref:意味着"这是你的变量,你可以随便改"。

但这违背了 JavaScript 开发者的直觉。在 JS 中,对象默认就是可变的。useRef 在本质上只是一个在重渲染之间保持引用的容器

React 19 放弃了这种人为的"类型洁癖",承认了 useRef 的本质:它就是一个 { current: ... } 对象。无论里面放的是 DOM 节点还是普通数据,它在物理上都是可写的。

变化前:开发者需要根据"是否传入 null"来揣摩类型推导的规则。

变化后useRef 就是一个储物柜,钥匙一直在你手里。

2. 承认现实:DOM 引用是"共享状态"而非"独占状态"

正如前文提到的 useCombinedRefs 案例,以及 React 生态中大量存在的 forwardRef 场景,现实中的 DOM 引用往往是多方协作的结果:

  1. React 在 commit 阶段写入 DOM。
  2. 父组件可能需要通过 callback ref 劫持这个 DOM。
  3. 第三方库(如动画库、拖拽库)可能直接操作这个 DOM。

旧的 RefObject 试图假装 DOM 引用是静态的、由 React 独占的,这是一种 "虚假的安全感" 。React 19 的改动承认了 Ref 是组件与其外部环境(DOM、Web API、第三方库)之间的共享可变状态

3. 给 React 19 的其它新特性铺路

如果把 React 19 的 ref 相关变化放在一起看,你会发现 useRef 的类型更新并不是孤立事件,而是一个"让所有 ref 行为在类型层面自洽"的基础设施升级。

1)ref as a prop:ref 开始变成"组件 API"的一部分

React 19 引入了 ref 作为函数组件的 prop 的能力:你可以在组件参数里直接拿到 ref,因此新函数组件不再需要 forwardRef 这层包装,官方也明确表示未来会逐步弃用并移除 forwardRefReact

一旦 ref 变成普通 prop,生态里最常见的模式会变得更"显性":

  • 组件库 / 业务组件都会更频繁地 透传 ref
  • 也会更频繁地 合并多个 ref(内部自己要用一个 ref,外部也传了 ref)

而这恰恰就是 React 18 时代 readonly RefObject 最尴尬的点:
你写一个通用的 mergeRefs/useCombinedRefs 工具函数时,根本无法保证外部传入的是"可写 ref" ,最终只能靠断言/any 解决。

React 19 把所有 RefObject.current 统一为可写,本质上是在为"ref 作为组件 API"扫清障碍:

ref 不再被类型系统暗示成"只属于 React 的只读视图",而是一个可以被 React 与用户共同写入的槽位。 React

你可以在文章里点明这一句:
ref as a prop 让 ref 的流动更频繁,而"所有 ref 可写"让这种流动在类型层面不需要额外的 hack。

2)Ref callback 支持 cleanup:ref 的"释放语义"正在变化,TS 必须更严格

react.dev/blog/2024/0...

React 19 还引入了一个很容易被忽略但很关键的 ref 行为变化:ref callback 可以返回 cleanup 函数,React 会在元素从 DOM 移除时调用这个 cleanup。

并且官方明确说明:如果你返回了 cleanup,React 将不再走"卸载时用 null 再调用一次 ref callback"的旧路径,并且未来会逐步弃用"卸载时传 null"这个行为。

这带来一个直接后果:TypeScript 必须能区分你返回的到底是 cleanup 还是"你不小心写了隐式返回值" 。因此升级指南里专门强调:由于 cleanup 的引入,ref callback 返回其它值会被 TS 拒绝,并提供 codemod 来迁移隐式返回。

你会发现这里的主题和 useRef 类型更新是一致的:

  • ref 的能力变强了(callback cleanup)
  • TS 必须更准确地反映真实行为(避免"看起来能写、其实类型不让写 / 看起来返回了个值、其实不是 cleanup")

而"所有 ref 可写"的统一模型,使 ref 相关 API 在类型层面更一致:

你不需要再用 "readonly 代表 React 所有" 的旧抽象去解释复杂生命周期。

3)useRef 必须传初始值:不是"强迫症",是为了让类型推导停止猜测

React 19 升级指南里对这一点讲得非常直白:

  • useRef 现在必须传入一个初始值
  • 这会显著简化类型签名,让它行为更像 createContext
  • 同时,这也意味着 所有 ref 都可变 ,不会再出现"因为用 null 初始化导致 current 只读"的问题 React

这里你可以把它写成一个"价值主张":

React 18 的类型系统在尝试从 null/非null/无参 推断你的语义(JSX ref vs 实例变量)。

React 19 选择停止猜测:你必须明确给初始值,类型也就能更稳定、更一致。

并且官方还给出了迁移策略:React 19 codemod 里就包含 refobject-defaults,专门处理 useRef() 无参的迁移。 React

4)把这三件事连起来:React 19 的 ref 观念正在从"所有权"走向"共享槽位"

把 React 19 的三条 ref 相关变化并排看:

  1. ref as a prop:ref 是组件 API 的一部分(减少 forwardRef 依赖)
  2. ref callback cleanup:ref 生命周期语义更完善,更像 effect cleanup
  3. useRef 类型统一 + 必填初值:停止用 overload 进行语义猜测,统一为可写 RefObject

你会得到一个非常清晰的结论:

React 19 想要的 ref,不再是"React 拥有 / 用户旁观"的所有权模型,

而是一个在运行时会被多方写入、需要明确生命周期语义的共享槽位(mutable identity)。

这也解释了为什么 readonly RefObject 最终必须被移除:

它不是"语义标注",而是一种会在 ref 合并、透传、工具函数中不断制造摩擦的类型幻觉。

结语

现在是 2026 年,React 19 也早就不是新闻了。

但我还是想写这篇整理,是因为这种"悄无声息的架构修正"往往最容易被忽略。大家会记得 Server Components、Compiler、Actions,却很少有人去回头看:这些东西成立的前提,是 ref 这种最底层的概念被重新定义过。

useRef 变成"所有 ref 都可写",表面上看是去掉了一个 readonly

实际上,它是 React 放弃了一种曾经非常优雅、但已经不再真实的抽象。

我觉得这件事本身,就值得被记住。

相关推荐
白兰地空瓶9 小时前
React 性能优化的“卧龙凤雏”:useMemo 与 useCallback 到底该怎么用
react.js
百度地图汽车版9 小时前
【AI地图 Tech说】第二期:一文解码百度地图ETA
前端
恋猫de小郭9 小时前
罗技鼠标因为服务器证书过期无法使用?我是如何解决 SSL 证书问题
android·前端·flutter
Sailing9 小时前
AI 流式对话该怎么做?SSE、fetch、axios 一次讲清楚
前端·javascript·面试
白兰地空瓶9 小时前
聊聊那个让 React 新手抓狂的“闭包陷阱”:Count 为什么永远是 0?
react.js
橙露9 小时前
Vue3 组件通信全解析:技术细节、适用场景与性能优化
前端·javascript·vue.js
扉间7989 小时前
lightrag嵌入思路
前端·chrome
toooooop89 小时前
Vuex Store实例中`state`、`mutations`、`actions`、`getters`、`modules`这几个核心配置项的区别
前端·javascript·vue.js
LYFlied9 小时前
Rust代码打包为WebAssembly二进制文件详解
开发语言·前端·性能优化·rust·wasm·跨端