HeyBinyang

useRef 与稳定引用——什么时候它是逃生口,什么时候它是坏味道

·技术·visibility1 阅读 ·React

前面几篇已经把几件事讲清楚了:

  • useEffect 不该管组件内部的纯计算。

  • 闭包问题本质上是渲染快照和函数引用的问题。

  • 很多所谓“状态管理”,其实只是把计算错写成了 state。

走到这一步,通常会遇到一个新的问题:

有些东西我不想放进 state,因为它不该触发重渲染; 但我又确实想让它跨渲染保留下来。 那它到底该放哪?

答案就是:useRef

但也正因为它“不触发重渲染”,所以它非常像一把好用的旁门兵器: 用对了,能解决闭包、订阅、DOM 控制这些很棘手的问题; 用错了,组件的数据流就开始偷偷分叉。

一、先把 useRef 的本质说清楚

React 官方对 useRef 的定义非常直接:

  • 它能在多次渲染之间保存一个值。

  • 修改它不会触发组件重新渲染。

也就是说,useRef 更像一个“跨 render 持久存在的可变盒子”:

const ref = useRef(initialValue);

// ref.current 可读可写
ref.current = nextValue;

它和普通变量的区别在于:

  • 普通变量每次 render 都会重新执行、重新声明。

  • ref 会在组件生命周期里一直保留同一个对象。

它和 state 的区别在于:

  • state 改了,React 会重新渲染。

  • ref 改了,React 不会知道,也不会更新 UI。

所以一句话总结:

useRef 适合存“需要记住,但不影响当前界面”的值。

这句话非常关键,后面所有判断几乎都能从这里推出来。

二、最典型的两个 useRef 用途

1. 访问 DOM

这是大家最熟悉的:

function Input() {
  const inputRef = useRef<HTMLInputElement | null>(null);

  function focusInput() {
    inputRef.current?.focus();
  }

  return (
    <>
      <input ref={inputRef} />
      <button onClick={focusInput}>聚焦</button>
    </>
  );
}

这种场景非常标准,因为你需要一个“指向真实 DOM 节点的句柄”,然后做一些命令式操作,比如:

  • 聚焦。

  • 滚动到某个位置。

  • 读取尺寸。

  • 和第三方 DOM 库对接。

这里 ref 是 React 官方明确提供的 escape hatch: 当声明式 UI 不够表达某些行为时,你可以短暂地下潜到 DOM 层。

2. 保存不触发渲染的持久值

这才是实际项目里更容易被低估的用法。

比如:

  • 上一次的值。

  • 定时器 ID。

  • 当前订阅实例。

  • 某个只给 effect / 回调用的最新参数。

例子:

const timerRef = useRef<number | null>(null);

useEffect(() => {
  timerRef.current = window.setInterval(() => {
    console.log('tick');
  }, 1000);

  return () => {
    if (timerRef.current !== null) {
      clearInterval(timerRef.current);
    }
  };
}, []);

这里如果你把 timerId 放到 state 里,反而是错的,因为:

  • 它不参与 UI 渲染。

  • 它只是一个内部控制句柄。

  • 改它没必要触发界面刷新。

这类“值要保留,但不该驱动 UI”的场景,才是 useRef 真正大规模发挥作用的地方。

三、useRef 为什么能解决 stale closure 问题?

前一篇讲闭包时提过,很多旧值问题的根源是:

  • 定时器 / 监听器 / 异步回调注册在旧 render。

  • 它们拿到的也是旧 render 里的变量。

useRef 之所以常被拿来解这个问题,是因为:

  • effect 或回调可以始终持有同一个 ref 对象;

  • 而这个 ref 的 .current 可以在每次 render 后更新成最新值。

比如:

function SearchBox() {
  const [query, setQuery] = useState('');
  const queryRef = useRef(query);

  useEffect(() => {
    queryRef.current = query;
  }, [query]);

  useEffect(() => {
    const id = setInterval(() => {
      console.log(queryRef.current);
    }, 3000);

    return () => clearInterval(id);
  }, []);

  return (
    <input
      value={query}
      onChange={e => setQuery(e.target.value)}
    />
  );
}

