useRef,到底在 ref 什么?

0 阅读6分钟

你一定写过这种代码:一个 setInterval 跑起来之后,想在另一个函数里 clearInterval——然后发现,那个 interval ID 找不到了。

放到 state 里?每次更新都会触发重新渲染,但你根本不需要 UI 变化。放到普通变量里?下一次渲染它就被重置成 null 了。

这正是 ref 要解决的问题:跨 render 保持稳定、但不驱动 UI 重绘的值。

一、先搞清楚一个问题:ref 不是"绕开 React"

很多人第一次接触 useRef,会把它理解成"React 管不到的后门"。但 React 官方文档的措辞是 escape hatch(逃脱舱口) ——这个词不是贬义,而是精确的工程术语。

宇宙飞船需要逃脱舱口,不是因为飞船设计有缺陷,而是因为有些场景天然不在主系统管辖范围内。

ref 也一样。它存在的意义不是"绕开 React 的数据流",而是为那些不属于 UI 描述的值提供一个安全的存放位置

React 官方 ref 插图:ref 就像组件的一个

React 官方 ref 插图:ref 就像组件的一个"秘密口袋"

这张 React 官方插图特别形象——ref 就是组件身上的一个"秘密口袋",React 不会去翻它,但你需要的时候随时能掏出来用。

二、ref vs state:一张表说清楚

维度useRefuseState
返回值{ current: value }[value, setter]
修改后触发渲染❌ 不触发✅ 触发
可变性直接赋值 ref.current = x必须走 setter
更新时机同步,立即生效异步快照,下次渲染才生效
渲染期间能否读写❌ 不应该✅ 随时读取

这里有一个很实用的判断标准

这个值变化后,UI 应不应该更新?

  • 应该 → 用 state
  • 不应该,但需要跨 render 保留 → 用 ref
  • 不应该,也不需要跨 render → 用普通变量

state 是给用户看的,ref 是给你自己用的。

三、三个最常用的 ref 场景

场景存什么为什么不用 state
定时器 IDsetTimeout / setInterval 返回值只需要 clearInterval,UI 不关心这个数字
DOM 引用<input ref={inputRef}>用于 focus、scroll、测量尺寸等命令式操作
第三方实例图表对象、地图实例、WebSocket 连接实例的内部状态由第三方库管理,React 不需要追踪

看一个经典示例——秒表:

import { useState, useRef } from 'react';

export default function Stopwatch() {
  const [startTime, setStartTime] = useState(null);  // 用于渲染 → state
  const [now, setNow] = useState(null);               // 用于渲染 → state
  const intervalRef = useRef(null);                    // 不用于渲染 → ref

  function handleStart() {
    setStartTime(Date.now());
    setNow(Date.now());
    clearInterval(intervalRef.current);
    intervalRef.current = setInterval(() => {
      setNow(Date.now());
    }, 10);
  }

  function handleStop() {
    clearInterval(intervalRef.current);
  }

  let secondsPassed = 0;
  if (startTime != null && now != null) {
    secondsPassed = (now - startTime) / 1000;
  }

  return (
    <>
      <h1>Time passed: {secondsPassed.toFixed(3)}</h1>
      <button onClick={handleStart}>Start</button>
      <button onClick={handleStop}>Stop</button>
    </>
  );
}

注意 intervalRef 的用法:它在 handleStart 里被赋值,在 handleStop 里被读取——全程不参与渲染逻辑。如果你把 interval ID 放进 state,每次赋值都会触发一次无意义的重新渲染。

不需要被"看见"的值,就别让 React 去"追踪"它。

四、一个血泪教训:为什么普通变量不行?

很多人会想:既然 ref 就是存一个不触发渲染的值,我用普通 let 变量不行吗?

// ❌ 这样写,Undo 永远无法取消发送
function Chat() {
  let timeoutID = null;  // 每次渲染都会被重置为 null

  function handleSend() {
    timeoutID = setTimeout(() => alert('Sent!'), 3000);
  }

  function handleUndo() {
    clearTimeout(timeoutID);  // timeoutID 已经是 null 了!
  }
  // ...
}

问题出在哪?函数组件每次渲染都是一次新的函数调用。  let timeoutID = null 在每次调用时都会执行一遍,上一次保存的值直接丢了。

