尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

深入理解React useRef:原理、应用场景与常见坑

深入理解React useRef:原理、应用场景与常见坑 1. 从一个困扰我很久的问题说起如果你写过一阵子 React大概率会碰到这么个场景写了个定时器想在组件卸载的时候把它清掉结果cleanup函数里怎么都拿不到最新状态或者想手动聚焦一个输入框document.getElementById用起来总感觉自己写的是 jQuery再或者想存个标志位一不小心用了useState结果每次赋值都触发一次重新渲染页面卡得不行。我第一次意识到必须把useRef吃透是在做一个表单编辑器的时候。那个项目里有十几个动态生成的输入项用户每敲一个字符组件都要重新渲染一次。我当时为了拿到某个节点的位置信息直接在渲染函数里document.querySelector结果又慢又容易拿到过期的 DOM。后来改成useRef存节点引用瞬间清爽了。但真正让我对useRef改观的是后来研究它的底层实现时发现——它的本质不是一个DOM 引用工具而是一个跨渲染周期保存可变数据的容器。这篇文章我会从最基础的角度切入把useRef的原理、应用场景、常见坑和 React 19 的新变化系统讲一遍。不管你是刚学 Hooks 的新手还是写了两三年 React 想查漏补缺的开发者都能在这里找到点东西。2. useRef 的底层机制与核心理解2.1 useRef 只是返回了一个可变对象很多人对useRef的第一印象是用来拿 DOM 的 API这其实是 API 设计带来的错觉。官方文档里写得很清楚useRef返回一个可变的 ref 对象其.current属性被初始化为传入的参数。返回的对象在组件的整个生命周期内保持不变。这句话拆开就两个关键点useRef返回的是一个对象不是原始值。这个对象上的current属性可以随时改改了不会触发重新渲染。看一行最简单的代码function Counter() { const countRef useRef(0); const handleClick () { countRef.current 1; console.log(countRef.current); }; return button onClick{handleClick}点击/button; }这个例子点击十次countRef.current会变成 10但页面上的内容一点都不会变。因为在 React 眼里这个 ref 对象从头到尾就是同一个引用current的值变了也不涉及状态变更自然不触发渲染。这正是useRef和useState最本质的分界线前者是可变的但不驱动 UI后者是不可变但驱动 UI。2.2 为什么它能跨渲染保持同一个引用这一节想讲讲 React 内部的执行逻辑。你可能听过一个说法组件每次渲染都是重新执行一次函数。这个说法没错但有一个例外就是 ref 对象本身。React 在组件挂载的时候会创建这个 ref 对象并把它存在 fiber 节点上。之后每一次重新渲染只要组件还在React 都会把之前存在 fiber 里的那个对象原封不动地再传给你。你可以理解为useRef的返回值是一个单例组件换了多少张皮渲染结果它还是原来的内核。这个特性极其重要。这意味着你可以放心地把 ref 对象放进useEffect的依赖数组里因为它的引用永远不变effect不会因为 ref 变化而重复执行。你可以在任意闭包、任意回调函数里通过ref.current拿到最新值而不是某个时刻的旧值快照。用生活化的比喻来说useState像是给你一张纸条每次更新都要重新写一张新的useRef则像是给你一块白板你随时可以擦掉重写但白板一直就是那一块。2.3 和 useState 的选择判断标准这个判断标准是很多初学者最纠结的点。我根据自己的实践经验给一个判断流程这个值变了需要页面跟着变吗需要就用useState不需要就用useRef。这个值是否需要跨事件、跨定时器、跨异步函数共享如果是useRef是比useState更稳定的选择。这个值是否只是临时存放比如定时器 ID、DOM 节点、滚动位置这几个几乎只用useRef。这里特别提一个我踩过的坑不要为了省一次渲染硬用useRef去管理本该驱动 UI 的数据。反过来也不要用useState去存不该触发渲染的数据比如定时器 ID一旦setState被调用整个子树都会重新渲染一遍如果组件树很重性能直接凉凉。3. 高频场景DOM 引用与组件通信3.1 获取 DOM 节点的标准姿势这是useRef最广为人知的用法。代码如下function SearchInput() { const inputRef useRef(null); const handleFocus () { inputRef.current?.focus(); }; return ( input ref{inputRef} typetext / button onClick{handleFocus}聚焦输入框/button / ); }注意几个细节useRef(null)里面传的null是初始值。在首次渲染时ref.current是null只有在 DOM 挂载完成后React 才会把 DOM 节点赋给current。ref.current?.focus()这段使用了可选链不是多余的防御。因为如果你在useEffect里访问ref.current理论上此时已经有值了但在 StrictMode 下或者某些异步时序里防御性判断能避免很多报错。DOM 挂载的时序值得多说一句。在 React 18 之前如果你在useEffect里访问ref.current一般没问题因为useEffect是在浏览器能够访问 DOM 之后才执行的。如果你在useLayoutEffect里访问甚至可以在浏览器绘制之前就拿到 DOM 节点。所以记住一个匹配关系useRef 拿到 DOM 节点的时机初次渲染完成后 useEffect 执行的时机DOM 更新后恰好可以访问 ref.current useLayoutEffect 执行的时机DOM 变更后、浏览器绘制前这个匹配关系理解了就不会再出现为什么 ref.current 是 null的玄学问题。3.2 ref 回调与 useImperativeHandle除了ref{myRef}这种写法还有一种叫 ref 回调的用法适合场景更复杂的 DOM 管理function MeasureExample() { const [height, setHeight] useState(0); const measureRef useCallback((node) { if (node ! null) { setHeight(node.getBoundingClientRect().height); } }, []); return ( h1 ref{measureRef}Hello, world/h1 p上面的标题高度是 {height}px/p / ); }这个写法的好处是回调函数会在节点挂载和卸载时分别调用一次而且通过useCallback绑定 ref 函数可以让 React 在依赖不变时跳过不必要的调用。注意我这里用了useCallback如果不用每次渲染都会生成一个新的函数React 会不断解绑再绑定旧节点这属于不必要的开销。如果你封装的是自定义组件还想对外暴露组件内部的方法就要用到useImperativeHandle。它配合forwardRef一起使用const FancyInput forwardRef(function FancyInput(props, ref) { const inputRef useRef(null); useImperativeHandle(ref, () ({ focus: () inputRef.current?.focus(), clear: () { if (inputRef.current) inputRef.current.value ; } })); return input ref{inputRef} {...props} /; });父组件拿到ref后只能调用focus和clear不能直接操作内部 DOM。这相当于给你的组件实例开了一个受控的后门从封装设计的角度看比直接暴露出 DOM 节点要安全得多。4. 进阶应用可变容器在实战中的妙用4.1 定时器控制与清理定时器是useRef的最佳练兵场没有之一。原因很简单setInterval返回一个数字 ID这个 ID 既不需要驱动 UI又需要能在任意位置被clearInterval访问。用useState存这个 ID 完全是浪费渲染性能。function Timer() { const timerRef useRef(null); const startTimer () { if (timerRef.current) return; // 防止重复启动 timerRef.current setInterval(() { console.log(tick); }, 1000); }; const stopTimer () { clearInterval(timerRef.current); timerRef.current null; }; useEffect(() { return () { clearInterval(timerRef.current); }; }, []); return ( div button onClick{startTimer}启动/button button onClick{stopTimer}停止/button /div ); }这里有几个容易漏掉的细节定时器启动前要检查timerRef.current是否已经有值避免连点按钮导致启动多个定时器。组件卸载时一定要在useEffect的 cleanup 里清理定时器否则就是经典的内存泄漏。停止后建议把timerRef.current置回null方便下次启动时重新赋值。这个习惯能避免不少逻辑上的边界问题。用 ref 存定时器的另一个好处是如果你的定时器回调需要读取最新状态你可以把状态同步到 ref 里回调中通过ref.current读取而不是把依赖写进useEffect里触发定时器重建。4.2 缓存上一次状态自定义 usePrevious想要拿到上一次渲染时某个 state 的值这是个非常经典的需求。React 官方没有直接提供这个 Hook但只要对useRef和useEffect有理解你自己就能写出来function usePrevious(value) { const ref useRef(); useEffect(() { ref.current value; }, [value]); return ref.current; }这个 hook 的逻辑很巧妙每次渲染时ref.current还是上一次 effect 执行后写入的旧值。useEffect在渲染完成后执行写入的是最新值供下一次渲染读取。用在哪最常见的是检测某个值是否从 A 变成了 B比如分页加载时判断筛选项是否变化或者聊天窗口里判断当前对话 ID是否变化变了就清空消息列表。很多复杂的useEffect依赖判断其实都能用usePrevious简化。需要注意这个 hook 返回的是上一次提交到 DOM 之后的值不是上一次渲染过程中的值。在并发渲染特性下这两者可能有细微差别。如果你需要绝对准确的上一次渲染值可以把写入动作放到渲染阶段function usePreviousDuringRender(value) { const ref useRef(); const previous ref.current; ref.current value; return previous; }这种写法直接在渲染期间更新 ref语义是在本次渲染开始前记录上次值。它虽然省了一个 effect但有一个限制不能在useMemo或复杂渲染逻辑里随意用因为它改变了渲染期间的副作用规则。一般情况下我更推荐第一种写法更符合 React 的思维模型。4.3 避免重复创建实例有时候某个对象构造函数很重而你只想在组件挂载时创建一次之后每次渲染都复用同一个实例。这种场景用useState的懒初始化也能实现const [client] useState(() new ExpensiveClient());但如果你根本不需要这个对象参与渲染用useRef更清爽const clientRef useRef(null); if (clientRef.current null) { clientRef.current new ExpensiveClient(); }注意这个写法其实是在渲染期间给 ref 赋值的。React 官方文档里有时候也允许这种懒初始化 ref的模式但要小心在并发渲染中组件可能被中断然后重新开始渲染导致这种初始化被多次重复。如果ExpensiveClient的构造函数有副作用比如发起请求渲染阶段被重复执行就是个大坑。对这种场景我更推荐把初始化放到useEffect或者直接用useState的懒初始化因为useState能保证初始化只执行一次。const [client] useState(() new ExpensiveClient());这句话的效果和懒初始化 ref 一样还能避开并发渲染问题属于用 React 自己的机制去解决别自己造轮子的典型。5. 常见坑与排查实录5.1 ref.current 为什么是 null这个问题我在社区里回答过很多次。最常见的场景是在渲染函数里直接访问ref.current然后得到null。原因是ref.current 是在 DOM 挂载阶段由 React 赋值的此时组件函数已经执行完了。你在渲染函数期间拿当然拿不到。function BrokenExample() { const divRef useRef(null); console.log(divRef.current); // 第一次渲染时是 null return div ref{divRef}内容/div; }这行console.log在首次渲染时会打印null但在组件更新后的第二次渲染时又能打印出 DOM 节点。原因是首次渲染时 DOM 还没生成ref还没来得及绑定第二次渲染时组件复用了之前的 DOM 节点所以能拿到。这正好解释了为什么很多人的代码时好时坏——取决于是否经历过一次更新。如果确实需要在首次渲染后立刻访问 DOM有两个选择在useEffect/useLayoutEffect里访问。在事件回调里访问此时 DOM 一定已经挂载。5.2 想用 ref 去触发更新结果页面不动这是对useRef和useState区别理解不够导致的。我见过一个真实的坑有同学在滚动容器里监听scroll事件滚动中不断更新ref.current上的一个scrollTop值然后在渲染函数里读取这个值去渲染进度条结果进度条一动不动。原因很直白ref 的变更不会通知 React 去重新渲染。渲染函数执行的时候ref.current里的值还是上一次渲染时的旧值。所以需要驱动 UI 的数据必须放进 stateref 只负责存需要跨渲染周期访问但不参与 UI 的数据。如果你确实希望读取 ref 但不频繁更新 state常见的折中方案是滚动中只更新 ref滚动结束时统一 setState 一次或者用requestAnimationFrame节流减少 setState 次数。这样既有最新数据可用又不会每帧都触发渲染。5.3 StrictMode 下的双调用问题React 18 开始开发模式下 StrictMode 会让组件渲染两次、useEffect执行两次这是为了帮你发现潜在的副作用问题。这个特性对 ref 的应用也有影响。一个典型场景你在useEffect里创建了某个资源比如一个WebSocket实例存到ref.current里。在 StrictMode 下这个 effect 会先执行一次创建然后清理一次cleanup再执行一次创建。如果你没写好 cleanup第一次创建的实例就可能残留。解决办法不是躲避 StrictMode而是写标准化的对称代码useEffect(() { const ws new WebSocket(url); wsRef.current ws; return () { ws.close(); wsRef.current null; }; }, [url]);严格遵循创建时必须返回清理函数的规范StrictMode 的问题就不存在了。说到底StrictMode 是帮你在开发阶段暴露问题不是让你关掉的开关。6. React 19 来了ref 的新写法与心智模型升级6.1 ref 可以作为 props 直接传递React 19 之前函数组件里想用 ref必须forwardRef包裹一层。这在封装组件库或者写公共组件的时候非常烦人每个组件都得套一层业务代码里到处是forwardRef的模板代码。React 19 直接把这个限制拿掉了ref 现在可以作为普通 props 传递给函数组件。你不再需要forwardRef直接这样写function FancyInput({ ref, ...props }) { return input ref{ref} {...props} /; } // 使用 FancyInput ref{inputRef} placeholder请输入 /;这是 API 层面的简化但它背后代表了一个心智模型变化以后看到ref不用再想这是不是需要 forwardRef 的组件直接当普通属性用就行。6.2 变化带来的实际好处最直接的受益者是组件库作者。以前每个基础组件都要写export default forwardRef(function Button(props, ref) { return button ref{ref} {...props} /; });现在一行普通函数就完事。代码干净了类型推导也简单了。从调用方的角度看体验没有变化ref照样能在父组件里拿到子组件底层的 DOM 节点。但有一点要提醒React 19 依然保留forwardRef的兼容支持老项目不升级也能跑。如果你在维护一个老组件库不必急着一次性把所有forwardRef都删掉。新版写法最大的价值在于新代码不用再写模板存量代码等自然迭代的时候顺手改就行。6.3 这个变化对 useRef 使用习惯的影响新的 ref-as-prop 写法里useRef本身的用法没有变。你依然需要在父组件里创建 ref 对象依然通过ref.current访问 DOM。变的只是子组件接收方式更自然了。这给了我们一个提醒React 的 API 一直在朝着减少心智负担的方向演进但底层的核心机制——ref 是跨渲染的可变容器、不触发渲染、current 指向实例或 DOM——始终是稳定的。把原理吃透了无论 API 怎么变你都能第一时间平移到新写法上而不是靠死记硬背 API 格式。7. 几个项目里的实际经验总结讲到这里useRef的核心内容基本覆盖完了。最后想分享几个我在实际项目中沉淀下来的经验不算系统但都很实在。第一能用 useRef 直接解决的需求尽量别往 useMemo 里塞。有的人为了性能把所有工具函数都useMemo一下结果依赖数组写错缓存了旧闭包bug 半天查不出来。如果只是想持有某个稳定引用或者实例用useRef比useMemo语义更清晰。第二写自定义 Hooks 时返回给用户的稳定函数可以用 ref 保存。比如你写了一个useInterval内部把callback存在 ref 里每次渲染都更新 ref定时器回调里只读ref.current。这样定时器不会因为callback变化而重建代码也干净。类似模式在事件监听、ResizeObserver 回调里同样适用。第三注意 ref 与闭包的配合。在useEffect异步回调里读ref.current永远能拿到最新值但在某些依赖数组写错的场景下你可能会因为闭包捕获了旧值而误以为是 ref 的问题。排查时先确认依赖数组和调用时机再怀疑 API。第四性能优化时ref 是你绕过 React 渲染机制的逃生舱。当某些高频操作不需要 UI 实时更新时比如拖拽过程中记录鼠标位置、滚动容器里保存上一次滚动位置直接用 ref 存值比 setState 节流更省事。这也是很多画布类、游戏类项目里useRef密度极高的原因。这些经验不需要刻意记等你写多了 React踩过几次状态不同步的坑之后自然而然就会形成用 ref 存什么、用 state 存什么的直觉。希望这篇文章能帮你把这个过程缩短一点。如果你在实际项目里碰到什么奇葩的 ref 问题欢迎把自己的场景拿出来聊。前端这东西很多问题就是说到某个细节忽然就通了。
返回列表