🔥 别再只用 useRef 拿 DOM 了!这 5 个实战场景让你彻底理解它(附源码解析 + 面试题)

🔥 别再只用 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 用的",直到我在项目中遇到了三个"意外":

  1. useRef 存一个数字,修改它居然不会触发重新渲染
  2. useRef 存一个 Web Worker 实例,它居然能在渲染间持久存活
  3. useEffect 的回调里,用 useRef 成功突破了闭包陷阱

这三个场景串起来,我才真正理解了 useRef 的设计哲学:它不是 DOM 工具,它是 React 的"渲染之外的抽屉"


🎯 本文适合谁

  • 学过 useStateuseEffect,但对 useRef 一知半解的 React 初学者
  • 知道 useRef 能拿 DOM,但不知道它还能干嘛的同学
  • 面试前想搞清楚 useRef vs useState 区别的人
  • 想了解 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 的调度机制来看:

  • useStatesetCount() → 调用 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,但这样做有两个问题:

  1. 破坏封装:父组件可以调用 DOM 的任何方法,子组件失去对自身 API 的控制
  2. 不利于重构:子组件内部实现变了(比如 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 架构中是怎么存储的?

AuseRef 创建的 { current: initialValue } 对象会被挂载到 Fiber 节点的 memoizedState 链表上。首次渲染时创建对象,后续渲染时 React 复用同一个引用。修改 .current 不会触发调度器的 scheduleUpdate,因为它不经过 dispatchAction 流程。

Q5:useImperativeHandle 的作用是什么?为什么需要它?

AuseImperativeHandle 配合 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 做过什么"骚操作"?欢迎在评论区分享,我们一起交流! 💬

🔔 下期预告useMemouseCallback 到底什么时候该用?90% 的人都用错了!关注我,不迷路。

相关推荐
玉宇夕落1 小时前
useRef + Web Worker —— 从零到一理解React多线程协作原理
前端
用户6919026813391 小时前
React useRef、useEffect、useState 与 Web Worker 多线程实践
前端
BreezeJiang1 小时前
Web Worker 不负责渲染:React 中的线程分工与消息闭环
javascript·react.js
烬羽1 小时前
useContext 用是用了,但你真的用对了吗?——把 Context 封装进自定义 Hook
前端·react.js·全栈
做前端的娜娜子1 小时前
JavaScript 闭包
前端·javascript·掘金·金石计划
xiaominlaopodaren1 小时前
three.js地图数学基础(一)
前端·three.js
何时梦醒1 小时前
🎯 从零彻底搞懂 React Context API —— 一篇带你穿越"组件树"的状态共享方案
前端·javascript·react.js
渣波1 小时前
拒绝页面假死!React 并发编程实战:Web Worker + useRef 深度解析与高性能计算架构
前端·javascript
用户2930750976691 小时前
Web Worker:让 JavaScript 拥有"多线程"能力
前端