🔥 别再只用 useRef 拿 DOM 了!这 5 个实战场景让你彻底理解它(附源码解析 + 面试题)
摘要 :90% 的 React 开发者对 useRef 的认知停留在
ref={inputRef}+focus()。但 useRef 远不止于此------它是 React 给你的"渲染之外的抽屉",能存 DOM、存值、存 Worker、破解闭包陷阱,甚至帮你实现子组件方法调用。这篇文章用 5 个递进场景 + 源码级原理 + 6 道面试题,帮你彻底搞懂 useRef。
🗺️ 文章大纲
| 序号 | 场景 | 核心知识点 | 难度 |
|---|---|---|---|
| 1 | DOM 引用与自动聚焦 | ref 挂载时序 | ⭐ |
| 2 | 不触发渲染的可变值 | ref vs state 本质区别 | ⭐⭐ |
| 3 | Web Worker 线程管理 | 持有重量级对象 | ⭐⭐ |
| 4 | 闭包陷阱中的逃生舱 | ref 突破闭包捕获 | ⭐⭐⭐ |
| 5 | 子组件方法暴露 | useImperativeHandle | ⭐⭐⭐ |
读完这篇文章,你将获得:
- 对 useRef 从"会用"到"懂原理"的认知升级
- 5 个可直接复用到项目中的实战代码
- 6 道面试真题的深度解析
📌 前言
这篇文章来自我的 useRef 深度学习笔记。
之前我对 useRef 的认知停留在"拿 DOM 用的",直到我在项目中遇到了三个"意外":
- 用
useRef存一个数字,修改它居然不会触发重新渲染 - 用
useRef存一个 Web Worker 实例,它居然能在渲染间持久存活 - 在
useEffect的回调里,用useRef成功突破了闭包陷阱
这三个场景串起来,我才真正理解了 useRef 的设计哲学:它不是 DOM 工具,它是 React 的"渲染之外的抽屉"。
🎯 本文适合谁
- 学过
useState、useEffect,但对useRef一知半解的 React 初学者 - 知道
useRef能拿 DOM,但不知道它还能干嘛的同学 - 面试前想搞清楚
useRefvsuseState区别的人 - 想了解 useRef 源码级原理的进阶开发者
📚 场景一:DOM 引用 ------ 最经典的用法
需求
页面加载后,输入框自动获取焦点,提升用户体验。
代码
jsx
import { useRef, useEffect } from 'react'
const App = () => {
// 1️⃣ 创建 ref,初始值为 null(此时 DOM 还没挂载)
const inputRef = useRef(null)
useEffect(() => {
// 3️⃣ 组件挂载后,inputRef.current 已经指向真实 DOM
console.log(inputRef.current) // <input type="text" />
inputRef.current.focus() // 自动聚焦
}, [])
return (
<input
type="text"
placeholder="请输入用户名"
// 2️⃣ 通过 ref 属性,把 DOM 节点"注入"到 inputRef.current
ref={inputRef}
/>
)
}
关键点:时序!
ini
渲染阶段: inputRef = { current: null } ← DOM 还没创建
↓
React 创建 DOM,把 <input> 赋值给 inputRef.current
↓
effect 阶段: inputRef.current = <input> ← 现在可以操作了
💡 一句话理解 :
useRef就是一个"盒子",渲染时是空的,挂载后 React 会把真实 DOM 放进去。
为什么不用 useState?
jsx
// ❌ 错误示范:用 state 存 DOM
const [inputEl, setInputEl] = useState(null)
// setInputEl(dom) 会触发重新渲染 → 再触发 setInputEl → 死循环风险!
// 而且 DOM 不是"状态",它是渲染的结果
| 对比 | useRef | useState |
|---|---|---|
| 修改时触发渲染 | 不触发 | 触发 |
| 适合存放 | DOM 节点、不需渲染的值 | 需要驱动 UI 更新的数据 |
| 语义 | "引用"------指向某个东西 | "状态"------驱动 UI 的数据 |
📚 场景二:可变值持久化 ------ 不触发渲染的"隐形变量"
需求
一个计数器,点击后数字增加,但不触发重新渲染。这在性能敏感场景(如动画帧计数、事件触发频率统计)中非常有用。
代码
jsx
import { useRef, useState, useCallback } from 'react'
const App = () => {
const numRef = useRef(0) // 引用一个值,初始为 0
const [, forceRender] = useState(0) // 用来手动触发渲染
console.log('渲染了,numRef.current =', numRef.current)
const handleClick = useCallback(() => {
numRef.current += 1 // ⚡ 修改 ref.current 不会触发渲染!
// 只有需要展示时才手动触发
forceRender(n => n + 1)
}, [])
return (
<div onClick={handleClick}>
点击次数:{numRef.current}
</div>
)
}
核心问题:为什么 numRef.current += 1 不触发渲染?
这是理解 useRef 的关键。我们从 React 的调度机制来看:
useState的setCount()→ 调用dispatchAction→ 向 Fiber 节点的lanes注入更新优先级 → React 调度重新渲染useRef的.current = xx→ 直接修改{ current: xx }这个普通对象的属性 → React 的调度系统完全不知道这件事
用一个形象的比喻:
makefile
setState: 我变了!→ React 调度器: 收到,排期重新渲染!
ref: 我变了。→ React 调度器: ...(没收到信号,继续摸鱼)
源码视角:ref 在 Fiber 中是怎么存的?
在 React 的 Fiber 架构中,每个组件对应一个 Fiber 节点。useRef 创建的对象会被挂载到 fiber.memoizedState 上:
js
// 简化的 React 源码(react-reconciler)
function useRef(initialValue) {
// 复用上一次渲染的 ref 对象(如果有的话)
const ref = { current: initialValue }
// 挂载到 fiber.memoizedState 链表上
// 下次渲染时,React 会跳过 ref 的创建,直接复用同一个对象
return ref
}
关键点:ref 对象在渲染间是同一个引用 。你修改 .current 只是改了对象属性,React 的 reconciler 不会对比 ref 对象的内容变化,所以不会触发重渲染。
什么时候用 useRef 存值?
| 场景 | 用什么 | 原因 |
|---|---|---|
| 需要显示在界面上的数字 | useState |
变更需要触发渲染 |
| 上一次渲染的值 | useRef |
不需要触发渲染,只需记录 |
| 定时器 ID | useRef |
存起来以便清除,不需渲染 |
| 动画帧 ID | useRef |
同上 |
| 表单的"脏"标记 | useRef |
标记状态,不需渲染 |
| 事件触发频率统计 | useRef |
高频更新,不应触发渲染 |
📚 场景三:Web Worker 线程 ------ 持有"重量级对象"
需求
主线程执行一个耗时计算(如大数组排序、图像处理),页面被"卡死"了。解决方案:用 Web Worker 把计算放到子线程。
代码
jsx
// === App.jsx ===
import { useRef, useEffect } from 'react'
function App() {
// ref 引用了 worker 线程,初始为 null
const workerRef = useRef(null)
useEffect(() => {
// 开启一个 Worker 线程(开销比较大,只创建一次)
workerRef.current = new Worker(
new URL("./worker.js", import.meta.url)
)
// 监听 Worker 返回的结果
workerRef.current.onmessage = (e) => {
console.log('计算结果:', e.data)
}
// 清理:组件卸载时终止 Worker
return () => {
workerRef.current?.terminate()
}
}, []) // 空依赖 → 只在挂载时执行一次
const handleCalculate = () => {
// 通过 ref 操作 Worker,发送数据到子线程
workerRef.current?.postMessage({ data: [1, 2, 3, 4, 5] })
}
return <button onClick={handleCalculate}>开始计算</button>
}
js
// === worker.js ===
self.onmessage = (e) => {
const { data } = e.data
// 模拟耗时计算
const result = data.reduce((sum, n) => sum + n * n, 0)
self.postMessage(result) // 把结果发回主线程
}
为什么用 useRef 而不是 useState?
jsx
// ❌ 用 useState 存 Worker
const [worker, setWorker] = useState(null)
useEffect(() => {
setWorker(new Worker(...)) // 触发重新渲染!Worker 实例被重建!
}, [])
// ✅ 用 useRef 存 Worker
const workerRef = useRef(null)
useEffect(() => {
workerRef.current = new Worker(...) // 不触发渲染,纯粹的"保管"
}, [])
| 对比 | useRef | useState |
|---|---|---|
| 赋值触发渲染 | 不触发 | 触发 |
| 适合存放 | Worker、定时器、DOM 等"工具对象" | 需要展示在 UI 上的数据 |
| 跨渲染保持 | 同一个引用 | 同一个引用 |
💡 核心区别 :Worker 是一个"工具",不是"数据"。它不需要显示在界面上,你只是需要一个地方"保管"它。
useRef就是那个"保管箱"。
📚 场景四:闭包陷阱中的逃生舱 ------ 面试高频考点
需求
一个组件中有一个 count state,还有一个定时器每秒打印当前 count。你期望定时器打印最新的 count,但它永远打印初始值。
闭包陷阱复现
jsx
import { useState, useEffect } from 'react'
function App() {
const [count, setCount] = useState(0)
useEffect(() => {
const timer = setInterval(() => {
console.log('count =', count) // ❌ 永远是 0!
}, 1000)
return () => clearInterval(timer)
}, []) // 空依赖 → effect 只执行一次 → 闭包捕获了初始的 count
return (
<button onClick={() => setCount(c => c + 1)}>
count: {count}
</button>
)
}
为什么? 因为 useEffect 的回调函数在组件首次渲染时创建,它"闭包捕获"了当时的 count(值为 0)。由于依赖数组是 [],这个回调不会重建,所以永远看到 count = 0。
用 useRef 破解闭包陷阱
jsx
import { useState, useRef, useEffect } from 'react'
function App() {
const [count, setCount] = useState(0)
const countRef = useRef(count) // 用 ref 跟踪最新值
// 每次 count 变化时,同步更新 ref
useEffect(() => {
countRef.current = count
}, [count])
useEffect(() => {
const timer = setInterval(() => {
// ✅ 通过 ref 读取最新值,突破闭包捕获
console.log('最新 count =', countRef.current)
}, 1000)
return () => clearInterval(timer)
}, [])
return (
<button onClick={() => setCount(c => c + 1)}>
count: {count}
</button>
)
}
原理分析
ini
闭包捕获的是"值":count = 0(数字,不可变)
ref 存储的是"引用":countRef = { current: 0 }(对象,可变)
每次 setCount → 触发渲染 → useEffect 同步更新 countRef.current
定时器通过 countRef.current 读取 → 永远是最新值
💡 面试金句:闭包捕获的是"快照",ref 持有的是"活引用"。当闭包内的值需要保持最新时,用 ref 做"逃生舱"。
📚 场景五:子组件方法暴露 ------ useImperativeHandle
需求
父组件需要调用子组件内部的方法(如让子组件的 input 聚焦、让子组件的视频播放/暂停)。
代码
jsx
// === 子组件 ===
import { useRef, useImperativeHandle, forwardRef } from 'react'
const CustomInput = forwardRef((props, ref) => {
const inputRef = useRef(null)
// 暴露给父组件的方法(白名单机制)
useImperativeHandle(ref, () => ({
focus: () => inputRef.current.focus(),
clear: () => { inputRef.current.value = '' },
getValue: () => inputRef.current.value
}))
return <input ref={inputRef} {...props} />
})
jsx
// === 父组件 ===
import { useRef } from 'react'
function App() {
const inputRef = useRef(null)
return (
<div>
<CustomInput ref={inputRef} placeholder="请输入" />
<button onClick={() => inputRef.current.focus()}>聚焦</button>
<button onClick={() => inputRef.current.clear()}>清空</button>
<button onClick={() => alert(inputRef.current.getValue())}>获取值</button>
</div>
)
}
为什么需要 useImperativeHandle?
直接把 ref 传给子组件也能拿到 DOM,但这样做有两个问题:
- 破坏封装:父组件可以调用 DOM 的任何方法,子组件失去对自身 API 的控制
- 不利于重构:子组件内部实现变了(比如 input 换成了 contentEditable),父组件代码也要跟着改
useImperativeHandle 的白名单机制,让子组件只暴露必要的方法,实现了解耦。
🧠 五个场景总结
| 场景 | useRef 存的是 | 为什么不用 useState |
|---|---|---|
| DOM 引用 | 真实 DOM 节点 | DOM 不是状态,且 setState 会触发无意义渲染 |
| 可变值 | 不需要渲染的计数/标记 | ref 变更对 React 不可见,避免不必要的渲染 |
| Worker 线程 | 重量级的"工具对象" | 工具对象不应参与渲染循环 |
| 闭包陷阱 | 最新值的"活引用" | 闭包捕获的是快照,ref 是可变引用 |
| 方法暴露 | 子组件 API 的桥接 | 需要配合 forwardRef 实现跨组件引用传递 |
一句话理解 useRef
useRef是 React 给你的一个"渲染之外的抽屉"。你可以把任何东西放进去(DOM、数字、Worker、定时器 ID...),修改它不会触发渲染,但它会在组件的整个生命周期内保持不变。
❓ 面试官可能会问(6 道高频题)
Q1:useRef 和直接声明一个变量 let count = 0 有什么区别?
A :函数组件每次渲染都会重新执行函数体,let count = 0 每次都会重新声明并重置为 0。而 useRef 返回的对象在渲染间始终是同一个引用,.current 的值会跨渲染保持。
jsx
function App() {
let count = 0 // 每次渲染都是 0(重新声明)
const ref = useRef(0) // 只有第一次是 0,之后保持上次的值
return <button onClick={() => {
count += 1 // 点击后 count = 1,但下次渲染又变回 0
ref.current += 1 // 点击后 ref.current = 1,下次渲染还是 1
}}>点击</button>
}
Q2:useRef 可以替代 useState 吗?
A :不能。ref 变更不触发渲染,所以如果你需要界面上显示最新值,必须用 useState。但你可以用 ref 来"辅助" state,比如记录上一次的值(usePrevious 自定义 Hook)。
jsx
// 自定义 Hook:获取上一次的值
function usePrevious(value) {
const ref = useRef()
useEffect(() => {
ref.current = value // 每次渲染后更新 ref
})
return ref.current // 返回上一次的值
}
Q3:useRef 如何帮助解决闭包陷阱?
A :闭包捕获的是变量的"快照值",而 ref 存储的是一个"可变引用对象"。通过在 effect 中同步更新 ref.current,可以让闭包内的回调函数通过 ref.current 始终读到最新值,从而突破闭包的捕获限制。详见本文场景四。
Q4:useRef 在 Fiber 架构中是怎么存储的?
A :useRef 创建的 { current: initialValue } 对象会被挂载到 Fiber 节点的 memoizedState 链表上。首次渲染时创建对象,后续渲染时 React 复用同一个引用。修改 .current 不会触发调度器的 scheduleUpdate,因为它不经过 dispatchAction 流程。
Q5:useImperativeHandle 的作用是什么?为什么需要它?
A :useImperativeHandle 配合 forwardRef 使用,让子组件可以自定义暴露给父组件的 ref 内容。它实现了"白名单"机制------父组件只能调用子组件明确暴露的方法,而不是直接访问整个 DOM。这提高了组件的封装性和可维护性。
Q6:在 React 19 中,forwardRef 还需要吗?
A :React 19 中,ref 已经作为普通 prop 直接传递给函数组件,不再需要 forwardRef 包裹。但 useImperativeHandle 仍然有用,因为它控制的是"暴露什么",而不是"如何传递 ref"。
⚠️ 常见踩坑提醒
踩坑 1:ref.current 变化不会触发 useEffect
jsx
const countRef = useRef(0)
useEffect(() => {
console.log('countRef.current 变了', countRef.current)
// ❌ 这个 effect 永远不会因为 countRef.current 变化而重新执行
// 因为 React 不追踪 ref 的变更
}, [countRef.current]) // 注意:这个依赖不会生效!
踩坑 2:不能在渲染期间读写 ref(Strict Mode 下会报错)
jsx
function App() {
const countRef = useRef(0)
// ❌ 渲染期间修改 ref(React 18 Strict Mode 会警告)
countRef.current += 1
// ✅ 应该在 effect 或事件处理函数中修改
useEffect(() => {
countRef.current += 1
}, [])
}
🔗 参考资料
📣 写在最后
useRef 是 React 中最容易被低估的 Hook。大多数人只知道它能拿 DOM,但它真正的价值在于:提供了一个渲染周期之外的、持久的、可变的存储空间。
如果你觉得这篇文章帮你理清了 useRef 的脉络,麻烦点个 赞 👍 收个 藏 ⭐,你的支持是我持续输出的最大动力!
你在项目中用 useRef 做过什么"骚操作"?欢迎在评论区分享,我们一起交流! 💬
🔔 下期预告 :
useMemo和useCallback到底什么时候该用?90% 的人都用错了!关注我,不迷路。