
一句话:
ref是「绕过 React 数据流直接访问 DOM 或保存可变值」的手段;访问 DOM 要用useRef+ref={ref}(函数组件不能直接接收ref,需要forwardRef,React 19 起可以直接接收)。
一、ref 的三种形态
// ① 对象 ref(最常用):useRef 返回的 { current: ... } 对象
const inputRef = useRef<HTMLInputElement>(null);
<input ref={inputRef} />;
// ② 回调 ref:传入一个函数,挂载时调用(参数为 DOM),卸载时以 null 调用
<input ref={(el) => { if (el) el.focus(); }} />;
// ③ 字符串 ref(❌ 已废弃,不要用)
<input ref="myInput" />;
this.refs.myInput;
// 回调 ref 的两种写法(React 19 起支持返回清理函数)
// 1) 简单赋值
<input ref={(el) => { inputRef.current = el; }} />
// 2) 带清理(React 19+,适合需要「解绑」的场景)
<input
ref={(el) => {
// 挂载
el?.focus();
return () => {
// 卸载(或 ref 变化时)
console.log('cleanup');
};
}}
/>
| 形态 | 写法 | 特点 |
|---|---|---|
| 对象 ref | useRef(null) + ref={obj} |
最常用,读写 ref.current |
| 回调 ref | ref={(el) => ...} |
灵活,可在挂载/卸载时执行逻辑 |
| 字符串 ref | ref="x" |
❌ 已废弃(性能与可读性问题) |
二、useRef 返回的对象
const ref = useRef<HTMLInputElement>(null);
console.log(ref); // { current: null }(首次渲染)
// 挂载后:{ current: <input> }
useRef 的三个特性:
| 特性 | 说明 |
|---|---|
| 值在渲染之间保持不变 | 引用同一个对象(ref 本身不变) |
修改 current 不触发重渲染 |
它不参与渲染流程(这是与 useState 的根本差异) |
| 每次渲染拿到同一个对象 | 所以可以用来存「跨渲染的变量」 |
// useRef vs useState 的对比
function Compare() {
const [count, setCount] = useState(0); // 变化 → 触发重渲染
const refCount = useRef(0); // 变化 → 不触发重渲染
const handleClick = () => {
setCount(count + 1); // UI 会更新
refCount.current++; // UI 不更新,但值已变
console.log(refCount.current); // 累加正常
};
return (
<>
<button onClick={handleClick}>点击</button>
<p>state: {count}</p>
<p>ref: {refCount.current}</p> {/* ⚠️ 不主动更新 */}
</>
);
}
| 维度 | useState |
useRef |
|---|---|---|
| 触发重渲染 | ✅ | ❌ |
| 渲染中使用 | ✅(值随渲染更新) | ⚠️ 不应用它决定渲染结果 |
| 适合存 | 影响 UI 的数据 | 与 UI 无关的可变值 |
| 典型用途 | 计数器、表单值、列表 | 定时器 ID、DOM 引用、上一次的值 |
三、访问 DOM 节点
function AutoFocusInput() {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// ⭐ 必须在 useEffect / 事件回调里访问(此时 DOM 已挂载)
inputRef.current?.focus();
}, []);
return <input ref={inputRef} />;
}
访问时机(重要):
① 首次渲染(render 阶段):ref.current 还是 null
→ 组件函数执行时 DOM 还没创建
② 挂载后的 commit 阶段:ref.current 被赋值为 DOM
→ useEffect / useLayoutEffect 里可以访问
③ 卸载时:ref.current 被置回 null
→ 清理函数里不要再访问它
// ❌ 在渲染中访问 ref
function Bad() {
const ref = useRef(null);
console.log(ref.current); // 首次渲染是 null
return <div ref={ref} />;
}
// ✅ 在 Effect 或事件处理里访问
function Good() {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
console.log(ref.current); // ✅ DOM 已存在
}, []);
const handleClick = () => {
console.log(ref.current?.getBoundingClientRect()); // ✅
};
return <div ref={ref} onClick={handleClick} />;
}
// 需要「在 DOM 更新后立刻读取布局」的场景用 useLayoutEffect
function MeasureComponent() {
const ref = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(0);
useLayoutEffect(() => {
// ⭐ 在浏览器绘制前执行 → 不会出现「先渲染错误高度再修正」的闪烁
if (ref.current) setHeight(ref.current.offsetHeight);
}, []);
return <div ref={ref}>高度:{height}</div>;
}
四、在函数组件上使用 ref(forwardRef)
// ❌ 函数组件不能直接接收 ref
function MyInput({ ref }) { // ⚠️ React 18 及之前:ref 不会出现在 props 里
return <input ref={ref} />;
}
// ✅ React 18 及之前:用 forwardRef 转发
const MyInput = forwardRef<HTMLInputElement, Props>(function MyInput(props, ref) {
return <input ref={ref} {...props} />;
});
// 使用
const inputRef = useRef<HTMLInputElement>(null);
<MyInput ref={inputRef} />;
inputRef.current?.focus();
// ✅ React 19:函数组件可以直接把 ref 当普通 prop 接收
function MyInput({ ref, ...props }: Props & { ref?: React.Ref<HTMLInputElement> }) {
return <input ref={ref} {...props} />;
}
// forwardRef 仍然可用(向后兼容),但不再必需
| 版本 | 写法 |
|---|---|
| React 18 及之前 | 必须用 forwardRef((props, ref) => ...) |
| React 19+ | 可以把 ref 当普通 prop 接收;forwardRef 仍兼容 |
一句话记忆:
ref不是普通 prop,它是一个特殊的保留字段——历史上 React 把它从 props 里「摘出去」单独处理,所以函数组件拿不到,必须forwardRef;React 19 起这个限制取消了。
五、useImperativeHandle:暴露自定义的命令式接口
// 场景:父组件需要调用子组件的方法(如「重置」「聚焦」「校验」)
const FancyInput = forwardRef<FancyInputHandle, Props>(function FancyInput(props, ref) {
const inputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState('');
useImperativeHandle(ref, () => ({
// ⭐ 只暴露想暴露的方法,而不是把整个 DOM 交出去
focus() {
inputRef.current?.focus();
},
clear() {
setValue('');
},
getValue() {
return value;
},
}), [value]);
return <input ref={inputRef} value={value} onChange={(e) => setValue(e.target.value)} />;
});
// 父组件
function Parent() {
const handleRef = useRef<FancyInputHandle>(null);
return (
<>
<FancyInput ref={handleRef} />
<button onClick={() => handleRef.current?.focus()}>聚焦</button>
<button onClick={() => handleRef.current?.clear()}>清空</button>
</>
);
}
useImperativeHandle 的取舍:
✅ 适合:需要「命令式」的操作(focus、scrollTo、play/pause、reset、validate)
❌ 不适合:用它来「传递数据」——数据应该用 props / 回调(保持单向数据流)
⚠️ 过度使用会让组件「黑盒化」(父组件依赖子组件的内部方法),
难以测试与重构。优先考虑「用 props 驱动状态」而不是「用 ref 调用方法」。
六、ref 的其它常见用途
// ① 保存定时器 / 订阅 ID(不需要触发渲染)
const timerRef = useRef<number>();
useEffect(() => {
timerRef.current = window.setInterval(tick, 1000);
return () => clearInterval(timerRef.current);
}, []);
// ② 保存「上一次的值」
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
// 使用
const prevCount = usePrevious(count);
<p>现在是 {count},之前是 {prevCount}</p>
// ③ 保存「不需要触发渲染」的可变值(如计数器、标志位)
const renderCount = useRef(0);
renderCount.current++;
// ④ 保存 DOM 尺寸 / 滚动位置
const scrollTopRef = useRef(0);
const handleScroll = (e) => { scrollTopRef.current = e.currentTarget.scrollTop; };
// ⑤ 保存「是否首次渲染」的标记
const isFirstRender = useRef(true);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return; // 跳过首次
}
doSomething();
}, [dep]);
七、多个 ref 与 ref 数组
// ✓ 场景:需要收集一组元素的 ref(如表单字段校验、虚拟列表测量)
function ItemList({ items }: { items: Item[] }) {
const itemRefs = useRef<Map<string, HTMLLIElement>>(new Map());
// 回调 ref:挂载时加入 Map,卸载时移除
const setItemRef = useCallback((id: string) => (el: HTMLLIElement | null) => {
if (el) itemRefs.current.set(id, el);
else itemRefs.current.delete(id);
}, []);
const scrollTo = (id: string) => {
itemRefs.current.get(id)?.scrollIntoView({ behavior: 'smooth' });
};
return (
<ul>
{items.map((it) => (
<li key={it.id} ref={setItemRef(it.id)}>{it.name}</li>
))}
</ul>
);
}
// ⚠️ 不要用「数组 + 索引」存 ref(列表变动时索引会错位)
const refs = useRef<HTMLLIElement[]>([]);
{items.map((it, i) => <li ref={(el) => { refs.current[i] = el!; }} />)}
// ✅ 用 Map + 稳定 ID(如上)
八、常见坑
// ① 在渲染中读取 ref.current
const el = ref.current; // ⚠️ 首次渲染是 null
return <div ref={ref}>{el?.offsetHeight}</div>;
// ② 忘记判空
ref.current.focus(); // ❌ 可能为 null
// ③ 用 ref 保存「应该触发渲染」的数据
const valueRef = useRef('');
valueRef.current = input; // ⚠️ UI 不会更新,应该用 useState
// ④ 函数组件直接接收 ref(React 18 及之前)
function Comp({ ref }) {} // ⚠️ 拿不到 ref,需要 forwardRef
// ⑤ 在 useEffect 的清理函数里访问 ref.current
return () => { ref.current?.focus(); }; // ⚠️ 此时已为 null(DOM 已卸载)
// ⑥ 用字符串 ref
<input ref="input" /> // ❌ 已废弃
// ⑦ 用 ref 做「数据传递」(应该用 props)
<Child ref={dataRef} /> // ⚠️ 反模式
// ⑧ useImperativeHandle 没写依赖数组,导致返回新对象
useImperativeHandle(ref, () => ({ ... })); // ⚠️ 每次渲染都重建(建议加依赖)
// ✅
useImperativeHandle(ref, () => ({ focus() {...} }), []);
// ⑨ 把 ref 直接传给「非 DOM 元素」的自定义组件(未 forwardRef)
<MyComp ref={ref} /> // ⚠️ 警告:Function components cannot be given refs
// ⑩ 在 SSR 中访问 DOM
if (typeof window !== 'undefined') { ... } // ✅ 判断环境
面试延伸
- 「
useRef和useState有什么区别?」
最根本的区别是「改变时是否触发重渲染」:useState的值变化会触发组件重新渲染,且每次渲染拿到的是新的快照值;useRef返回的对象在渲染之间保持不变,修改ref.current不触发渲染,也不参与渲染结果的计算。因此useRef适合存「与 UI 无关的可变值」(定时器 ID、DOM 引用、上一次的值、标志位)。 -
「什么时候能访问到
ref.current?」
① 首次渲染的 render 阶段:ref.current是null(DOM 还没创建);② commit 阶段之后(即useEffect/useLayoutEffect里):ref.current已经是 DOM 节点;③ 组件卸载后:被置回null。所以永远不要在渲染函数里读ref.current,也不要在清理函数里访问它(那时已经为null)。 -
「为什么函数组件不能直接接收
ref?」
因为历史上 React 把ref当作特殊的保留字段——它不会出现在props里,而是被 React 单独「摘出去」处理(用于挂载后赋值)。所以函数组件拿不到它,必须用forwardRef显式转发给内部的 DOM 或子组件。React 19 起这个限制取消了,ref可以作为普通 prop 接收(forwardRef仍向后兼容)。 -
「
forwardRef是做什么的?」
它让函数组件能把外部传入的ref转发到内部的某个 DOM 元素或子组件上。用法是forwardRef((props, ref) => <input ref={ref} {...props} />)。常见场景是「基础组件」(Button、Input)——使用方需要能拿到真实 DOM 做聚焦、测量或与第三方库集成。React 19 起可以直接把ref当 props 接收,不再强制使用它。 -
「
useImperativeHandle用在哪?」
当父组件需要命令式地调用子组件的方法时(如focus()、clear()、reset()、validate()、scrollTo())。它的价值是「只暴露想暴露的接口」,而不是把整个 DOM 节点交出去:useImperativeHandle(ref, () => ({ focus() {...} }))。但要谨慎——过度使用会让组件变成「黑盒」(父组件依赖内部实现),优先考虑用 props 驱动状态。 -
「为什么不能把 ref 当作数据传递的手段?」
因为ref的目的是「绕过 React 数据流访问 DOM 或保存可变值」,而数据流应该走 props / 回调 / Context。用 ref 传数据的后果:① 破坏单向数据流(谁改了数据不可追踪);② 不会触发重渲染(改了也不会更新 UI,除非碰巧有别的更新);③ 难以测试与调试。所以 ref 只用于「DOM 访问」与「与 UI 无关的可变值」。 -
「列表里的多个元素怎么收集 ref?」
三种方式:①useRef<Map<id, Element>>+ 回调 ref(推荐,卸载时能精确删除);②useRef<Element[]>([])+ 索引(⚠️ 列表增删排序时索引会错位,不推荐);③ 在回调 ref 里用el.dataset或闭包传 ID。推荐用 Map 是因为它用「稳定 ID」作键,列表变动不会错位。 -
「需要读取 DOM 布局信息时,用
useEffect还是useLayoutEffect?」
用useLayoutEffect。因为它在浏览器绘制前同步执行——如果你在useEffect(绘制后异步执行)里测量尺寸再 setState,用户会先看到「错误布局」再看到「修正后的布局」,产生闪烁。而useLayoutEffect在绘制前完成测量与更新,用户看不到中间状态。代价是它会阻塞绘制,所以只用于「确实需要同步读取/写入布局」的场景。
一句话速记
ref是「绕过数据流访问 DOM / 保存可变值」的手段;useRef返回的对象跨渲染保持同一引用,改.current不触发渲染(这是与useState的根本差异);ref.current在首次渲染时是null,只能在useEffect/ 事件回调里访问;函数组件要用forwardRef转发 ref(React 19 起可以直接当 prop);需要暴露命令式接口用useImperativeHandle(只暴露必要方法);别用 ref 传数据(那是 props 的职责)。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!