作为一名长期使用 React 的前端开发者,我最近深入研究了 React 19 中关于 useRef 的类型变更。这个看似微小的改动实际上反映了 React 团队对引用概念的重新思考,值得我们仔细探讨。
React 19 对 useRef 做了三个主要变更:
useRef() 不再允许无参调用RefObject.current 不再是 readonlyMutableRefObject 类型被删除这些变更看似只是类型系统的调整,实则是对 React 引用机制的一次根本性修正。让我们先看看 React 18 中的设计。
在 React 18 中,useRef 有三种重载定义:
typescript复制// ① 传入非 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 类型:
typescript复制interface RefObject<T> {
readonly current: T | null
}
interface MutableRefObject<T> {
current: T
}
这种设计背后的逻辑是:通过初始值来推断 ref 的语义:
null → 创建 JSX ref(只读)null → 创建实例变量(可写)2018 年 12 月,React 团队在 GitHub issue 中解释了这种设计的初衷。他们采用了类似 Rust/C++ 的"指针所有权"模型:
这种设计试图通过类型系统来明确 ref 的所有权,防止意外行为。开发者如果需要可写的 DOM ref,可以通过泛型明确包含 | null:
typescript复制const mutableDom = useRef<HTMLDivElement | null>(null) // MutableRefObject
然而,这种设计在实践中遇到了几个严重问题:
考虑一个常见的 ref 合并工具函数:
typescript复制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 // 这里会报错
}
})
}, refs)
这个函数在 React 18 中会报错,因为无法保证传入的 ref 是可写的。开发者被迫使用类型断言:
typescript复制(ref as React.MutableRefObject<T | null>).current = element
当使用 forwardRef 时,父组件传递的 ref 可能是只读的,导致子组件无法修改:
typescript复制const Parent = () => {
const ref = useRef<HTMLInputElement>(null) // 只读 RefObject
return <Child ref={ref} />
}
const Child = forwardRef<HTMLInputElement>((props, ref) => {
// 这里无法确定 ref 是否可写
return <input ref={ref} />
})
React 19 对 ref 类型系统做了以下调整:
typescript复制// 旧版
interface RefObject<T> {
readonly current: T | null
}
// 新版
interface RefObject<T> {
current: T
}
同时删除了 MutableRefObject 类型,并强制要求 useRef 必须传入初始值。
这一变更标志着 React 团队在设计哲学上的重要转变:
React 18 试图通过类型系统强制区分"React 拥有的 ref"和"开发者拥有的 ref"。React 19 承认了现实:ref 本质上是组件与其外部环境(DOM、Web API、第三方库)之间的共享可变状态。
新的设计更符合 JavaScript 开发者的直觉:
useRef 就是一个 { current: ... } 对象这一变更还为 React 19 的其他新特性奠定了基础:
React 19 允许函数组件直接接收 ref 作为 prop,不再需要 forwardRef:
typescript复制function MyInput({ ref }) {
return <input ref={ref} />
}
统一的可写 ref 使这种模式在类型层面更加自然。
React 19 允许 ref callback 返回清理函数:
typescript复制<div ref={(node) => {
// 设置 ref
return () => {
// 清理逻辑
}
}} />
统一的可写 ref 使这种生命周期管理更加一致。
React 19 不再允许无参调用 useRef:
typescript复制// React 18
const ref = useRef<HTMLDivElement>() // 允许但不推荐
// React 19
const ref = useRef<HTMLDivElement>(null) // 必须提供初始值
之前用于绕过只读限制的类型断言可以移除:
typescript复制// React 18
(ref as MutableRefObject<T | null>).current = value
// React 19
ref.current = value // 直接赋值
简化 ref 合并工具的实现:
typescript复制// React 19 版本
function mergeRefs<T>(...refs: Ref<T>[]) {
return (value: T) => {
for (const ref of refs) {
if (typeof ref === 'function') {
ref(value)
} else if (ref) {
ref.current = value
}
}
}
}
虽然类型系统不再强制区分,但建议通过命名表明 ref 的用途:
typescript复制// DOM 引用
const inputRef = useRef<HTMLInputElement>(null)
// 实例变量
const timeoutRef = useRef<number | null>(null)
即使类型系统不再强制,DOM ref 仍可能为 null:
typescript复制useEffect(() => {
if (inputRef.current) {
inputRef.current.focus()
}
}, [])
避免过度使用 ref 来修改 DOM,优先考虑 React 的声明式模型。
在 Class 组件中,ref 主要通过两种方式创建:
typescript复制// 方式一:createRef
class MyComponent extends React.Component {
inputRef = React.createRef<HTMLInputElement>()
render() {
return <input ref={this.inputRef} />
}
}
// 方式二:回调 ref
class MyComponent extends React.Component {
inputRef: HTMLInputElement | null = null
render() {
return <input ref={node => this.inputRef = node} />
}
}
createRef 创建的 ref 对象始终是 { current: T | null },这影响了后来 useRef 的设计。
Hooks 的引入使 ref 的使用更加灵活,但也带来了新的挑战:
typescript复制// 存储 DOM 引用
const divRef = useRef<HTMLDivElement>(null)
// 存储任意值
const counterRef = useRef(0)
React 团队最初试图通过类型系统来区分这两种用例,但实践证明这种区分造成了更多问题。
2023 年 5 月,TypeScript 专家 Matt Pocock 的一条推文引发了广泛讨论:
"React's useRef types are a mess. Why do I have to deal with
| nullwhen I know the ref exists?"
这促使 React 团队重新审视 ref 的类型设计,最终在 React 19 中实现了现在的解决方案。
React 19 中 useRef 的类型定义简化为:
typescript复制function useRef<T>(initialValue: T): RefObject<T>
interface RefObject<T> {
current: T
}
不再有重载和 readonly 限制,大大简化了类型推断。
虽然类型系统变化了,但运行时行为保持不变:
current 属性仍然是可写的与 useState 的对比:
useState 用于触发重新渲染的数据useRef 用于不触发渲染的可变值与 useMemo/useCallback 的对比:
useRef 用于保持引用即使类型系统不再强制,DOM ref 在以下情况仍可能为 null:
解决方案:
typescript复制useEffect(() => {
if (myRef.current) {
// 安全使用
}
}, [])
推荐使用以下模式:
typescript复制function useMergedRefs<T>(...refs: Ref<T>[]): RefCallback<T> {
return useCallback((value: T) => {
refs.forEach(ref => {
if (typeof ref === 'function') {
ref(value)
} else if (ref) {
ref.current = value
}
})
}, refs)
}
React 19 推荐的方式:
typescript复制function MyInput({ ref }: { ref: Ref<HTMLInputElement> }) {
return <input ref={ref} />
}
避免在每次渲染时创建新的 ref 回调:
typescript复制// 不推荐
<div ref={node => this.node = node} />
// 推荐
const setRef = useCallback(node => {
this.node = node
}, [])
<div ref={setRef} />
使用 React Testing Library:
typescript复制test('should focus input on mount', () => {
const { container } = render(<MyComponent />)
const input = container.querySelector('input')
expect(document.activeElement).toBe(input)
})
typescript复制const mockRef = { current: null }
render(<MyComponent ref={mockRef} />)
expect(mockRef.current).not.toBeNull()
主流库如 MUI、Radix UI 等已经适配 React 19 的 ref 变更。升级时检查:
确保 @types/react 版本 ≥ 18.2.0,并在 tsconfig.json 中:
json复制{
"compilerOptions": {
"types": ["react/next", "react-dom/next"]
}
}
React 团队表示未来可能:
这些变化都将建立在 React 19 的 ref 基础之上。
在实际项目中应用这些变更时,我发现:
建议团队:
React 19 对 useRef 的调整看似微小,实则是对核心概念的重要重构。它放弃了过度设计的类型安全,回归到更符合 JavaScript 习惯的简单模型,为未来的发展奠定了更好的基础。