一篇文章搞懂 useRef:聚焦 DOM、模拟 forceRender、管理 Worker 都靠它
React 里最被低估的 Hook,除了绑定 DOM 还能干这么多事。
前言
很多 React 新手对 useRef 的印象停留在"拿来聚焦输入框",然后就没了。其实 useRef 的能力远不止于此------它能存可变值不触发渲染、能模拟 forceRender、还能管理 Web Worker 线程。
这篇文章带你从三个真实场景出发,彻底搞懂 useRef 的底层逻辑。文末附有完整可运行项目代码,克隆即可上手。
适合读者 :会用 useState 但还没真正理解 useRef 的 React 初学者。
项目概览
ref-demo 包含两个子项目,覆盖了 useRef 的三种典型用法:
csharp
ref-demo/
├── readme.md ← 知识点笔记(DOM 编程、响应式、Web Worker)
├── ref-focus-demo/ ← useRef 操作 DOM + forceRender 模式
│ └── src/App.jsx
└── ref-worker-demo/ ← useRef 持有 Web Worker 实例
├── src/App.jsx
└── src/worker.js
| 子项目 | 核心知识点 |
|---|---|
| ref-focus-demo | useRef 绑定 DOM、useRef vs useState(响应式 vs 非响应式)、forceRender 模式 |
| ref-worker-demo | useRef 持有 Web Worker、JS 单线程 / event loop、主线程与 Worker 通信 |
依赖版本:React 19.2.6、Vite 8.0.12
核心知识点一:useRef 是什么?为什么有了 useState 还要它?
一句话理解
useRef 返回一个可变的"容器对象" ,这个对象有一个 current 属性,你可以往里面放任何值,修改它不会触发组件重新渲染。
js
const ref = useRef(null)
// ref 就是一个普通对象:{ current: null }
ref.current = 'hello'
// ref.current 现在是 'hello',但组件不会重新渲染
类比
| 概念 | 类比 |
|---|---|
useState |
冰箱上的便利贴------内容变了,全家人都能看到(触发渲染) |
useRef |
你口袋里的记事本------记了东西,只有你知道,别人不知道(不触发渲染) |
useState vs useRef 对比
jsx
// useState --- 响应式:改值 → 重渲染
const [count, setCount] = useState(0)
setCount(1) // ✅ 页面自动更新
// useRef --- 非响应式:改值 → 不渲染
const countRef = useRef(0)
countRef.current = 1 // ❌ 页面不会更新
React 的设计哲学 :90% 的场景用响应式
useState驱动 UI,10% 的边缘场景用useRef。两者配合,不是二选一。
核心知识点二:useRef 操作 DOM --- autoFocus 替代方案
场景
输入框一出现就自动聚焦,不用鼠标点。HTML 有 autoFocus 属性,但 useRef + useEffect 更灵活------你可以在任何时机触发聚焦。
代码
jsx
import { useRef, useEffect } from 'react'
const App = () => {
const inputRef = useRef(null) // ① 创建 ref 容器
useEffect(() => {
inputRef.current.focus() // ③ 挂载后,ref.current 指向真实 DOM,手动聚焦
}, [])
return (
<input
ref={inputRef} // ② 绑定到 JSX 元素
type="text"
placeholder="请输入用户名"
/>
)
}
这段代码的执行时序
csharp
组件函数执行 → 返回 JSX → React 创建 DOM 节点 → 挂载到页面
↑ ↓
如果在这里 ref.current.focus() ref.current 这时候才被赋值
→ ref.current 还是 null,报错! ✅
| 步骤 | 发生了什么 | ref.current 的值 |
|---|---|---|
| 组件函数执行 | 调用 useRef(null),创建容器 |
null |
| JSX 返回 | 声明了 <input ref={inputRef}> |
还是 null(DOM 还没创建) |
| React commit | 真实 <input> 挂载到页面 |
React 把 DOM 节点赋值给 ref.current |
useEffect 回调 |
此时 DOM 已存在,安全调用 .focus() |
指向 HTMLInputElement |
底层逻辑
为什么必须用 useEffect?因为组件函数执行时 DOM 还不存在------JSX 只是"装修图纸",React 需要先按图纸造房子(创建 DOM),房子造好之后才会执行 useEffect。
autoFocus不用useEffect是因为它是浏览器原生属性,浏览器自己会在元素创建后处理------不依赖 JS 调用时序。
注释代码里还有另一种方案
项目中被注释掉的代码展示了 useRef + useState 结合的版本:
jsx
// 注释版本:ref 绑定 input + useState 驱动计数
const [count, setCount] = useState(0)
const inputRef = useRef(null)
// inputRef 只管 DOM 聚焦,count 管业务值,各司其职
这是一种"关注点分离"的模式------state 管 UI 数据,ref 管 DOM 操作,互不干扰。
核心知识点三:forceRender 模式 --- 把 useState 当"手动刷新键"
什么场景需要?
你有一些数据不需要触发渲染,但又希望在某些时机手动刷新页面。比如高频更新的计数器、动画帧、实时数据流------如果用 useState 每次更新都重渲染,性能就崩了。
代码(ref-focus-demo 当前版本)
jsx
const App = () => {
const numRef = useRef(0) // ① 静默记账本
const [, forceRender] = useState(0) // ② 只拿 setter,state 值本身不用
return (
<div onClick={() => {
numRef.current += 1 // ③ 改值但不渲染
forceRender(numRef.current) // ④ 手动触发一次渲染
}}>
{numRef.current} // ⑤ 渲染时从 ref 里读最新值
</div>
)
}
逐行拆解
| 代码 | 作用 |
|---|---|
useRef(0) |
创建非响应式容器,存数字 |
const [, forceRender] = useState(0) |
解构时用逗号跳过 state 值,只拿 setter。这个 setter 的意思变了------不再是"更新 state 值",而是**"通知 React 重新渲染"** |
numRef.current += 1 |
静悄悄改值,页面不动 |
forceRender(numRef.current) |
调用 setter → React 重渲染 → 组件重新执行 → numRef.current 读到最新值 → 页面更新 |
为什么不直接用 useState?
jsx
// useState 版本:每次点击触发渲染
const [num, setNum] = useState(0)
setNum(num + 1) // 改值 + 触发渲染,两步合一
// useRef + forceRender 版本:改值和渲染解耦
numRef.current += 1 // 只改值
forceRender(...) // 只触发渲染
普通场景下 useState 就够。但当你需要在不渲染的情况下多次改值,然后攒一波统一刷新时,forceRender 模式就派上用场了。
更实用的场景
jsx
// 高频计数器:每秒更新 10 次值,但每 5 次才渲染一次
const countRef = useRef(0)
const [, forceRender] = useState(0)
useEffect(() => {
const timer = setInterval(() => {
countRef.current += 1
if (countRef.current % 5 === 0) {
forceRender(countRef.current) // 省掉 4/5 的渲染开销
}
}, 100)
return () => clearInterval(timer)
}, [])
核心知识点四:useRef 持有 Worker 实例
JS 为什么需要 Worker?
JS 是单线程 的------所有代码跑在一条线上。页面上既要响应用户点击、滚动,又要执行计算任务,如果有耗时操作(大循环、AI 推理、游戏逻辑),页面就会卡死。
Web Worker 是浏览器提供的方案:另开一条线程,把耗时任务丢给它,完成后通过消息通知主线程。
代码(ref-worker-demo)
主线程 App.jsx:
jsx
const App = () => {
const workerRef = useRef(null) // ① 持久化持有 Worker 引用
useEffect(() => {
workerRef.current = new Worker(
new URL('./worker.js', import.meta.url) // ② 创建 Worker 实例
)
}, [])
return <></>
}
Worker 线程 worker.js:
js
console.log('work online')
为什么要用 useRef 存 Worker?
| 用 useState | 用 useRef |
|---|---|
| Worker 实例触发渲染 ← 没意义,没必要 | Worker 实例只存不渲染 ← 合理 |
| 每次 setState 通知 React 重绘 ← 浪费 | 不触发渲染,干干净净 |
Worker 是一个"后台干活的东西",跟 UI 没关系 ,所以天然适合用 useRef 来持有。
注释代码里的知识点
项目中注释掉了一段阻塞主线程的代码:
js
// console.time('主线程耗时')
// for (let i = 0; i < 1000000; i++) {
// console.log(i)
// }
// console.timeEnd('主线程耗时')
这段循环如果执行,会占满主线程,页面上的按钮、滚动、输入框全部无响应------直到循环结束。而 Worker 线程里的计算不会影响主线程,这就是 Worker 的价值。
总结
回顾这篇文章的核心收获:
useRef返回一个{ current }容器 ,改current不触发渲染------这是它和useState的本质区别- 操作 DOM 用
useRef+useEffect:ref拿到 DOM 节点,useEffect保证在 DOM 挂载后执行 - 响应式 vs 非响应式不是对立,是互补 :90% 用
useState驱动 UI,10% 用useRef处理边缘场景 - forceRender 模式 :
useStatesetter 的本质是"通知 React 重新渲染",可以和useRef配合实现"改值和渲染解耦" useRef存 Worker 实例:跟 UI 无关的东西,放 ref 里最合适
下一步推荐学习:useRef 配合 forwardRef 实现父子组件间的 DOM 透传,以及 useImperativeHandle 自定义暴露给父组件的实例方法。
你平时用 useRef 最多干什么?评论区聊聊 👋
完整项目代码
项目文件树
csharp
ref-demo/
├── readme.md # 知识点笔记
├── ref-focus-demo/
│ ├── package.json # React 19.2.6 + Vite 8.0.12
│ ├── src/
│ │ ├── main.jsx # 入口,StrictMode
│ │ ├── App.jsx # useRef DOM 聚焦 + forceRender
│ │ ├── App.css
│ │ └── index.css
│ └── vite.config.js
└── ref-worker-demo/
├── package.json # React 19.2.6 + Vite 8.0.12
├── src/
│ ├── main.jsx # 入口,StrictMode
│ ├── App.jsx # useRef 持有 Worker 实例
│ ├── worker.js # Worker 线程代码
│ ├── App.css
│ └── index.css
└── vite.config.js
快速启动
bash
# 克隆仓库
git clone git@gitee.com:dcx2758/ai_doubao_dcx.git
cd ai_doubao_dcx/dcx/fe/react/basic/ref-demo
# 启动 ref-focus-demo(useRef 聚焦 DOM + forceRender)
cd ref-focus-demo
npm install
npm run dev
# 启动 ref-worker-demo(useRef 持有 Worker)
cd ../ref-worker-demo
npm install
npm run dev