useRef是组件的”私人抽屉”——往里放东西不会惊动任何人,但下次打开还在。
一、useRef 做的事:跨渲染持久引用
函数组件每次渲染都是全新执行——函数里的 let、const 全部重新声明,值全部重置。useRef 给你一个跨渲染持久存在的引用:
const ref = useRef(initialValue);
// ref 是一个对象:{ current: initialValue }
// 每次渲染拿到的都是同一个对象
关键特性:改 ref.current 不会触发重渲染。
类比:组件里有两个存储空间——
useState | useRef | |
|---|---|---|
| 类比 | 公告板 | 私人抽屉 |
| 改了之后 | 所有人都看到(触发重渲染) | 没人知道(不触发重渲染) |
| 读取方式 | 渲染时读 state | 渲染时读 ref.current |
| 可变性 | 不可变,必须用 setter | 随便改,直接赋值 |
二、与 useState 的核心区别
2.1 改值是否触发重渲染
function Demo() {
const [count, setCount] = useState(0);
const ref = useRef(0);
return (
<div>
<p>state: {count}</p>
<p>ref: {ref.current}</p>
{/* 点这个 → 页面更新,count 变成 1 */}
<button onClick={() => setCount(count + 1)}>改 state</button>
{/* 点这个 → ref.current 变了,但页面不会更新! */}
<button onClick={() => ref.current++}>改 ref</button>
</div>
);
}
点”改 ref”按钮,ref.current 确实变了,但 React 不知道——所以页面上的 ref: 0 不会自动变成 1。直到下一次因其他原因重渲染时,才会显示新值。
2.2 可变性
// state:必须通过 setter 改,不能直接赋值
const [count, setCount] = useState(0);
count = 1; // ❌ 毫无效果,变量被覆盖,React 不知道
setCount(1); // ✅ 正确方式
// ref:直接改 current 属性
const ref = useRef(0);
ref.current = 1; // ✅ 完全合法,ref 对象引用不变,只是 current 变了
2.3 渲染时的身份
function Demo() {
const [count, setCount] = useState(0);
const ref = useRef(0);
// 每次渲染:
// count 是"那一帧的快照"——渲染后就不变了
// ref 是"同一个对象"——每次渲染拿到的是同一个引用
}
useState | useRef | |
|---|---|---|
| 每次渲染拿到的是 | 那一帧的快照 | 同一个对象 |
| 值的更新时机 | setter 调用后,下次渲染才读到新值 | 赋值后立刻生效 |
| 适合存什么 | 参与 UI 渲染的数据 | 不参与渲染的辅助数据 |
判断标准:这个值变了需不需要更新 UI?需要 →
useState,不需要 →useRef。
三、两大用途
useRef 的用途就两类:
- 访问 DOM 元素——命令式操作 DOM(focus、measure、scroll、集成非 React 库)
- 存储不参与渲染的可变值——previous value、timer ID、abort controller 等
为什么需要 ref 去摸 DOM?
React 的规矩是:你只管告诉它”页面长什么样”,它帮你画出来、帮你更新。你写 <input />,它帮你生成输入框;你改了 state,它帮你刷新页面。这叫声明式——你说”要什么”,不说”怎么做”。
但有些事,你没法靠”描述想要什么”来达成。比如页面一打开,你希望光标自动跑到输入框里——你怎么跟 React 说”请把光标放进去”?没有哪个 JSX 属性能表达”获得焦点”这件事。因为焦点不是一个”样子”,而是一个”动作”。
React 其实能处理动作(<input autoFocus /> 就是它包了一层的例子),但它选择只包那些简单且通用的,剩下的一概不包——因为包了就得永远维护,而且有些事包起来反而碍手碍脚。所以 React 画了一条线:
- 线的左边——能用”长什么样”表达的,React 帮你管
- 线的右边——必须”亲自下命令”的,React 不管
这条线是 React 主动选择不越过的,不是它做不到。
那右边这些事怎么办?你得自己拿到那个真实的 DOM 节点,亲自下命令:input.focus()、element.scrollIntoView()、element.getBoundingClientRect()……ref 就是 React 给你的通行证——你把它挂到元素上,React 就把那个真实节点塞给你,剩下的你自己操作。
为什么命令式操作 DOM 必须用 ref,不能直接 getElementById?
技术上你确实可以直接用原生 API:
function MyComponent() {
useEffect(() => {
// 这样也能拿到节点,也能 focus
document.getElementById("myInput").focus();
}, []);
return <input id="myInput" />;
}
但问题在于:React 管着 DOM 的生杀大权。
React 每次 state/props 变了,就重新执行组件函数,生成新的虚拟 DOM,diff 之后更新真实 DOM。这个过程中真实节点可能被替换、移动、销毁——而你用 getElementById 拿到的旧引用完全不知道:
function MyComponent() {
const [show, setShow] = useState(true);
useEffect(() => {
const el = document.getElementById("myInput");
// 某次渲染后 React 销毁重建了这个 input
// 你手里的 el 指向的是已经不在页面上的僵尸节点
el.focus(); // 可能报错,可能无效
}, []);
return (
<div>
{show && <input id="myInput" />}
<button onClick={() => setShow(!show)}>切换</button>
</div>
);
}
ref 解决了这个问题——让 React 知道你在用这个节点。 你用 ref,React 会在每次虚拟 DOM 渲染到真实 DOM 之后,主动把最新的节点更新到 ref.current 上:节点换了,ref.current 自动更新;节点销毁了,ref.current 自动变 null。你永远拿着的是当前真实在页面上的那个节点。
document.getElementById() → 你自己去捞,React 不知道,可能捞到过期的
ref → React 主动给你,永远是最新鲜的
所以不是”命令式操作 DOM 必须用 ref”,而是在 React 的地盘上操作 DOM,用 ref 才安全——因为 React 通过虚拟 DOM 管着真实 DOM 的生老病死,你得通过它的渠道拿节点,否则你手里的引用随时可能失效。
四、DOM Ref 详解
4.1 ref prop 的用法
React 的 JSX 元素有个特殊的 ref prop——把 useRef 创建的 ref 对象传给它,React 就会把对应的 DOM 节点赋值给 ref.current:
function TextInput() {
const inputRef = useRef(null);
// 渲染后:inputRef.current → <input> DOM 节点
function handleFocus() {
inputRef.current.focus(); // 命令式调用 DOM API
}
return (
<div>
<input ref={inputRef} type="text" />
<button onClick={handleFocus}>聚焦输入框</button>
</div>
);
}
流程:
- 组件渲染,React 创建
<input>DOM 节点 - React 把这个节点赋给
inputRef.current - 你在事件处理器里通过
inputRef.current操作 DOM
4.2 经典场景:自动聚焦
function AutoFocusInput() {
const inputRef = useRef(null);
useEffect(() => {
// 组件挂载后,自动聚焦
inputRef.current.focus();
}, []); // 空依赖:只在挂载时执行一次
return <input ref={inputRef} type="text" placeholder="自动聚焦" />;
}
为什么要在 useEffect 里调 focus() 而不是渲染时直接调?因为渲染时 DOM 还没挂载,ref.current 还是 null。useEffect 在 DOM 更新后执行,此时节点已就绪。
4.3 回调 ref(Callback Ref)
ref prop 除了传 ref 对象,还可以传函数——这就是回调 ref:
function TextInput() {
function handleRef(node) {
// node 是 DOM 节点(卸载时是 null)
if (node) {
node.focus(); // 节点挂载时自动聚焦
}
}
return <input ref={handleRef} type="text" />;
}
回调 ref 的优势:你可以在节点挂载/卸载时做任意操作,不限于赋值给 .current。
典型场景——测量 DOM 尺寸:
function MeasureBox() {
const [height, setHeight] = useState(0);
const measuredRef = useCallback(node => {
if (node) {
setHeight(node.getBoundingClientRect().height);
}
}, []);
return (
<div>
<div ref={measuredRef}>内容区域</div>
<p>高度:{height}px</p>
</div>
);
}
为什么要用
useCallback包裹?这是社区最佳实践(官方文档未明确推荐):如果回调 ref 函数每次渲染都是新引用,React 会先卸载旧 ref(传null),再挂载新 ref——导致不必要的执行。useCallback保证引用稳定。
4.4 React 19+ ref cleanup 函数
React 19 新增了 ref callback 返回清理函数的支持——类似 useEffect 的清理机制:
function TextInput() {
return (
<input
ref={node => {
// 节点挂载时执行
node.focus();
// 返回清理函数——节点卸载时执行(React 19+)
return () => {
console.log("input 卸载了");
};
}}
type="text"
/>
);
}
注意:ref callback 只能返回函数(清理函数)或不返回值。React 19 会检查返回值——如果返回了非函数的值(比如隐式返回了 DOM 节点),会报错。
4.5 其他 DOM 操作场景
// 滚动到底部
const bottomRef = useRef(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// 集成非 React 库(如 D3、Leaflet)
const mapRef = useRef(null);
useEffect(() => {
const map = L.map(mapRef.current).setView([51.505, -0.09], 13);
return () => map.remove(); // 清理
}, []);
五、可变值 Ref 详解
5.1 记录上一次的值
React 没有内置 usePrevious,用 useRef 可以实现:
经典实现(useRef + useEffect):
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value; // 渲染完成后才更新,保证"上一次"
});
return ref.current;
}
⚠️ 局限:这个实现依赖 useEffect 的异步执行时序,在 React 18 Strict Mode(组件双次挂载)下可能出现边界问题。Chakra UI 等主流库已迁移到基于 useState 的实现。
推荐实现(useState-based,更可靠):
function usePrevious(value) {
const [current, setCurrent] = useState(value);
const [previous, setPrevious] = useState(undefined);
// 渲染期间同步比较,值变了就更新
if (!Object.is(value, current)) {
setPrevious(current);
setCurrent(value);
}
return previous;
}
为什么 state-based 更好?因为它在渲染期间同步执行,不依赖 useEffect 的异步时序,在 Strict Mode 和并发模式下行为更可预测。
使用:
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<div>
<p>
当前:{count},上一次:{prevCount}
</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
</div>
);
}
经典实现中赋值放在
useEffect里的原因:useEffect在渲染之后执行——这样本次渲染读到的ref.current还是旧值,渲染完才更新为新值。如果直接在渲染时赋值,那读到的就是当前值,不是”上一次”了。
5.2 存定时器 ID
function Timer() {
const [seconds, setSeconds] = useState(0);
const intervalRef = useRef(null);
function start() {
intervalRef.current = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
}
function stop() {
clearInterval(intervalRef.current);
}
// 组件卸载时清理
useEffect(() => {
return () => clearInterval(intervalRef.current);
}, []);
return (
<div>
<p>{seconds}秒</p>
<button onClick={start}>开始</button>
<button onClick={stop}>暂停</button>
</div>
);
}
为什么不用 let 存 timer ID?因为每次渲染 let 都会重置,你存不住。useRef 跨渲染持久,完美适配。
5.3 存 AbortController
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const abortRef = useRef(null);
useEffect(() => {
// 取消上一次请求
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setResults(data))
.catch(err => {
if (err.name !== "AbortError") throw err;
});
return () => controller.abort();
}, [query]);
return <ResultList results={results} />;
}
快速输入时,前一次请求被取消,只有最后一次请求的结果会更新 UI——经典的竞态处理模式。
六、ref 不触发重渲染的深层原因
React 的渲染触发机制追踪四类数据源:
- State —
useState/useReducer的值变了 - Props — 父组件传下来的值变了
- Context —
useContext订阅的值变了 - 父组件重渲染 — 即使子组件的 props 没变,父组件重渲染也会导致子组件重渲染(除非用
React.memo包裹)
注意:第 4 条和第 2 条不同——Props 变了是”数据驱动”,父组件重渲染是”渲染传播”。一个子组件可能 props 完全没变,但仍然因为父组件的 state 变了而跟着重渲染。
只有这四类变化才会让 React 重新执行组件函数。ref.current 不在这个追踪列表里——React 根本不监听它。
setState() → React 检测到变化 → 重新渲染组件 → 更新 DOM
ref.current = xxx → React 不知道 → 什么都不做
这不是 bug,而是设计:ref 就是用来存”React 不需要管的数据”的。如果 ref 变了也触发渲染,那它和 state 有什么区别?
本质:
useRef返回的对象和useState返回的 state 存在 React 的同一个 Fiber 节点上——区别不在存储,而在 React 是否追踪变化。State 被追踪,ref 不被追踪。
七、使用规则与陷阱
7.1 不要在渲染过程中读写 ref.current
function Bad() {
const ref = useRef(0);
// ❌ 渲染期间修改 ref——可能导致不一致的行为
ref.current = ref.current + 1;
// ❌ 渲染期间读取 ref 来决定 UI——值可能不是最新的
return <p>{ref.current}</p>;
}
为什么?因为 ref.current 的修改不触发渲染,React 无法保证渲染结果和 ref 值的一致性。如果渲染依赖 ref.current,你就绕过了 React 的数据流——这和直接操作 DOM 一样,是反模式。
function Good() {
const ref = useRef(0);
// ✅ 在事件处理器中读写——安全
function handleClick() {
ref.current += 1;
console.log("点击次数:", ref.current);
}
// ✅ 在 effect 中读写——安全
useEffect(() => {
ref.current = someValue;
});
return <button onClick={handleClick}>点击</button>;
}
规则:渲染时只读 ref 做初始化(
useRef(initialValue)),其他读写都放在事件处理器或 effect 里。
7.2 ref.current 可能是 null
const ref = useRef(null);
// 首次渲染时,如果 ref 还没挂载到 DOM,ref.current 就是 null
useEffect(() => {
// ✅ effect 里安全,DOM 已挂载
ref.current?.focus();
});
function handleClick() {
// ✅ 事件处理器里也安全,用户交互时 DOM 一定在
ref.current?.scrollTo(0, 0);
}
养成用可选链 ?. 的习惯,防止 null 报错。
7.3 函数组件的 ref
默认情况下,函数组件不能接收 ref prop——因为函数组件没有实例。你需要用特定方式让父组件拿到子组件的 DOM 或暴露方法。
React 19+(推荐):ref 已成为普通 prop,不再需要 forwardRef:
// React 19+ — ref 直接作为 prop 接收
function FancyInput({ ref, ...props }) {
return <input ref={ref} className="fancy" {...props} />;
}
// 父组件
function Parent() {
const inputRef = useRef(null);
return <FancyInput ref={inputRef} />;
}
React 19 官方公告原文:
“Starting in React 19, you can now access
refas a prop for function components. New function components will no longer needforwardRef. In future versions we will deprecate and removeforwardRef.”
React 18 及以下(旧写法):需要 forwardRef 包裹:
const FancyInput = React.forwardRef((props, ref) => {
return <input ref={ref} className="fancy" />;
});
function Parent() {
const inputRef = useRef(null);
return <FancyInput ref={inputRef} />;
}
forwardRef + useImperativeHandle 可以精确控制暴露哪些方法——详见进阶篇。
八、与 useEffect 的配合模式
useRef 和 useEffect 是经典搭档——ref 存”跨渲染需要但不应触发渲染”的值,effect 处理副作用和清理。
8.1 模式一:effect 中操作 DOM
useEffect(() => {
inputRef.current.focus();
}, []);
8.2 模式二:effect 中清理副作用
useEffect(() => {
const id = setInterval(callback, 1000);
intervalRef.current = id;
return () => clearInterval(intervalRef.current);
}, []);
8.3 模式三:跳过首次 effect
⚠️ React 官方不推荐此模式(GitHub Issue #24527)——建议将逻辑放到事件处理函数中。
了解即可,不建议在生产环境使用:
function useUpdateEffect(effect, deps) {
const isFirstRender = useRef(true);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return; // 首次渲染跳过
}
return effect();
}, deps);
}
问题:React 18 Strict Mode 下组件会挂载两次,isFirstRender 标志在第二次挂载时已经是 false,行为不符合预期。
推荐替代:用 useRef 存前一个值,在 effect 中比较:
function MyComponent({ value }) {
const prevValue = useRef(value);
useEffect(() => {
if (prevValue.current !== value) {
// 值实际发生了变化
doSomething(value);
}
prevValue.current = value;
}, [value]);
}
8.4 模式四:判断组件是否已卸载
❌ 反模式——不推荐使用。React 官方 2015 年即已声明 isMounted is an Antipattern,eslint-plugin-react 有 no-is-mounted 规则。
了解这个模式是为了避免使用它:
// ❌ 反模式——不要这样做
function useIsMounted() {
const mounted = useRef(false);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
return mounted;
}
// ❌ 典型误用:防止卸载后 setState
function DataFetcher() {
const [data, setData] = useState(null);
const isMounted = useIsMounted();
useEffect(() => {
fetchData().then(result => {
if (isMounted.current) {
// 只是隐藏了警告,没解决根本问题
setData(result);
}
});
}, []);
}
问题:isMounted 检查只是隐藏了警告,不解决根本问题——仍然持有对已卸载组件的引用(内存泄漏),且在并发模式下行为不可预测。
✅ 正确替代——用 AbortController(详见 §5.3):
function DataFetcher() {
const [data, setData] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch("/api/data", { signal: controller.signal })
.then(res => res.json())
.then(result => setData(result))
.catch(err => {
if (err.name !== "AbortError") throw err; // 忽略取消,处理真实错误
});
return () => controller.abort(); // 卸载时取消请求,从根本上避免问题
}, []);
}
规律:凡是”需要跨渲染持久、但不参与 UI 渲染”的数据,都该用
useRef存,然后在useEffect里读写。
九、一句话
useRef是组件的私人抽屉——往里放东西不会惊动 React,下次打开还在。记住三件事:改 ref.current 不触发重渲染、不要在渲染过程中读写 ref、需要触发渲染的数据用 useState 不要用 useRef。