问题场景
很多人对 useRef 的认知停留在"拿 DOM 元素":
tsx
const btnRef = useRef<HTMLButtonElement>(null!)
return <button ref={btnRef}>点击</button>
但实际项目里,真正让我们头皮发麻的不是拿不到 DOM,而是:
❝ useEffect 里闭包捕获了旧值,怎么都拿不到最新 state ❞ ❝ 我想知道组件到底重渲染了多少次 ❞ ❝ 定时器清理时 ref 已经 null 了,清不掉 ❞ ❝ 不想用 useState 但又想记住一个值,还不想触发重渲染 ❞
这些问题,useRef 就是答案。
1. 🎯 解决 useEffect 闭包陷阱
场景:倒计时组件,用户点击暂停,定时器里拿到的 count 永远是旧值。
tsx
function Countdown() {
const [count, setCount] = useState(60)
useEffect(() => {
const timer = setInterval(() => {
// ❌ count 被闭包捕获,永远是最初的 60
setCount(count - 1) // 永远是 60-1=59
}, 1000)
return () => clearInterval(timer)
}, []) // 加依赖会频繁重建定时器
}
✅ useRef 解法:
tsx
function Countdown() {
const [count, setCount] = useState(60)
const countRef = useRef(count)
// 同步 ref
useEffect(() => { countRef.current = count }, [count])
useEffect(() => {
const timer = setInterval(() => {
// ✅ ref 永远指向最新值,不参与闭包捕获
if (countRef.current > 0) {
setCount(c => c - 1) // 也可以用函数式更新
}
}, 1000)
return () => clearInterval(timer)
}, [])
}
原理 :ref.current 是一个可变对象,修改它不会引发重渲染,且读取时总是最新值。
2. 📊 追踪组件渲染次数(调试神器)
tsx
function ExpensiveList({ items }: { items: Item[] }) {
const renderCount = useRef(0)
renderCount.current++
useEffect(() => {
console.log(`🔁 已渲染 ${renderCount.current} 次`)
})
return <div>{/* ... */}</div>
}
开发时挂上这个,一眼看出哪些组件因 props/state 变化在无意义重渲染。配合 React DevTools Profiler 更香。
升级版------追踪哪个 prop 变化触发了重渲染:
tsx
function useWhyDidYouUpdate(name: string, props: Record<string, any>) {
const prev = useRef(props)
useEffect(() => {
const changes: Record<string, { from: any; to: any }> = {}
Object.keys({ ...prev.current, ...props }).forEach(key => {
if (prev.current[key] !== props[key]) {
changes[key] = { from: prev.current[key], to: props[key] }
}
})
if (Object.keys(changes).length) {
console.log(`[${name}] 变动的 props:`, changes)
}
prev.current = props
})
}
3. ⏰ 定时器/事件的稳健清理
用 ref 保存定时器/事件句柄,避免 cleanup 时拿不到正确引用:
tsx
function PollingComponent() {
const timerRef = useRef<number | null>(null)
// ✅ 任何时候清理都安全
const stopPolling = useCallback(() => {
if (timerRef.current !== null) {
clearInterval(timerRef.current)
timerRef.current = null
}
}, [])
const startPolling = useCallback(() => {
stopPolling() // 防止重复
timerRef.current = window.setInterval(() => {
console.log('polling...')
}, 1000)
}, [stopPolling])
useEffect(() => {
return stopPolling // 组件卸载时自动清理
}, [stopPolling])
return <button onClick={startPolling}>开始轮询</button>
}
React 18 StrictMode 下组件会 mount-unmount-mount,不用 ref 管理句柄,两次 mount 的定时器会打架。
4. 📋 追踪上一次的 props/state
不用额外 state,轻松拿到"上一次的值":
tsx
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>()
useEffect(() => {
ref.current = value
})
return ref.current // 返回的是 useEffect 执行前的值
}
// 使用
function ScrollNotifier({ y }: { y: number }) {
const prevY = usePrevious(y)
useEffect(() => {
if (prevY !== undefined && y > prevY) {
console.log('⬇️ 向下滚动')
} else if (prevY !== undefined && y < prevY) {
console.log('⬆️ 向上滚动')
}
}, [y, prevY])
return <div>滚动距离: {y}px</div>
}
为什么能拿到上一次值?
useRef 不参与渲染周期。第一次渲染时 ref 是 undefined;执行 useEffect 时 ref 被更新为 y。第二次渲染时,useEffect 还没跑,所以 ref 保存的仍然是上一次渲染周期的值。
5. 🔗 避免不必要的 useEffect
场景:表单编辑,只在"保存"时获取最新值,不需要每次输入都触发 state → 渲染 → 副作用。
tsx
function EditForm({ initial }: { initial: { name: string } }) {
const nameRef = useRef(initial.name)
// ✅ 输入时不触发重渲染
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
nameRef.current = e.target.value
}
const handleSave = () => {
alert(`保存: ${nameRef.current}`)
}
return (
<div>
<input defaultValue={initial.name} onChange={handleChange} />
<button onClick={handleSave}>保存</button>
</div>
)
}
⚠️ 适用判断 :如果值需要在提交/特定时机使用,且不需要响应式渲染,就用 ref 代替 state。能避免大量不必要的渲染。
要点总结
| 场景 | 用 ref 还是 state |
|---|---|
| 渲染到页面显示 🤯 | ✅ state |
| 不渲染但需要记住值 📝 | ✅ ref |
| 闭包里读最新值 🎯 | ✅ ref |
| 做调试计数/日志 🔍 | ✅ ref |
| 管理定时器/事件句柄 ⏰ | ✅ ref |
| 表单输入即时反馈 ✍️ | ✅ state |
一句话总结 :如果值的改变不需要 UI 刷新,就用 useRef 而不是 useState。它就是一个不会触发渲染的"记忆盒子",用好了能省掉一堆依赖数组的心智负担。