React Refs
为什么 refs 是 React 模型的必要补充
React 的核心哲学是声明式 UI——你描述在任何给定时刻 UI 应该是什么样子,React 负责更新实际的 DOM。这个模型在绝大多数场景下工作得很好:通过 state 和 props 的变化驱动 UI 更新。
但有时候,你需要命令式地与 DOM 节点或 React 组件实例交互:
- 设置输入框焦点
- 触发动画
- 测量 DOM 节点尺寸
- 调用第三方库的 API
- 保存一个不需要触发 re-render 的值(定时器 ID、上一次的值)
这些场景有一个共同特征:你需要绕过 React 的声明式模型,直接访问底层 DOM 节点或组件实例。Refs 就是这个"后门"——它提供了 React 声明式世界和命令式 DOM 世界之间的桥梁。
面试定位:refs 是 React 面试中考察"对 React 渲染模型理解深度"的代表性话题。面试官通过候选人能否说清楚 useRef 的本质是 mutable container、forwardRef 的穿透机制、useImperativeHandle 的自定义暴露控制,来判断其对 React 内部工作原理的理解程度。
一、useRef 的本质:mutable container
1.1 什么是 Ref
Ref 是一个容器,其 .current 属性可以存储任何值。修改 ref 的值不会触发组件 re-render——这是 ref 与 state 的根本区别。
function Timer() {
const intervalRef = useRef(null);
const [count, setCount] = useState(0);
const startTimer = () => {
intervalRef.current = setInterval(() => {
setCount(c => c + 1);
}, 1000);
};
const stopTimer = () => {
clearInterval(intervalRef.current);
};
return (
<div>
<p>{count}</p>
<button onClick={startTimer}>开始</button>
<button onClick={stopTimer}>停止</button>
</div>
);
}
1.2 为什么修改 ref 不触发 re-render
React 的渲染决策由 state 和 props 变化驱动。Ref 被设计为"React 数据流之外的值"——它的变化不被视为需要重新渲染的信号。
从实现角度看:ref 只是一个普通的 JavaScript 对象 { current: initialValue },修改它的 .current 属性不会调用 setState,自然不会触发 re-render。
1.3 useRef 的初始化
useRef 接受一个初始值作为参数:
const ref = useRef(initialValue);
// ref.current === initialValue
这个初始值只在首次渲染时使用,后续渲染不会重置 ref——ref 的值在每次渲染之间持久化。
function Counter() {
const countRef = useRef(0);
const handleClick = () => {
countRef.current += 1;
console.log('countRef.current:', countRef.current);
// 点击多次后可以看到计数在增加
// 但组件不会 re-render,所以 UI 不会更新
};
return <button onClick={handleClick}>点击 {countRef.current}</button>;
// 显示的永远是初始值 0,因为没有 state 驱动 re-render
}
1.4 useRef vs useState:何时选择
| 场景 | useRef | useState |
|---|---|---|
| 值的变化需要立即反映在 UI 上 | ❌ | ✅ |
| 值的变化不需要反映在 UI 上 | ✅ | ❌ |
| 需要保存定时器 ID | ✅ | ❌ |
| 需要保存上一次渲染的 props | ✅ | ❌ |
| 值变化后需要触发副作用 | 在 useEffect 中监听 ref 变化 | useEffect 中监听 state 变化 |
一个常见误解:认为 useRef 比 useState "性能更好",所以应该优先使用。这是错误的——useRef 不触发 re-render,所以它根本不在 React 的渲染竞争模型中。如果你需要在 UI 上显示某个值,必须用 state;如果你需要在组件中存储一个值但不需要驱动 UI,useRef 是正确选择。
二、createRef vs useRef
2.1 两种创建 ref 的方式
// 类组件中
class MyComponent extends React.Component {
constructor(props) {
super(props);
this.inputRef = createRef(); // createRef
}
render() {
return <input ref={this.inputRef} />;
}
}
// 函数组件中
function MyComponent() {
const inputRef = useRef(null); // useRef
return <input ref={inputRef} />;
}
2.2 关键区别
createRef 每次渲染都会创建新的 ref 对象。在函数组件中使用 createRef 意味着每次渲染都会创建一个新的 { current: null } 对象,这会导致 ref 引用不稳定。
// 错误:在函数组件中使用 createRef
function MyComponent() {
const inputRef = createRef(); // 每次渲染都创建新对象!
useEffect(() => {
inputRef.current.focus(); // focus() 可能在某些渲染中失效
}, []);
return <input ref={inputRef} />;
}
useRef 使用单例模式——它维护的 ref 对象在多次渲染之间保持稳定:
// 正确:在函数组件中使用 useRef
function MyComponent() {
const inputRef = useRef(null); // 单例,整个组件生命周期内保持同一对象
useEffect(() => {
inputRef.current.focus(); // 稳定引用
}, []);
return <input ref={inputRef} />;
}
2.3 规则:函数组件用 useRef,类组件可以用 createRef
这个区别的工程含义是:
- 函数组件:永远使用
useRef - 类组件:
createRef或在构造函数中创建this.ref = createRef()都可以
// 类组件中 createRef 的正确用法
class MyComponent extends React.Component {
inputRef = createRef(); // 类属性,整个生命周期稳定
componentDidMount() {
this.inputRef.current.focus();
}
render() {
return <input ref={this.inputRef} />;
}
}
三、forwardRef:ref 穿透多层组件
3.1 问题:ref 不能跨组件传递
默认情况下,ref 不能像 props 一样传递——如果你尝试将 ref 作为 prop 传给子组件,React 会忽略它并给出一个警告:
function Parent() {
const inputRef = useRef(null);
return <Child inputRef={inputRef} />; // ref 会被忽略!
}
function Child({ inputRef }) {
return <input ref={inputRef} />; // 不会生效
}
这是因为 ref 在 React 看来是一个"命令式句柄",不应该像普通 props 一样流动。
3.2 forwardRef 的解决方案
forwardRef 允许组件接收 ref 作为第二个参数,并将其转发到内部的 DOM 节点:
const Child = forwardRef((props, ref) => {
return <input ref={ref} />;
});
function Parent() {
const inputRef = useRef(null);
return <Child inputRef={inputRef} />; // ref 现在可以正常传递
}
3.3 forwardRef 的工作原理
从实现角度,forwardRef 是一个 HOC(高阶组件),它接收一个组件并返回一个新组件。新组件的 render 方法中,ref 被作为第二个参数传入:
// forwardRef 的简化实现
function forwardRef(render) {
return function ForwardRefComponent(props, ref) {
return render(props, ref);
};
}
3.4 ref 穿透的实际使用场景
场景一:暴露 DOM 节点给父组件
const Button = forwardRef((props, ref) => (
<button ref={ref} className="btn" {...props} />
));
// 父组件可以直接操作 button 的 DOM
function Parent() {
const buttonRef = useRef(null);
return <Button ref={buttonRef}>点击</Button>;
}
场景二:High-Order Component 中的 ref 穿透
function withLogger(WrappedComponent) {
const WithLogger = forwardRef((props, ref) => {
useEffect(() => {
console.log('Component mounted');
}, []);
return <WrappedComponent ref={ref} {...props} />;
});
return WithLogger;
}
如果不使用 forwardRef,HOC 会"吃掉" ref,导致父组件无法访问底层组件的 ref。
3.5 ref 的类型定义
TypeScript 中使用 forwardRef 时,需要注意 ref 的类型:
// DOM ref
const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => (
<input ref={ref} {...props} />
));
// 组件实例 ref(罕见场景)
interface FancyButtonHandle {
focus(): void;
scrollIntoView(): void;
}
const FancyButton = forwardRef<FancyButtonHandle, ButtonProps>((props, ref) => (
<button ref={ref} {...props} />
));
四、useImperativeHandle:自定义暴露给父组件的方法
4.1 问题:forwardRef 暴露了整个 DOM 节点
有时候你不想把整个 DOM 节点暴露给父组件——这会导致父组件可以执行任何 DOM 操作(如 remove()、replaceWith()),这在封装性上是危险的。
// 使用 forwardRef 后,父组件获得了完整的 DOM 控制权
const Input = forwardRef((props, ref) => <input ref={ref} {...props} />);
function Parent() {
const inputRef = useRef(null);
return (
<>
<Input ref={inputRef} />
<button onClick={() => inputRef.current.remove()}>删除输入框</button>
{/* 危险!父组件不应该能删除子组件的 DOM */}
</>
);
}
4.2 useImperativeHandle 的解决方案
useImperativeHandle 允许你自定义暴露给父组件的 ref 的内容:
const Input = forwardRef((props, ref) => {
const inputRef = useRef(null);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current.focus(),
value: () => inputRef.current.value,
// 只暴露这两个方法,父组件无法执行其他 DOM 操作
}));
return <input ref={inputRef} {...props} />;
});
4.3 使用场景
场景一:封装第三方控件
某些第三方输入控件有自己独特的 API,通过 useImperativeHandle 可以将这些 API 适配到 ref 接口:
const DatePicker = forwardRef((props, ref) => {
const pickerRef = useRef(null);
useImperativeHandle(ref, () => ({
getValue: () => pickerRef.current?.getSelectedDate(),
setValue: (date) => pickerRef.current?.setDate(date),
open: () => pickerRef.current?.showPicker(),
close: () => pickerRef.current?.hidePicker(),
}));
return <ThirdPartyDatePicker ref={pickerRef} {...props} />;
});
场景二:创建命令式 API
某些组件需要提供命令式 API(如视频播放器需要 play()、pause()):
const VideoPlayer = forwardRef((props, ref) => {
const videoRef = useRef(null);
useImperativeHandle(ref, () => ({
play: () => videoRef.current?.play(),
pause: () => videoRef.current?.pause(),
seek: (time) => { videoRef.current.currentTime = time; },
}));
return <video ref={videoRef} src={props.src} />;
});
4.4 注意事项
useImperativeHandle 配合 forwardRef 使用才有意义。单独使用 useImperativeHandle(没有 forwardRef)没有效果,因为父组件根本无法传递 ref。
// 正确用法
const Input = forwardRef((props, ref) => {
useImperativeHandle(ref, () => ({ /* ... */ }));
return <input />;
});
// 错误用法:没有 forwardRef,useImperativeHandle 毫无意义
const Input = (props) => {
useImperativeHandle(props.inputRef, () => ({ /* ... */ })); // 毫无意义
return <input />;
};
五、ref 在 DOM 操作之外的使用场景
5.1 存储上一次的 props 或 state
有时候你需要"记住上一次渲染时的某个值",这在 class 组件中用实例属性轻松实现,在函数组件中可以用 ref 实现:
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value; // 每次渲染后更新
});
return ref.current; // 返回上一次的值
}
function Counter() {
const [count, setCount] = useState(0);
const previousCount = usePrevious(count);
return (
<div>
<p>当前: {count}, 上次: {previousCount}</p>
<button onClick={() => setCount(c => c + 1)}>增加</button>
</div>
);
}
注意这个 pattern 有局限性:它依赖 useEffect 同步更新,所以在同一次渲染中 previous 值是滞后的。如果需要在渲染阶段就获取 previous 值,需要用其他方式。
5.2 存储定时器 ID
定时器 ID 存储在 ref 中是最自然的选择——定时器 ID 只是为了后续清除,不需要驱动 UI:
function AutoCounter() {
const [count, setCount] = useState(0);
const timerRef = useRef(null);
const start = () => {
timerRef.current = setInterval(() => {
setCount(c => c + 1);
}, 1000);
};
const stop = () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
useEffect(() => {
return () => clearInterval(timerRef.current); // 清理函数
}, []);
return (
<>
<p>{count}</p>
<button onClick={start}>开始</button>
<button onClick={stop}>停止</button>
</>
);
}
5.3 避免 useEffect 依赖频繁变化的对象
当 useEffect 依赖一个高频变化的对象时,可以将这个对象存储在 ref 中,通过 ref 的变化来触发副作用:
function SearchComponent({ query }) {
const queryRef = useRef(query);
const [results, setResults] = useState([]);
useEffect(() => {
queryRef.current = query; // 更新 ref
// 发起搜索请求
fetchSearch(query).then(setResults);
}, [query]); // query 变化触发 effect
// 如果我们不需要 query 作为依赖...
// 可以用 ref 存储 query,在 effect 内部读取 queryRef.current
}
六、ref 转发和 callback ref
6.1 callback ref:更灵活的 ref 赋值方式
除了 ref={refVar} 这种赋值方式,React 还支持 callback ref——一个接收 DOM 节点作为参数的函数:
function CallbackRefExample() {
const [isFocused, setIsFocused] = useState(false);
const setInputRef = (node) => {
if (node) {
// DOM 节点挂载时调用
node.focus();
node.addEventListener('focus', () => setIsFocused(true));
node.addEventListener('blur', () => setIsFocused(false));
}
};
return (
<div>
<input ref={setInputRef} />
<p>{isFocused ? '输入框获得焦点' : '输入框未获得焦点'}</p>
</div>
);
}
6.2 callback ref vs 对象 ref
| 特性 | 对象 ref | callback ref |
|---|---|---|
| API | ref={myRef} |
ref={(node) => { ... }} |
| 灵活性 | 固定 | 可自定义逻辑 |
| 多次回调 | 不适用 | DOM 挂载/卸载时各调用一次 |
| 适用场景 | 一般 DOM 引用 | 需要在挂载时执行逻辑 |
6.3 混用 forwardRef 和 callback ref
forwardRef 接收的 ref 参数同样可以是 callback ref:
const Input = forwardRef((props, ref) => {
// ref 可能是一个回调函数,也可能是一个 ref 对象
return <input ref={ref} {...props} />;
});
function Parent() {
const setInputRef = (node) => {
if (node) {
console.log('Input mounted:', node);
}
};
return <Input ref={setInputRef} />;
}
七、面试高频问题
Q: useRef 和 useState 的区别是什么?
回答要点:两者都是用来存储组件数据的方式,但行为完全不同。useState 的变化会触发组件 re-render,UI 会更新;useRef 的变化不会触发 re-render。useRef 本质上是一个 mutable container,它的 .current 属性可以存储任何值,修改它不会让 React 重新渲染组件。useRef 适合存储那些"不需要显示在 UI 上"的值,如定时器 ID、上一次渲染的 props 值等;useState 适合存储那些"变化后需要更新 UI"的值。
Q: forwardRef 的作用是什么?
回答要点:默认情况下,ref 不能像 props 一样传递给子组件——这是 React 的设计决策,因为 ref 是一个命令式句柄。forwardRef 允许组件接收 ref 作为第二个参数并将其转发到内部的 DOM 节点。这在创建可复用的 UI 组件(如 Button、Input)时非常有用——父组件可以通过 ref 直接操作这些 DOM 节点。但需要注意:不加限制地暴露 ref 会破坏组件的封装性,可以使用 useImperativeHandle 限制暴露的内容。
Q: useImperativeHandle 是什么?什么场景下需要用?
回答要点:useImperativeHandle 允许你自定义通过 ref 暴露给父组件的内容。正常情况下,如果一个组件使用 forwardRef 接收 ref,它会将这个 ref 转发给内部的 DOM 节点,父组件就获得了完整的 DOM 操作能力。useImperativeHandle 允许你在 ref 中放置一个自定义对象,只暴露必要的方法(如 focus()、scrollIntoView()),而不是整个 DOM 节点。典型使用场景包括封装第三方控件、创建命令式 API(如视频播放器的 play/pause)等。
延展阅读
- React 官方文档:Refs and the DOM — 官方对 refs 的完整说明
- React 官方文档:forwardRef — forwardRef API 文档
- React 官方文档:useImperativeHandle — useImperativeHandle API 文档
- Dan Abramov: Refs and useEffect — 解释 refs 与 useEffect 的关系
- Josh W. Comeau: Why React's useRef is weird — useRef 的深入分析