这里定时器只注册一次,但每次读到的都是最新 query。 原因不是 React 帮你“热更新了闭包”,而是闭包拿到的是那个一直没变的 queryRef 对象,而你每次改的是它里面的 current

所以 useRef 在这类场景中的角色是:

不去重建外部订阅,而是让订阅去读一个持续更新的“最新值容器”。

这点很重要,因为它决定了 ref 该在什么时候出场。

四、什么时候应该用 ref,而不是 state?

这是整篇最实用的问题,判断标准可以很简单。

用 state 的情况

如果这个值会影响渲染结果,就该用 state。

比如:

  • 弹窗是否打开。

  • 当前选中项。

  • 加载中状态。

  • 表单输入值。

因为这些一变,界面就该跟着变。

用 ref 的情况

如果这个值只是组件内部记忆,不直接影响界面,就优先考虑 ref。

比如:

  • 上一个 props 值。

  • 计时器 ID。

  • 一个第三方实例。

  • 是否已经提交过。

  • 滚动位置缓存。

  • 某个供异步回调读取的最新值。

一句非常好记的话: > 想让 React 重新画界面,用 state; > 只想让组件自己记住点东西,用 ref。

五、一个最容易写对也最容易写歪的例子:上一个值

很多教程都喜欢用 usePrevioususeRef 示例,因为它确实很典型:

function usePrevious<T>(value: T) {
  const ref = useRef<T | undefined>(undefined);

  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref.current;
}

然后这样用:

const prevCount = usePrevious(count);

这类代码成立,是因为:

  • “上一个值”本身不直接参与当前 UI 的更新机制;

  • 它只是一个辅助信息。

如果你为了拿“上一个值”,去再开一个 state:

const [prevCount, setPrevCount] = useState(count);

useEffect(() => {
  setPrevCount(count);
}, [count]);

那就又绕回了“effect 同步状态”的老问题。

所以这里 ref 很合适,它提供的是“记忆能力”,不是“渲染驱动能力”。

六、什么时候 useRef 是坏味道?

到这里,容易进入另一个极端: 既然 ref 不触发重渲染、还能存最新值,那是不是很多 state 都可以换成 ref?

绝对不是。

下面这些情况,通常都说明你已经在滥用 ref。

1. 用 ref 存本该驱动 UI 的数据

比如:

const openRef = useRef(false);

function handleOpen() {
  openRef.current = true;
}

然后你还想让界面跟着显示弹窗。 这就错了,因为 React 根本不知道 openRef.current 变了,页面不会更新。

只要某个值改变后,你期待界面立刻重新反映出来,那它就不该是 ref,而应该是 state。

2. 在 render 期间读写 ref,偷偷绕开 React 数据流

React 官方专门提醒过:

不要在渲染期间读写 ref.current,除非是初始化这种极少数可预测场景。

比如这种就很危险:

function Component() {
  const countRef = useRef(0);
  countRef.current += 1;

  return <div>{countRef.current}</div>;
}

这段代码的问题在于:

  • render 本该是纯的;

  • 你却在 render 里偷偷改了外部可变值;

  • 这样会让组件行为变得不可预测,尤其在严格模式和未来特性下更危险。

所以 ref 可以变,但不要把它当成“render 阶段的可变全局变量”。

3. 用 ref 回避依赖数组,而不是解决问题

这是团队里很常见的一种“高级坏味道”。

比如本来 effect 正常应该依赖 userId,结果有人为了避免重跑,硬是这样写:

const userIdRef = useRef(userId);

useEffect(() => {
  userIdRef.current = userId;
}, [userId]);

useEffect(() => {
  fetchUser(userIdRef.current);
}, []);

这段代码也许“能跑”,但往往是在偷偷改变语义:

  • 原本这个 effect 应该随 userId 变化重跑。

  • 现在你把它改成了“只执行一次,但读最新值”。

这两者不是一回事。

所以一个很重要的原则是:

ref 应该用来表达“我不想重建这个外部订阅,但我想让它读到最新值”; 而不是用来逃避“这个 effect 本来就该跟依赖变化一起更新”的事实。

