前言
上一篇我们把 useRef 定义成"持久化可变对象",最后留了个钩子:修改 ref 不触发渲染 。这句话是理解 React 数据模型的分水岭,也是面试常考的对比题。这一篇把 useRef 和 useState 放在一起逐项对比,回答三个问题:相同点是什么、不同点是什么、以及------如果我想渲染 ref 里的值,该怎么办? 最后展开 useRef 真正的主战场:那些"不需要渲染但需要共享/清理"的数据。
一、表面差异:返回值与修改方式
先看写法,一眼就能分出区别:
jsx
// useState ------ 响应式
const [num, setNum] = useState(0)
num = 1 // ❌ 不能直接改,必须走 setNum
setNum(1) // ✅ 改完 → 组件自动重新渲染
// useRef ------ 非响应式
const numRef = useRef(0)
numRef.current = 1 // ✅ 直接改 → 不重新渲染
两个最直观的差异:
| useState | useRef | |
|---|---|---|
| 返回值 | [值, 修改函数] 数组 |
{ current: 值 } 对象 |
| 修改方式 | 必须 setNum(n+1) |
直接 numRef.current += 1 |
useState 把"读"和"写"分成了两个东西,强制你通过 set 函数去改------这是它实现响应式的机制。useRef 则把读写都集中在一个 current 属性上,自由得近乎裸奔。
二、核心差异:是否触发渲染
这是两者唯一的本质分界线:
| useState | useRef | |
|---|---|---|
| 修改后是否重新渲染 | ✅ 会 ,所以叫响应式 | ❌ 不会 ,所以叫非响应式 |
| 设计定位 | 给渲染用的数据 | 给"不参与渲染"的数据用 |
一个最直观的实验:
jsx
function App() {
const [count, setCount] = useState(0)
const numRef = useRef(0)
return (
<>
<div>{count}</div> {/* setCount 后,这里会变 */}
<div>{numRef.current}</div> {/* current++ 后,这里纹丝不动 */}
</>
)
}
为什么会这样?因为 React 的渲染流程是:状态变了 → 重新渲染 → 重新执行组件函数体 。setState 会通知 React"该重画了",而 ref.current 的修改是偷偷的,React 根本不知道,自然不会重画。
所以选型口诀特别简单:
值要显示在页面上、变了页面要跟着变 → useState;值只是存着用、变了页面不用动 → useRef。
三、更新时机:异步 vs 同步
同样是修改数据,拿到新值的时机完全不同:
jsx
function App() {
const [num, setNum] = useState(0)
const numRef = useRef(0)
const click = () => {
setNum(num + 1)
console.log(num) // 0 !setState 是异步的,这里还是旧值
numRef.current += 1
console.log(numRef.current) // 1 !ref 同步生效
}
}
- useState 是异步批处理 :React 会把同一事件里的多次
setState攒起来一起处理,所以setNum之后立刻读,拿到的还是旧值 - useRef 是同步的 :
current改完立刻就是新值
这条差异在"点击立刻取最新值"的场景里很要命,也是后面"最新值"用途的由来。
四、相同点
对比完了差异,别忘了它们也是同族:
| 相同点 | 说明 |
|---|---|
| 都是 React Hook | 都要从 react 导入,都要遵守 Hook 规则(顶层调用,不能放 if/循环里) |
| 都能"记住"数据 | 组件反复重渲染,数据都还在(都有持久化能力) |
| 都能被修改 | 不是只读的 |
| 都有初始值 | useState(0) / useRef(0) |
| 用途有重叠 | 存数字、存对象......很多场景两者都能用 |
尤其"都能记住数据"这点别忽略------它俩都靠"闭包 + React 组件实例"实现跨渲染保留,区别只在"改完要不要通知渲染"。
五、要渲染 ref 的值怎么办:三个方案
useRef 本身没有触发渲染的能力,所以"渲染它"必须借助别的机制。从最简单到最正规:
方案一:直接改用 useState(最简单,推荐)
jsx
const [num, setNum] = useState(0)
<div onClick={() => setNum(n => n + 1)}>{num}</div>
这是最正确的做法。"要渲染的值"就该归 useState 管,一行代码搞定。
方案二:useRef + 强制刷新(ref 存数据,useState 借刷新)
jsx
const numRef = useRef(0)
const [, forceRender] = useState(0) // 不关心值,只借它的"刷新"能力
<div onClick={() => {
numRef.current += 1
forceRender(n => n + 1) // 手动通知 React:"请重新渲染"
}}>{numRef.current}</div>
思路是两件事分开做 :useRef 存数据,useState 触发渲染。forceRender 那个状态没人读,只是每次 +1 让组件重跑一遍,重跑时 numRef.current 自然就渲染出新值。代价是:每次改 ref 都得记得补一个 forceRender,漏了页面就不动------这就是"非响应式"要手动拉的代价。
方案三:封装成自定义 Hook(把方案二包装好)
jsx
function useReactiveRef(init) {
const ref = useRef(init)
const [, forceRender] = useState(0)
const set = (newVal) => {
ref.current = newVal
forceRender(n => n + 1)
}
return [ref, set]
}
// 用起来像 useState:
const [numRef, setNum] = useReactiveRef(0)
<div onClick={() => setNum(numRef.current + 1)}>{numRef.current}</div>
什么时候值得"ref + forceRender"而不用纯 useState? 一个真实场景:既要渲染,又要给异步逻辑拿最新值。比如定时器每秒累加:
jsx
const numRef = useRef(0)
const [, forceRender] = useState(0)
setInterval(() => {
numRef.current += 1 // 后台逻辑放心改,闭包里永远拿最新值
forceRender(n => n + 1) // 页面跟着显示
}, 1000)
纯 useState 的话,定时器闭包容易读到过期值(见下节闭包陷阱),用 ref 反而稳。
六、useRef 的真正主战场:四个经典用途
操作 DOM 只是 useRef 的入门用途,它本质是"持久化可变盒子",实战里这些场景更常见。
用途 1:存定时器 ID,方便清理(最常用)
jsx
function Timer() {
const timerRef = useRef(null)
const start = () => {
timerRef.current = setInterval(() => console.log('tick'), 1000)
}
const stop = () => {
clearInterval(timerRef.current) // 用 ref 里存的那个 ID 清除
}
// 组件卸载时兜底清理
useEffect(() => () => clearInterval(timerRef.current), [])
}
为什么不用普通变量?因为定时器 ID 不需要触发渲染,但要在多个函数、多次渲染之间共享同一个 ID------"不需要渲染 + 需要持久化"正是 useRef 的射程。
用途 2:保存"上一次的值",做对比
jsx
function App() {
const [count, setCount] = useState(0)
const prevCountRef = useRef(0)
useEffect(() => {
prevCountRef.current = count // 每次更新后记录
})
return <p>现在是 {count},之前是 {prevCountRef.current}</p>
}
这是 React 官方文档给出的经典做法:ref 充当"上一次值的存档点"。
用途 3:存"最新值",解决闭包过期问题(面试高频)
经典的闭包陷阱:useEffect 里注册的定时器/监听器,闭包会"卡在"第一次渲染时的旧值。
jsx
function App() {
const [count, setCount] = useState(0)
useEffect(() => {
const id = setInterval(() => {
console.log(count) // ❌ 永远打印 0!闭包抓住的是第一次渲染的 count
}, 1000)
return () => clearInterval(id)
}, [])
}
解决方案之一是用 ref 转发最新值:
jsx
function useLatest(value) {
const ref = useRef(value)
ref.current = value // 每次渲染都更新到最新
return ref
}
function App() {
const [count, setCount] = useState(0)
const latestCount = useLatest(count) // latestCount.current 永远是最新值
useEffect(() => {
const id = setInterval(() => {
console.log(latestCount.current) // ✅ 拿到的不是"旧"count,是最新的
}, 1000)
return () => clearInterval(id)
}, [])
}
原理就是第三节讲的"ref 是同步的":每次渲染更新 ref.current,异步回调里读它,永远是最新。
用途 4:存"创建一次、不能重建"的重对象实例
jsx
function App() {
const workerRef = useRef(null)
useEffect(() => {
// 开启一个 worker 线程,开销较大,只能创建一次
const worker = new Worker(new URL('./worker.js', import.meta.url))
workerRef.current = worker
return () => worker.terminate() // 卸载时关掉线程,防止泄漏
}, [])
const handleClick = () => {
workerRef.current.postMessage('compute') // 交给子线程干活
}
}
Web Worker 是最典型的例子:浏览器独立开辟的内存,处理耗时计算,不阻塞主线程(event loop),完成后通过消息机制(postMessage / onmessage)告知主线程 。这类对象很重,组件每次重渲染都 new 一个会资源爆炸,所以:
- 在
useEffect里创建一次(创建是耗时的,挂载后再做,给渲染让步) - 用
useRef存住实例,之后随便取用 - 卸载时
terminate()清理,防止线程泄漏
WebSocket、Audio 实例、第三方库的复杂对象,套路完全一样:创建一次 + useRef 存住 + 卸载清理。
小结
- useRef 和 useState 都是 React Hook,都能跨渲染记住数据;唯一的本质区别是改完是否触发渲染------响应式 vs 非响应式
- useState 异步批处理、useRef 同步生效;useState 只能走 set 函数、useRef 直接改 current
- 要渲染 ref 的值,最省事是直接用 useState;非要 ref,用
forceRender或封装成自定义 Hook - useRef 的实战主场:定时器 ID、上一次的值、最新值(破闭包陷阱)、重对象实例(Web Worker)------共同点是"不需要渲染,但需要持久化/共享/清理"
- 一个雷区:不要在渲染期间(组件函数体里)读写 ref ,会造成渲染结果不确定;只在事件回调、
useEffect里碰它
一句话:useState 是"改了要重画"的状态,useRef 是"改了不用重画"的数据------选谁,先问自己这个值要不要上界面。