这就像认知科学里的工作记忆 vs 长期记忆——普通变量是草稿纸,写完这一轮就扔了;state 是你大声说出来的话,所有人都能听到(触发渲染);ref 则是你默默记在心里的事,不说出口,但下次需要时还能想起来。

ref 的"记住"不是为了展示,而是为了随时可用。

正确的写法:

// ✅ 用 ref 保存 timeout ID
function Chat() {
  const timeoutRef = useRef(null);

  function handleSend() {
    timeoutRef.current = setTimeout(() => alert('Sent!'), 3000);
  }

  function handleUndo() {
    clearTimeout(timeoutRef.current);  // 跨渲染稳定,永远拿到最新值
  }
  // ...
}

五、渲染期间读写 ref——React 的红线

这是 useRef 最容易犯的错,也是最难调试的 bug:

// ❌ 在渲染期间修改 ref
function Bad({ value }) {
  const ref = useRef();
  ref.current = expensiveDerived(value);  // 破坏渲染纯度
  return <div>{ref.current}</div>;
}

React 的渲染模型建立在一个核心假设上:render 是纯函数。  给定相同的 props 和 state,应该返回相同的 JSX。

而 ref.current 的修改是 React 完全感知不到的——你在渲染期间偷偷改了一个 React 不追踪的值,就像在数学考试里偷偷改了公式里的常数,答案就全乱了。

尤其在 React 18 引入并发渲染后,一次渲染可能被中断、重启、甚至执行多次。如果你在渲染中写 ref,这些中间值可能互相覆盖。

唯一的例外是惰性初始化:

// ✅ 这种写法是安全的——只执行一次
if (!ref.current) {
  ref.current = new ExpensiveThing();
}

因为它本质上是一次性赋值,不会在后续渲染中改变行为。

六、ref vs 单例——共享边界想清楚了吗?

用户提供了一个很有意思的视角:ref 和单例模式很像但又不一样。

维度单例模式useRef
生命周期应用级,整个 app 共享一份组件实例级,每个组件实例独立
共享范围所有调用者看到同一个实例仅当前组件实例可见
本质全局共享资源实例级稳定引用
类比公司唯一的打印机你工位抽屉里的笔

这个对比的价值在于:它帮你想清楚 "共享边界"

如果一个值需要全局共享(比如 WebSocket 连接、全局配置),单例更合适。如果一个值只属于当前组件实例(比如这个输入框的 DOM 引用、这个秒表的 interval ID),ref 才是正确选择。

如果你发现自己在用 Context 传递一个 ref 给多个子组件去修改——停一下,你可能在用 ref 冒充全局状态管理器了。

ref 的"稳定"不是"全局共享",而是"个体持久"。

七、useRef 的底层真相

React 官方文档透露了一个有趣的实现细节——useRef 在概念上可以用 useState 实现:

// useRef 的概念性实现
function useRef(initialValue) {
  const [ref, unused] = useState({ current: initialValue });
  return ref;
}

关键在于:返回的始终是同一个对象引用useState 会在每次渲染返回相同的状态值,而 useRef 利用了这一点——它返回一个 { current } 对象,这个对象在组件的整个生命周期内引用不变。

所以 ref 不是什么魔法,它就是一个被 React 帮你保管的、引用稳定的普通 JavaScript 对象。你改的是对象的属性(.current),不是对象本身,所以 React 无感,也就不会重新渲染。

如果你写过类组件,这和 this.xxx 是一模一样的概念——只是换了个皮。

八、实战判断清单

最后给一份速查表,下次纠结"用 state 还是 ref"的时候,过一遍这个流程:

问题答案选择
这个值变化后,UI 需要更新吗?useState
这个值需要跨渲染保留吗?普通变量
这个值只在事件处理器里用?useRef
这个值是 DOM 节点?useRef + JSX ref 属性
这个值是第三方库实例?useRef
这个值需要全局共享?Context / 状态管理库,不是 ref

如果你只想带走一句话,我建议记这个:

state 是 React 的台账,ref 是你自己的口袋——需要 React 帮你"广播"给 UI 的值放台账,只需要自己悄悄留着用的值放口袋。

参考原文:

• React 官方文档 — Referencing Values with Refs

qrcode_for_gh_6a9e7f3719d6_344.jpg