七、一个完整重构例子:防抖搜索该怎么写

这个场景很适合把 useRef、闭包和稳定引用串起来。

先看一个常见坏代码:

function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
  const [query, setQuery] = useState('');

  useEffect(() => {
    const id = setTimeout(() => {
      onSearch(query);
    }, 500);

    return () => clearTimeout(id);
  }, [query, onSearch]);

  return (
    <input
      value={query}
      onChange={e => setQuery(e.target.value)}
    />
  );
}

这段其实不算错。 它表达的是:每次 query 变化,就重新启动一个 500ms 定时器。 如果你的意图就是“随 query 重建防抖计时”,它是合理的。

但如果你想把“防抖函数”抽成一个稳定工具,就很容易写出这种有 stale closure 的版本:

function useDebouncedCallback(fn: (...args: any[]) => void, delay: number) {
  const timerRef = useRef<number | null>(null);

  return useCallback((...args: any[]) => {
    if (timerRef.current !== null) {
      clearTimeout(timerRef.current);
    }

    timerRef.current = window.setTimeout(() => {
      fn(...args);
    }, delay);
  }, [delay]);
}

这段的坑在于:

  • callback 被缓存住了;

  • 但里面闭包的 fn 可能已经过期;

  • 如果外部传进来的函数变了,这里不一定拿的是最新版本。

更稳妥的写法是把最新 fn 放到 ref 里:

function useDebouncedCallback<T extends (...args: any[]) => void>(
  fn: T,
  delay: number
) {
  const fnRef = useRef(fn);
  const timerRef = useRef<number | null>(null);

  useEffect(() => {
    fnRef.current = fn;
  }, [fn]);

  return useCallback((...args: Parameters<T>) => {
    if (timerRef.current !== null) {
      clearTimeout(timerRef.current);
    }

    timerRef.current = window.setTimeout(() => {
      fnRef.current(...args);
    }, delay);
  }, [delay]);
}

这里的结构就很清楚:

  • timerRef 保存计时器句柄。

  • fnRef 保存最新回调。

  • 返回的函数只在 delay 变化时更新。

这就是 useRef 非常典型的一种高阶用途: 让工具函数保持稳定,同时又能拿到最新业务逻辑。

八、useRef 和 useCallback 的关系,到底怎么配合?

前一篇已经讲过,useCallback 解决的是函数引用稳定,不是最新值问题。 而 useRef 解决的是跨 render 持久保存一个可变值

所以它们经常搭配出现,但分工不同:

  • useCallback:决定“这个函数对象要不要换”。

  • useRef:决定“函数内部需要读的那个最新值放哪”。

一个典型模式是:

const latestHandlerRef = useRef(onChange);

useEffect(() => {
  latestHandlerRef.current = onChange;
}, [onChange]);

const stableHandler = useCallback((value: string) => {
  latestHandlerRef.current(value);
}, []);

这里:

  • stableHandler 引用是稳定的;

  • 但它执行的逻辑始终是最新 onChange

这类模式在封装 hook、桥接第三方事件系统时特别常见。

不过要强调一句:

如果你只是普通业务组件,先别急着上这种模式。 很多时候老实写依赖数组,代码反而更简单。

ref + callback 组合适合的是“工具化场景”“订阅场景”“不想重建场景”,而不是所有地方都上。

九、这一篇可以直接带走的 7 条规则

  1. useRef 是一个跨渲染持久存在的可变盒子,改它不会触发重渲染。

  2. 它适合保存“不影响 UI、但需要记住”的值,比如 DOM 节点、定时器 ID、第三方实例、最新回调。

  3. 如果某个值变化后应该立刻反映到界面上,那它应该是 state,不是 ref。

  4. stale closure 场景下,ref 常用来让“长寿命回调”读到最新值,而不必频繁重建订阅。

  5. 不要在 render 期间随意读写 ref.current,这会破坏 render 的纯度。

  6. 不要用 ref 去逃避本来就应该存在的 effect 依赖。

  7. useRefuseCallback 经常搭配,但一个解决“值持久化”,一个解决“函数引用稳定”,别混为一谈。

分享