React useRef 完全指南:底层原理、常见误区与实战避坑
React 里有个小小的 Hook几乎每个组件都见过它但真要问一句“useRef 到底是干嘛的”不少人会愣一下——知道它能拿 DOM知道它返回一个带 current 的对象可再往深了问比如“它和 useState 到底啥区别”“为什么函数组件里不能直接给 ref 赋值”“怎么把子组件的方法暴露给父组件”能讲利索的人就不多了。我早几年刚从类组件转函数组件的时候在这上面也没少栽跟头。后来在项目里用多了踩过几次坑才慢慢把 useRef 这套东西理清楚。这篇东西就是想把 useRef 的底层逻辑和真实应用场景一次讲透。不是给你念一遍官方文档而是从“它到底是什么”开始一路讲到最容易被忽略的进阶用法和最容易翻车的那些细节。适合正在学 React Hooks 的初学者也适合用了很久但总觉得哪里没吃透的开发者。1. 先别急着用搞清楚 useRef 的本质再动手1.1 一个永远返回同一个对象的小盒子要理解 useRef最关键的一点是它返回的并不是一个普通变量而是一个对象这个对象在整个组件的生命周期里都是同一个引用。这句话听起来简单但就是很多人搞混的根源。用代码说话最直观import { useRef, useState } from react; function Counter() { const countRef useRef(0); const [countState, setCountState] useState(0); console.log(countRef 的引用是否不变, countRef); // 每次都一样 console.log(countRef.current:, countRef.current); // 一直是同一个值 console.log(countState:, countState); // 每次更新都是新值 return ( div button onClick{() (countRef.current 1)} ref 加 1当前{countRef.current} /button button onClick{() setCountState(countState 1)} state 加 1当前{countState} /button /div ); }你把这段代码跑起来点“ref 加 1”那个按钮页面上的数字并不会变——因为ref 变化不会触发重新渲染。但你再去点 state 的按钮触发一次渲染会发现 ref 那边显示的数字已经变了。这就是 useRef 的第一个核心特性它可以跨渲染周期保存数据但它本身跟渲染没任何关系。你不触发渲染它不会自己更新界面但你改了它值一定会保存在那里等下一次任意原因触发渲染时读到的就是最新值。这个特性和 Vue 里的 ref 完全是两回事Vue 的 ref 是响应式的改值会自动更新视图React 的 useRef 是“非响应式的档案柜”只负责存不负责通知别人来看。初学 React 的时候如果带着 Vue 的习惯来用 useRef非常容易产生“为什么我改了值页面没反应”的困惑。1.2 useRef 的基本用法两种核心用途useRef 的基本用法就两种一个是引用 DOM 节点一个是保存任意可变数据。前者是大多数人最先接触的场景后者才是真正能让你写出更优雅代码的关键。先看最常见的 DOM 引用function TextInputWithFocus() { const inputRef useRef(null); const focusInput () { // 拿到真实的 DOM 节点调用它的方法 inputRef.current.focus(); inputRef.current.style.borderColor #4f9cf9; }; return ( div input ref{inputRef} typetext placeholder点击按钮聚焦我 style{{ padding: 8, border: 2px solid #ddd }} / button onClick{focusInput} style{{ marginLeft: 8 }} 聚焦输入框 /button /div ); }这段代码的逻辑是React 在渲染 DOM 后会把真实的 DOM 节点对象塞进 inputRef.current 里。之后你想对这个 DOM 做任何操作——聚焦、获取值、测量宽高、手动修改样式——都从这里走。但注意一个细节useRef(null) 的 null 只是初始值它不影响任何东西。你写 useRef() 或者 useRef(undefined) 也可以React 在挂载 ref 时用的是“把 DOM 节点赋值给 current”这一步初始值只是你没拿到 DOM 之前的占位符。只不过 null 是 React 官方约定俗成的写法一来语义清晰二来避免误用。再看保存可变数据的用法这个很多人没意识到function TimerExample() { const [seconds, setSeconds] useState(0); const timerRef useRef(null); const startTimer () { if (timerRef.current ! null) return; // 防止重复启动 timerRef.current setInterval(() { setSeconds((s) s 1); }, 1000); }; const stopTimer () { clearInterval(timerRef.current); timerRef.current null; }; // 组件卸载时清理避免内存泄漏 useEffect(() { return () { if (timerRef.current ! null) { clearInterval(timerRef.current); } }; }, []); return ( div p已运行{seconds} 秒/p button onClick{startTimer}开始/button button onClick{stopTimer}停止/button /div ); }定时器的 ID 是一个很典型的场景。如果不存到 ref 里你可能想着用普通变量存但普通变量在组件重新渲染时会被重置想用 useState 存又会因为“赋值一个定时器 ID”这种行为触发无所谓的多余渲染。useRef 正好卡在中间既能跨渲染保存又不会引发渲染用它存定时器 ID、requestAnimationFrame ID、Socket 实例、任何不需要展示给 UI 的数据对象都是非常适合的。2. useRef 和 useState 的边界到底在哪2.1 同一段代码值更新了页面没变和页面变了值没更新的区别刚接触 useRef 的人最容易犯的一个错误就是拿它当 useState 用——期望改了 .current 之后界面也跟着刷新。我也干过这种事当时做一个拖拽组件想着把拖拽的坐标存进 ref这样高频更新的时候不卡结果发现界面纹丝不动。后来才明白这俩 Hook 在 React 里的职责是完全不同的。useState 的职责是“触发渲染”。你调用 setStateReact 会安排一次重新渲染然后新的 UI 才会出现。useRef 的职责是“跨渲染保存数据”。它自己永远不会触发渲染。你可以这么理解useState 是“开着聚光灯的舞台”每次换演员都要通知观众看useRef 是“后台的储物柜”你往里面塞什么都不会有人知道但你需要的时候随时可以打开拿。这里有几组对比更直观维度useStateuseRef数据存在哪React 的 hook 链表节点中也是 hook 链表节点中值更新后会渲染吗会这是它的核心职责不会它没有通知机制重新渲染后值还在吗在React 帮你维护在同一个对象引用不变可以用在事件处理器里吗可以但要通过 setter可以直接改 current用在高频更新场景性能差每次都要渲染性能好不触发渲染用于保存 DOM 节点不行setState 第一次是 undefined可以React 主动赋值用于保存定时器/Socket ID可以但过度设计最合适用于在 useEffect 中做依赖可以但要注意引用类型不能ref 变化不会触发 effect2.2 用 useRef 保存“上一次的值”比 useEffect 里偷偷记录优雅很多有一个非常经典的应用场景是“拿到上一次渲染时的值”。比如一个计数器你不但想知道现在的数字还想知道它上一次是多少用来判断是变大了还是变小了。很多人的第一反应是const [count, setCount] useState(0); const [prevCount, setPrevCount] useState(0); useEffect(() { setPrevCount(count); }, [count]);这个写法有两个问题。其一useEffect 是在渲染之后执行的你在 effect 里 setState等于多触发了一次渲染性能上不划算其二依赖数组稍微复杂一点就容易出现“值不对”的诡异 bug排查起来非常费劲。更优雅的做法是用 ref 在渲染过程中“顺手”把上一轮的值存下来function usePrevious(value) { const ref useRef(); // 第一次渲染时 ref.current 是 undefined这是预期行为 useEffect(() { ref.current value; }, [value]); // 返回的是“上一次”的值因为 current 还没被本次 effect 更新 return ref.current; } function Counter() { const [count, setCount] useState(0); const prevCount usePrevious(count); const isIncreasing prevCount undefined ? false : count prevCount; return ( div p当前值{count}上一次的值{prevCount ?? 无}/p p趋势{isIncreasing ? 上升 : 下降或持平}/p button onClick{() setCount(count 1)}加 1/button /div ); }这个自定义 Hook 的思路很巧妙useEffect 确实是在渲染之后才执行所以在一次渲染周期里ref.current 保存的始终是上一次渲染时的值。你拿它和当前渲染的 prop 或 state 比较拿到的就是“变化前后的差值”。这个模式我第一次看到时感觉像变了个魔术但原理其实一点都不复杂就是利用了“effect 在 paint 之后运行”的时序差。2.3 useEffect 依赖项里为什么不该放 ref还有一种很容易犯的错是把 ref 放进 useEffect 的依赖数组里const inputRef useRef(null); // 错误示范 useEffect(() { console.log(ref 变化了, inputRef.current); // 这里做的任何初始化不会在 ref 重新赋值时触发 }, [inputRef]);你以为“ref 变化了 effect 会执行”实际上不会。因为 useRef 返回的那个对象引用从头到尾就没变过React 对比依赖项的时候发现inputRef inputRef永远相等所以 effect 一次都不会再触发。那你想在“DOM 挂载完成之后做点事情”怎么办正确的做法是让 effect 依赖那些和 DOM 节点相关的真实数据比如组件是否渲染完成、某个 props 是否变化// 正确做法依赖一个能触发渲染的状态 const [isMounted, setIsMounted] useState(false); const inputRef useRef(null); useEffect(() { setIsMounted(true); }, []); useEffect(() { if (isMounted inputRef.current) { inputRef.current.focus(); inputRef.current.setAttribute(data-touched, true); } }, [isMounted]);或者更直接的你可以在事件处理器里访问 ref根本不需要 effect 来做这层中转。能不用 effect 的地方尽量不用React Hooks 的依赖管理越简单bug 越少。3. ref 的进阶玩法ref 回调、forwardRef、useImperativeHandle3.1 函数组件里想用 ref先过 forwardRef 这一关很多从类组件转过来的开发者最开始都会踩一个坑在函数组件上直接写 ref发现拿不到东西。这是因为函数组件默认不接收 ref 作为 props。你写ChildComponent ref{childRef} /的时候“ref”这个属性是被 React 特殊处理的它不会像普通 prop 那样自动传给子组件。解决办法是使用 forwardRef这是一个高阶组件它让函数组件能显式接收 ref 并转发给子组件内部的某个 DOM 节点或其他元素// 子组件一个自定义输入框 import { forwardRef } from react; const FancyInput forwardRef((props, ref) { // ref 是父组件传进来的我们要把它绑定到内部的 input 上 return ( div classNamefancy-input input ref{ref} typetext placeholder{props.placeholder} / span{props.label}/span /div ); }); // 父组件 function Parent() { const fancyRef useRef(null); const handleFocus () { if (fancyRef.current) { // 这里的 current 是子组件内部的 input DOM fancyRef.current.focus(); fancyRef.current.style.backgroundColor #fdd; } }; return ( div FancyInput ref{fancyRef} label姓名 placeholder请输入姓名 / button onClick{handleFocus}聚焦自定义输入框/button /div ); }forwardRef 的写法看起来有点绕但逻辑很清晰父组件创建了一个 ref它的 current 是空对象然后通过 JSX 的 ref 属性传给了 FancyInput。forwardRef 包了一层之后React 会把父组件的 ref 作为第二个参数传给 render 函数你在里面决定把这个 ref 绑定到哪个真正的 DOM 节点上。绑定哪个节点父组件的 ref.current 就是哪个节点这个决定权完全在子组件手里。这个模式在实现 UI 组件库的时候几乎人人都会用到。比如你写一个 Button 组件内部可能是个 button 元素也可能是一个包装了一堆样式的 div你希望父组件能够精准控制内部真正的 button就必须用 forwardRef 把它暴露出去。3.2 useImperativeHandle把子组件的“菜单”给父组件而不是把整个厨房扔过去forwardRef 直接暴露 DOM 节点有一个问题把整块底裤都露给父组件了。父组件不仅能调用 focus还能随意改值、改样式、加类名、移除节点完全不设防。这在团队协作、封装公共组件的时候非常容易出现失控。useImperativeHandle 就是用来解决这个问题的。它让你可以自定义“暴露给父组件的 ref 对象”只给你想给的方法其他的一律藏起来。这个有点像餐厅的管理你作为顾客父组件只能通过菜单useImperativeHandle 定义的方法点菜你不能直接闯进后厨DOM 节点随便翻。import { forwardRef, useImperativeHandle, useRef, useState } from react; const CountdownControl forwardRef(({ initialSeconds 60 }, ref) { const [seconds, setSeconds] useState(initialSeconds); const intervalRef useRef(null); const stop () { if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current null; } }; const start () { if (intervalRef.current) return; intervalRef.current setInterval(() { setSeconds((s) { if (s 1) { clearInterval(intervalRef.current); intervalRef.current null; return 0; } return s - 1; }); }, 1000); }; const reset (newSeconds) { stop(); setSeconds(newSeconds ?? initialSeconds); }; // 定义暴露给父组件的“菜单” useImperativeHandle(ref, () ({ start, stop, reset, getRemaining: () seconds, })); return div倒计时{seconds} 秒/div; }); function Parent() { const countdownRef useRef(null); return ( div CountdownControl ref{countdownRef} initialSeconds{10} / button onClick{() countdownRef.current?.start()}开始/button button onClick{() countdownRef.current?.reset(30)}重置为 30 秒/button /div ); }看到区别了吧父组件拿到的 ref.current 上只有 start、stop、reset、getRemaining 这几个方法它无法直接碰 DOM 里那个计时的 value也无法直接操作 interval 本身。这是一层很好的封装尤其在多人协作的项目里能极大降低组件之间互相误撞的可能性。还有一点值得注意useImperativeHandle 的第二个参数是一个工厂函数这个函数返回的对象就是暴露出去的东西。如果它内部依赖了某些 state比如上面的 getRemaining 返回 seconds你需要在依赖数组里把 seconds 加进去否则闭包会捕获旧值。这个细节非常容易变成一个隐蔽的 bug很多人排查半天都不知道为什么拿到的永远是初始值。3.3 ref 回调一个更灵活的方案除了 ref 对象React 还支持ref 回调函数的写法。你不写ref{someRef}而是写ref{(node) { ... }}React 会在 DOM 节点挂载时调用这个回调参数就是真实的 DOM 节点在节点卸载时会再次调用参数是 null。这个写法最大的好处是你能在回调里做更多自定义的事情比如绑定多个引用或者把节点存进一个 Set 里。典型的场景是动态列表你有若干个同类 DOM 节点需要分别记录function ListWithMeasurer() { const items [A, B, C, D]; const heightMapRef useRef(new Map()); const setItemNode (key) (node) { if (node) { heightMapRef.current.set(key, node.getBoundingClientRect().height); } else { heightMapRef.current.delete(key); } }; return ( ul {items.map((item) ( li key{item} ref{setItemNode(item)} style{{ padding: 12px, borderBottom: 1px solid #eee }} 节点 {item} 的高度{heightMapRef.current.get(item) ?? 待测量}px /li ))} /ul ); }这里每个 li 的 ref 都绑定到一个回调函数上回调函数通过闭包记住了自己的 key然后把节点高度存进一个 Map 里。这个列表不管是增加还是删除高度数据始终同步更新而且不需要额外的 state 频繁触发渲染。我后来在很多测量类组件比如计算列表总高度、实现虚拟列表里都用这个模式非常顺手。需要注意的是“ref 回调”在 React 19 之前有一个行为如果回调函数本身是内联函数组件更新时 React 会先执行“卸载”传 null再执行“挂载”传 node。如果你的 ref 回调里有重逻辑这个行为可能会造成一定性能损耗或者引发一些意外。React 19 已经修复了很多相关边界行为规律更加稳定但旧项目里如果真的遇到奇怪的重复执行可以和useCallback配合来固定回调函数的引用。4. 并发模式下的 useRef拿不准就看看这里4.1 StrictMode 下 ref 为什么会突然多跑一遍React 18 以后默认开启了 StrictMode开发模式下组件会“双调用”。这个双调用是为了帮你暴露不纯的函数、缓存问题、副作用遗漏等问题。很多人在 StrictMode 下发现自己用 useRef 初始化的逻辑跑了两次以为代码有 bug。其实这是预期行为。下面的代码function Component() { const randomRef useRef(null); if (randomRef.current null) { randomRef.current Math.random(); // 这段在 StrictMode 下会执行两次 } return p{randomRef.current}/p; }第一遍渲染时randomRef.current 被赋了一个随机数第二遍“模拟卸载再重新挂载”时useRef 的状态会保留吗实际上 StrictMode 的模拟重挂载会重新运行初始化逻辑你会拿到两个不同的随机数。看上去是“跑了两遍”但从 React 的角度看这是故意在检查你的初始化逻辑有没有“纯函数”的样子。如果你只是为了“只在第一次生成随机数”这种写在 ref 里的随机值在 StrictMode 下就是不可靠的。正确的做法是把需要幂等的初始化放在事件处理器里或者放在 effect 中因为 effect 的清理和重跑在 StrictMode 下是成对出现的你能明确地清理上一次的副作用。function Component() { const randomRef useRef(null); useEffect(() { // effect 中初始化StrictMode 下会先清理再重新执行一次 if (randomRef.current null) { randomRef.current Math.random(); } return () { randomRef.current null; // 清理时同步重置保证一致性 }; }, []); return p{randomRef.current}/p; }这里我把初始化挪到 effect 里并且在清理时重置回 null。StrictMode 下会演示一遍“挂载 - 清理 - 再挂载”的完整流程最后状态是一致的。虽然随机数依然被计算了两次但至少组件不会因为这个重复而出现状态错乱。4.2 并发渲染中被中断的 ref 写入React 18 的并发特性意味着渲染过程可以被更高优先级的任务“打断”。如果你的组件在渲染期间直接修改了 ref.current这个修改可能发生在一次“最终会被丢弃”的渲染中导致 ref 记录了错误的状态。举例来说// 不推荐渲染期间写入 ref function BadComponent({ items }: { items: string[] }) { const lastItemRef useRef(null); lastItemRef.current items.at(-1); // 直接写在渲染函数里 return p最后一项{items.at(-1)}/p; }并发模式下这次渲染可能被中断React 丢掉了这次“尝试”的渲染结果但 lastItemRef.current 已经被修改了。下一次真正提交的渲染读取到的 reference 可能是错误的。更安全的做法是非必要不要在渲染函数里修改 ref。如果确实需要在“某次渲染结果被确认后”记录信息把逻辑放进 useEffect 或 useLayoutEffectfunction GoodComponent({ items }: { items: string[] }) { const lastItemRef useRef(null); // effect 中写入只有真正提交到页面的渲染才会执行 useEffect(() { lastItemRef.current items.at(-1); }, [items]); return p最后一项{items.at(-1)}/p; }这也回到第一节说的useRef 是“档案柜”档案归档的动作最好在他们确认无误后也就是提交渲染后统一进行不要在“草稿阶段”顺手乱记。4.3 什么时候用 useLayoutEffect 而不是 useEffect 操作 DOMuseRef 拿到的 DOM 节点已经挂载到真实 DOM 上了但什么时候读取它的尺寸、位置、滚动状态是个需要考虑的细节。useEffect 是在浏览器绘制之后执行的这时候你去读 DOM 的几何信息可能会看到短暂的一帧“旧状态”造成肉眼可见的闪烁。useLayoutEffect 是在浏览器有机会绘制之前同步执行的。它更适合那些需要“在页面显示之前就完成 DOM 测量和调整”的场景。配合 ref 使用会非常顺function Tooltip({ targetRef }) { const tooltipRef useRef(null); const [pos, setPos] useState({ top: 0, left: 0 }); useLayoutEffect(() { if (!targetRef.current || !tooltipRef.current) return; const targetRect targetRef.current.getBoundingClientRect(); const tooltipRect tooltipRef.current.getBoundingClientRect(); // 计算悬浮窗的最佳位置避免超出视口 let top targetRect.bottom 8; let left targetRect.left targetRect.width / 2 - tooltipRect.width / 2; left Math.max(8, Math.min(left, window.innerWidth - tooltipRect.width - 8)); setPos({ top, left }); }, []); return ( div ref{tooltipRef} style{{ position: fixed, top: pos.top, left: pos.left, zIndex: 999, background: #333, color: #fff, padding: 6px 12px, borderRadius: 6, fontSize: 12, }} 我是悬浮提示 /div ); }这个组件在弹层首次渲染时就把位置计算好了因为用了 useLayoutEffect所以用户第一眼看到的 tooltip 就已经是在正确的位置上。如果用 useEffect可能会出现 tooltip 先出现在左上角、再“瞬移”到目标位置下方的现象尤其在低端设备上很明显。简单的经验法则是只要你在操作 DOM 之后要读取 DOM 信息并且这些信息需要影响当前帧的 UI优先用 useLayoutEffect如果你只是订阅事件、请求数据不需要同步阻塞绘制用 useEffect 足够。5. 这些坑我都踩过你现在可以提前绕开5.1 “修改 ref 不会触发渲染”导致的页面不更新这是 useRef 新手遇到最多的问题。写了一个计数器用 ref 存数字点了按钮发现页面不动然后怀疑 React 坏掉了。实际上逻辑非常简单useRef 没有通知机制。你改了 .current页面没有任何必要重新渲染。React 永远不会主动知道“ref 值变了”。它只会等你因为其他原因触发渲染时顺带读到最新值。所以如果你需要“改了值之后页面立刻变”那么应该用 useState不是 useRef。那 useRef 能做“改值 手动触发渲染”的组合吗可以但非常不推荐const countRef useRef(0); const [, forceRender] useState(); const increase () { countRef.current; forceRender(); // 手动强制渲染 };这个模式相当于绕过了 React 的数据流自己搞了一套“非受控数据 手动刷新”逻辑代码越写越难维护项目里能不用就不用。真要追求性能应该用 useMemo、useCallback、React.memo 这些更和谐的方式而不是把整个渲染机制打碎。5.2 组件卸载后再访问 ref.current 会报错吗很多人担心组件卸载后ref.current 是不是还能用。这里要分两种DOM ref组件卸载后React 会在 DOM 节点被移除前把 ref.current 置为 null。所以你在 useEffect 的清理函数里访问 ref.current可能已经拿不到原来的 DOM 了但这没问题——你本来也不应该再操作一个不在页面上的节点。自定义数据 ref你往 ref.current 塞的任何数据定时器 ID、Socket 对象、对象实例React 不会帮你清。组件卸载了这个对象还留在内存中如果它里面还有事件监听、定时器、网络连接就会造成内存泄漏。所以最佳实践是在 useEffect 的清理函数里把 ref.current 里的资源手动释放。const socketRef useRef(null); useEffect(() { // 模拟建连 const socket { id: Math.random(), close: () console.log(关闭连接), }; socketRef.current socket; socketRef.current.open?.(); return () { socketRef.current?.close(); socketRef.current null; }; }, []);5.3 列表项 ref 全指向最后一个元素在循环列表里直接用同一个 ref 是拿不到每个子元素的const itemRef useRef(null); // 错误每一项都绑定了同一个 ref只有最后一项会被赋值 {list.map(item div ref{itemRef}{item}/div)}你以为 itemRef.current 是每一项的 DOM实际上它是最后一个 div。正确做法是给每个列表项建一个独立的 ref或者用上面讲过的 ref 回调const itemRefMap useRef(new Map()); const setItemRef (id) (node) { if (node) itemRefMap.current.set(id, node); else itemRefMap.current.delete(id); }; // 使用 {list.map(item div key{item.id} ref{setItemRef(item.id)}{item.name}/div)}这样每个 DOM 节点都能被独立记录而 ref 的对象是 Map它本身也可以通过 useRef 保存下来跨渲染不丢失。5.4 闭包陷阱和 useRef 的救场React 函数组件里最隐蔽的问题之一就是闭包捕获旧值。在 setTimeout、Promise、事件订阅等异步代码里如果直接引用 state很可能拿到的是一份旧的快照function DelayedAlert() { const [count, setCount] useState(0); const showAlert () { setTimeout(() { alert(当前 count 是 count); // 这里往往是 0而不是最新值 }, 3000); }; return ( div p{count}/p button onClick{() setCount(count 1)}加 1/button button onClick{showAlert}3 秒后弹窗/button /div ); }如果你先点了“加 1”再点“3 秒后弹窗”弹窗里显示的很大概率是“当前 count 是0”。因为 setTimeout 的函数是在 3 秒后执行的它捕获的是它被创建时那个渲染周期里的 count 值。这时 useRef 可以当“救火队员”function DelayedAlertFixed() { const [count, setCount] useState(0); const countRef useRef(count); // 每次渲染时同步一次 useEffect(() { countRef.current count; }, [count]); const showAlert () { setTimeout(() { alert(当前 count 是 countRef.current); // 永远是最新值 }, 3000); }; return ( div p{count}/p button onClick{() setCount((c) c 1)}加 1/button button onClick{showAlert}3 秒后弹窗/button /div ); }这里的关键是 useEffect 每次渲染后都把最新 count 写入 ref异步回调里读 ref.current 永远拿到最新值。这个技巧在实现“输入框防抖搜索”“轮询读取最新待办状态”等场景时特别管用。5.5 多个 ref 的批量管理当一个组件里有多个 ref 需要初始化、清理写法会变得很啰嗦比如const nameRef useRef(null); const emailRef useRef(null); const messageRef useRef(null);如果数量多你可以用一个小技巧把 ref 统一放进一个对象里管理const formRef useRef({}); const bindField (field) (node) { formRef.current[field] node; }; // 绑定 input ref{bindField(name)} / input ref{bindField(email)} / textarea ref{bindField(message)} / // 使用 formRef.current[name]?.focus();这种“ref 聚合”模式在表单特别多、校验逻辑细化到输入框级别时很有用能把维护成本降下来不少。5.6 React 19ref 可以当普通 prop 传了如果你项目已经用上了 React 19会发现一个重大变化函数组件可以直接接收 ref 作为普通 prop不再强制要求 forwardRef。// React 19 的用法 function Input({ ref, label, ...props }) { return ( label {label} input ref{ref} {...props} / /label ); } function Form() { const inputRef useRef(null); return ( div Input ref{inputRef} label搜索 typetext placeholder输入关键词 / button onClick{() inputRef.current.focus()}聚焦搜索框/button /div ); }React 19 把 ref 当成一个常规的 prop 处理绝大多数旧项目中用 forwardRef 写法的代码在升级后依然可用但新代码可以更简洁。如果你正在用一个还停留在 React 16/17 的老项目那 forwardRef 仍然是绕不开的选择。了解这个背景能帮你在看 GithHub 上一些新库源码的时候不至于迷惑为什么有的组件直接写function(props)还带 ref有的又必须包一层 forwardRef。6. useRef 的实战综合案例把前面的知识一口气串起来6.1 需求设计与组件拆分光看知识点容易分散我们做一个比较综合的小组件来收尾一个支持防抖搜索的输入框带历史关键词下拉并且暴露 ref 方法供父组件聚焦或清空。这个需求看似简单实际涵盖了useRef 保存定时器 ID防抖useRef 记录输入框 DOM聚焦、清空useImperativeHandle 暴露子组件能力用 ref 解决异步闭包中的旧值问题用自定义事件或重置 ref 来清理状态拆成两个组件SearchInput封装输入框 防抖 下拉建议forwardRef useImperativeHandleSearchPage父组件通过 ref 调用子组件的 focus/clear 方法6.2 核心代码实现及细节说明先写 SearchInputimport { forwardRef, useImperativeHandle, useRef, useState, useEffect, } from react; const mockSearch (keyword) { // 模拟网络请求延迟 300ms return new Promise((resolve) { setTimeout(() { const suggestions [React, Vue, Angular, Svelte, Solid, Node, Deno] .filter((item) item.toLowerCase().includes(keyword.toLowerCase()) ) .slice(0, 5); resolve(suggestions); }, 300); }); }; const SearchInput forwardRef( ({ placeholder 请输入关键词, onSearch }, ref) { const [keyword, setKeyword] useState(); const [suggestions, setSuggestions] useState([]); const [open, setOpen] useState(false); const inputRef useRef(null); const timerRef useRef(null); const latestKeywordRef useRef(); // 清理定时器 useEffect(() { return () { if (timerRef.current) { clearTimeout(timerRef.current); } }; }, []); const handleChange (e) { const value e.target.value; setKeyword(value); latestKeywordRef.current value; // 保存最新值防止异步拿到旧值 // 防抖先清除上一个定时器 if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current null; } timerRef.current setTimeout(async () { if (!latestKeywordRef.current.trim()) { setSuggestions([]); setOpen(false); return; } // 用 ref 里保存的最新值发请求避免闭包拿到旧关键词 const data await mockSearch(latestKeywordRef.current); setSuggestions(data); setOpen(data.length 0); }, 300); }; const handleSelect (suggestion) { setKeyword(suggestion); latestKeywordRef.current suggestion; setOpen(false); onSearch?.(suggestion); }; const clear () { setKeyword(); latestKeywordRef.current ; setSuggestions([]); setOpen(false); }; const focus () { inputRef.current?.focus(); }; // 暴露给父组件的方法 useImperativeHandle(ref, () ({ focus, clear, getKeyword: () latestKeywordRef.current, })); return ( div style{{ position: relative, display: inline-block }} input ref{inputRef} typetext value{keyword} placeholder{placeholder} onChange{handleChange} onFocus{() suggestions.length 0 setOpen(true)} onBlur{() setTimeout(() setOpen(false), 150)} style{{ width: 260, padding: 8px 12px, borderRadius: 6, border: 1px solid #ccc }} / button onClick{clear} style{{ marginLeft: 4, padding: 8px 12px }} 清空 /button {open ( ul style{{ position: absolute, top: 100%, left: 0, width: 278, margin: 0, marginTop: 4, padding: 6px 0, listStyle: none, background: #fff, border: 1px solid #eee, borderRadius: 8, boxShadow: 0 6px 20px rgba(0,0,0,0.08), zIndex: 10, }} {suggestions.map((item) ( li key{item} onMouseDown{() handleSelect(item)} style{{ padding: 8px 16px, cursor: pointer }} {item} /li ))} /ul )} /div ); } ); export default SearchInput;这段代码有几个值得注意的细节细节一handleChange里的防抖用了timerRef而不是局部变量。局部变量在函数每次调用时都会重建根本存不住定时器 IDstate 也可以但每次 setState 都触发渲染完全没必要。ref 是这里最合适的容器。细节二latestKeywordRef保存了最新的输入关键词。防抖等待的 300ms 内用户可能又输了很多新字符等到定时器触发时闭包里的keyword还是旧的。用 ref 保存最新值后setTimeout 回调里读到的永远是当前最新输入不会出现“搜了 A 却返回 B 的结果”这种错乱。细节三onBlur里用了setTimeout(() setOpen(false), 150)这 150ms 的延迟是为了给onMouseDown触发选中争取时间。如果你直接把下拉项做成onClick在点击之前对 input 的 blur 可能已经把下拉关掉了这是下拉类组件很常见的交互陷阱。用onMouseDown能优先于onBlur执行点击项就不会丢失。6.3 父组件如何通过 ref 精确控制子组件再写父组件import { useRef } from react; function SearchPage() { const searchRef useRef(null); const handleFocus () { searchRef.current?.focus(); }; const handleClear () { searchRef.current?.clear(); console.log(当前搜索词, searchRef.current?.getKeyword()); }; return ( div style{{ padding: 40 }} h3商品搜索/h3 SearchInput ref{searchRef} placeholder搜索 React / Vue 等框架 onSearch{(kw) console.log(发起搜索, kw)} / div style{{ marginTop: 12 }} button onClick{handleFocus} style{{ marginRight: 8 }}点击聚焦搜索框/button button onClick{handleClear}一键清空/button /div /div ); } export default SearchPage;这里的searchRef.current只能调用 focus、clear、getKeyword拿不到内部 input 节点本身也碰不到 suggestions 的 state。这就是 useImperativeHandle 带来的封装边界提供你需要的能力锁住不应该暴露的东西。整套代码跑起来之后你会看到输入关键词停顿 300ms 后才触发搜索建议点下拉建议项输入框值自动填充并回调 onSearch父组件按钮可以聚焦、清空输入框组件卸载时定时器被自动清理不会造成内存泄漏6.4 这个案例还能怎么扩展这个组件的核心逻辑其实已经覆盖了一个中型前端项目里相当常见的交互模式。你可以把它继续扩展成远程搜索联想输入框把 mockSearch 换成真实 API 请求在此基础上增加请求竞态处理给每次请求打标记过期请求的结果不采纳支持键盘上下选择用 ref 记录当前高亮的下标上下键移动时更新高亮输入框值跟着变化支持清除历史记录把历史搜索词存进 localStorage历史列表用 ref 维护避免每次输入都触发 setState配合虚拟列表当下拉建议数量很大时用 ref 存滚动容器节点配合 onScroll 事件做虚拟列表渲染这些扩展方向都能进一步检验你对 ref 各种用法是否真正掌握。尤其是请求竞态那块闭包陷阱和 ref 记录请求序号是两个非常典型的解法建议自己动手写一遍印象会深刻很多。最后再说点个人感受用了这么多年 React我最大的体会是useRef 是一个看起来简单、用起来万能的工具但正因为它太万能很多人反而用不好。你对它的定位越清晰——它是一个可以跨渲染保存数据的普通对象它不参与渲染它不会通知 React——你的 React 水平就越上一层。另一个经验就是遇到“状态存了但渲染不对”“异步回调拿到的值不对”“函数组件拿不到 ref”这类问题优先想 useRef但要想清楚到底该往 ref 里放“DOM 节点”还是“数据”这两者心智模型完全不同。最后给一个小建议当你封装公共组件时能用 useImperativeHandle 尽量用能给父组件 3 个方法就绝不给 4 个这个 API 设计习惯能帮你在团队协作里省掉很多“乱动别人内部零件”的麻烦。