React 的 useRef vs useState:响应式与非响应式的分界线

前言

上一篇我们把 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 是"改了不用重画"的数据------选谁,先问自己这个值要不要上界面。

相关推荐
我真是泰库辣1 小时前
用TraeWork制作应用 —— 从 0 到 1 · 手把手搭建 opencode 网页对话网关
前端·后端
想要成为糕糕手1 小时前
面试必问!React 受控与非受控组件,这篇彻底讲透了
react.js
RobinDevNotes1 小时前
轻量级动画引擎Anime.js迎来V4大版本更新
开发语言·前端·javascript·ecmascript·动画·c4前端
喜欢睡觉1 小时前
React 受控组件与非受控组件
react.js
渣波1 小时前
React 性能优化实战:从 memo 到 Hooks 的底层原理与进阶封装
前端·javascript
触底反弹1 小时前
别再 Prop Drilling 了!一文彻底搞懂 React 组件通信的 5 种方案
前端·javascript·react.js
玉宇夕落1 小时前
受控与非受控组件以及一些表单的简单业务逻辑的理解
前端
两只羊ovo1 小时前
listToTree 速通:一维数组怎么变出多级菜单?Map 和 reduce 两种全解
前端·javascript
何时梦醒1 小时前
React 进阶必修:彻底搞懂受控组件与非受控组件
前端·javascript·react.js