深入 React 的 useRef 与 forwardRef:从零开始的详细指南
作者:React 深度爱好者
时间:2025年7月23日
阅读时长:约 25 分钟
适用人群:对 React 基础有一定了解,但希望深入理解useRef和forwardRef工作机制的新手开发者
在 React 开发中,有时我们需要直接访问 DOM 元素或组件实例,而不是通过 props 和 state 来间接控制它们。为此,React 提供了两个非常有用的工具:useRef 和 forwardRef。本文将从基础讲起,逐步深入探讨这两个 API 的工作原理、使用场景及具体实现方法。
一、useRef:获取引用
什么是 useRef?
useRef 是一个 React Hook,它允许你在组件中创建一个持久化的引用对象。这个引用对象不会导致组件重新渲染,因此非常适合用于存储一些不需要触发更新的信息,比如 DOM 节点、计时器 ID 等。
基本用法
import { useRef } from 'react';
function MyComponent() {
const myRef = useRef(null);
return (
<div>
<input type="text" ref={myRef} />
</div>
);
}
代码解析:
useRef(null)创建了一个初始值为null的引用对象。<input type="text" ref={myRef} />将myRef绑定到<input>元素上。这意味着你可以通过myRef.current访问该输入框的 DOM 节点。
示例:自动聚焦输入框
假设你有一个表单,希望页面加载后自动聚焦第一个输入框:
import { useRef, useEffect } from 'react';
function InputField() {
// 创建一个引用对象
const inputRef = useRef(null);
// 使用 useEffect 在组件挂载后执行
useEffect(() => {
// 当组件挂载后,尝试让输入框获得焦点
inputRef.current.focus(); // 使用 .current 来访问实际的 DOM 节点
}, []);
return <input ref={inputRef} type="text" />;
}
详细解释:
useEffect钩子:在组件首次渲染完成后执行一次(依赖项为空数组)。此时通过inputRef.current获取到真实的<input>DOM 节点,并调用其.focus()方法。ref.current?.focus():使用了可选链操作符,防止在ref.current为null时调用focus()报错。
底层原理
在 React 的 Fiber 架构中,每个组件实例都有一个对应的 fiber 节点。useRef 创建的引用对象是在组件的 Hook 链表 中维护的。React 会为每个 Hook 分配一个内存位置,并在组件更新时保留这些值。
useRef的值不会触发组件更新,因为它不是状态(state),而是直接保存在 Hook 对象中。- React 在组件挂载时创建 ref 对象,并在组件卸载时保持其存在,直到组件被销毁。
useRef的.current是一个可变的引用容器,React 不会追踪它的变化,因此适用于保存一些“副作用”相关的值,如计时器 ID、DOM 元素、上次的 props/state 等。
使用场景
- 访问 DOM 元素:如聚焦输入框、滚动行为、动画等。
- 跨渲染保持状态:不希望触发重新渲染的状态,如上一次的 props。
- 缓存昂贵计算:避免在每次渲染中重复计算。
二、forwardRef:穿透组件的引用传递机制
什么是 forwardRef?
有时我们需要在父组件中使用子组件内部的某个 DOM 元素或组件实例。这时可以使用 forwardRef,它能够“转发” ref 到子组件内部,让父组件可以直接操作子组件内部的 DOM 节点或组件实例。
基本用法
import { forwardRef } from 'react';
const CustomInput = forwardRef((props, ref) => {
return <input ref={ref} {...props} />;
});
代码解析:
forwardRef接受一个函数作为参数,该函数接收两个参数:props和ref。- 函数返回的 JSX 中可以直接使用传入的
ref,将其绑定到任意子节点上。
示例:封装一个可聚焦的输入组件
import { forwardRef, useRef } from 'react';
const CustomInput = forwardRef((props, ref) => {
return <input ref={ref} {...props} />;
});
export default function App() {
const inputRef = useRef(null);
return (
<div>
<CustomInput ref={inputRef} placeholder="请输入..." />
<button onClick={() => inputRef.current.focus()}>
聚焦输入框
</button>
</div>
);
}
详细解释:
CustomInput是一个封装好的组件,它内部使用了forwardRef来接收外部传入的ref。- 父组件
App中创建了一个inputRef,并通过ref={inputRef}传递给CustomInput。 - 点击按钮时调用
inputRef.current.focus(),从而实现了对子组件内部<input>元素的聚焦控制。
底层原理
在 React 的虚拟 DOM 构建过程中,ref 是一种特殊的属性,它不参与 props 传递,而是在组件构建时被特殊处理。默认情况下,ref 只能绑定到类组件或使用 forwardRef 包装过的函数组件。
forwardRef的本质是告诉 React:“这个组件接受一个ref,请把它传递给内部的某个节点。”- React 在构建组件树时,会检查组件是否是
forwardRef类型,如果是,则将父组件传递的ref作为第二个参数传入组件函数。 - 这个
ref实际上是一个指向useRef创建的对象,最终指向了某个 DOM 节点或组件实例。
使用场景
- 函数组件默认不支持
ref,因为它们是纯函数,没有实例。 - 如果你封装了一个组件(比如一个
Input组件),你希望外部能访问其内部的<input>DOM 节点,就必须使用forwardRef。 - 在开发可复用组件库时,
forwardRef是实现“透明引用传递”的关键。
三、结合实际案例加深理解
案例1:动态调整输入框大小
import { useRef, useEffect } from 'react';
function ResizableInput() {
const inputRef = useRef(null);
useEffect(() => {
const handleResize = () => {
if (inputRef.current) {
inputRef.current.style.width = `${inputRef.current.value.length * 8}px`;
}
};
const inputElement = inputRef.current;
inputElement.addEventListener('input', handleResize);
// 清理事件监听器
return () => {
inputElement.removeEventListener('input', handleResize);
};
}, []);
return <input ref={inputRef} type="text" />;
}
解释:
ResizableInput组件:我们利用useRef来获取输入框的引用,并通过useEffect监听用户的输入事件。handleResize函数:每当用户输入内容时,根据输入框内的字符长度动态调整输入框的宽度。这里假定每个字符大约占用 8px 的宽度。- 清理事件监听器:在组件卸载时移除事件监听器,以避免内存泄漏问题。
案例2:自定义可拖动组件
import { useRef } from 'react';
const DraggableBox = forwardRef((props, ref) => {
const dragStart = (e) => {
e.target.style.opacity = '0.4';
};
const dragEnd = (e) => {
e.target.style.opacity = '1';
};
return (
<div
ref={ref}
draggable
onDragStart={dragStart}
onDragEnd={dragEnd}
style={{ width: '100px', height: '100px', backgroundColor: 'blue' }}
>
Drag me!
</div>
);
});
export default function App() {
const boxRef = useRef(null);
return (
<div>
<DraggableBox ref={boxRef} />
<button onClick={() => {
boxRef.current.style.backgroundColor = 'red';
console.log('背景颜色已改为红色');
}}>
改变颜色
</button>
</div>
);
}
解释:
DraggableBox组件:使用forwardRef来接收外部传入的ref,并实现了一个简单的可拖动盒子。onDragStart和onDragEnd事件处理程序:当用户开始拖拽时,盒子的透明度变为 0.4;拖拽结束时恢复到 1。- 父组件
App中创建了一个boxRef,并通过ref={boxRef}传递给DraggableBox。此外,还提供了一个按钮来改变盒子的颜色,并在控制台打印一条消息。 - 注意:
draggable属性使元素可拖动。- 通过
ref,我们可以直接操作组件内部的 DOM 元素,例如更改样式或添加事件监听器。
案例3:防抖与节流
const timeoutRef = useRef(null);
function handleChange(e) {
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
console.log('发送请求:', e.target.value);
}, 300);
}
解释:
timeoutRef:用来存储定时器的 ID,确保每次输入时清除之前的定时器。handleChange函数:使用防抖技术,即在用户停止输入后的 300ms 内才执行相应的操作(在这里是打印输入值)。这有助于减少不必要的网络请求或其他高成本操作,提高应用性能。
四、最佳实践与注意事项
✅ 推荐使用场景
- 使用
useRef保存不触发更新的变量。 - 使用
forwardRef在组件库中暴露内部 DOM。 - 避免在渲染中依赖
ref.current的值,它可能不是最新的。
❌ 不推荐使用场景
- 不要用
ref替代state来控制 UI。 - 不要在
ref中保存复杂对象,除非你知道如何管理内存。 - 不要在
ref中保存函数,除非是为了性能优化。
如果你喜欢这篇文章,欢迎为我点赞,或分享给更多开发者朋友。也欢迎留言交流,我们一起深入 React 的世界 🚀