useRef

访问 DOM 和保持可变值的利器 - 跨越渲染周期的"记忆口袋"

核心概述

在 React 函数组件中,状态(props 和 state)是渲染的"快照"——每次渲染都有独立的值, 更新状态会触发重新渲染。但在实际开发中,你需要一种方式来存储不会触发重新渲染的可变值, 或者直接访问 DOM 元素。这就是 useRef 的用武之地。

useRef 返回一个可变的 ref 对象,其 .current 属性可以被赋值和读取。 与 state 不同,修改 ref.current 不会触发重新渲染,这使得 ref 非常适合存储: 定时器 ID、DOM 元素引用、之前的 props/state 值、第三方库实例等需要在渲染之间持久化的数据。

核心特性:ref 对象在组件的整个生命周期内保持稳定—— React 会在每次渲染时返回同一个 ref 对象,这意味着你可以安全地在闭包中存储和读取值, 而不用担心闭包陷阱问题。

适用场景:当需要存储不触发渲染的值、访问 DOM 元素、与第三方库集成时使用 useRef。 但如果你需要值的变化触发 UI 更新,应该使用 useState 而非 ref。

💡 心智模型

将 useRef 想象成一个组件的"记忆盒子":

  • 盒子本身(ref 对象): 在组件生命周期内永远不变,始终是同一个盒子
  • 盒子里的内容(current): 可以随时替换,但替换不会触发任何通知
  • 与 state 的区别: state 是"保险箱"(换内容会报警触发渲染),ref 是"普通盒子"(换内容静默无息)

关键:ref 适用于"存储值但不通知变化"的场景,如计时器 ID、DOM 引用、上次的值等。

技术规格

类型签名

function useRef<T>(initialValue: T): MutableRefObject<T>

// 泛型 T 是 ref.current 的类型
// 返回值类型是 MutableRefObject<T>,包含可变的 current 属性

参数说明

参数类型说明
initialValueTref.current 的初始值。可以是任何类型的值,包括 null、undefined、对象、函数等。

返回值

返回值类型说明
ref 对象MutableRefObject<T>包含 current: T 属性的对象。在组件整个生命周期内保持稳定引用。

运行机制

初始化:React 在组件首次渲染时创建 ref 对象,并将 initialValue 赋值给 ref.current。 这个 ref 对象会被存储在 Fiber 节点的 ref 链表中。

稳定性:与普通变量不同,ref 对象在组件的多次渲染之间保持稳定。 React 会在每次渲染时返回同一个 ref 对象引用,这使得 ref 非常适合存储需要在闭包中访问的值。

非响应式:修改 ref.current 不会触发组件重新渲染, React 也不会在渲染过程中读取或使用 ref.current 的值。 这使得 ref 成为存储不需要触发渲染的数据的理想选择。

实战演练

1. 基础用法:DOM 访问

ref 最常见的用途是直接访问 DOM 元素,实现焦点管理、文本选择、动画触发等:

import { useRef, useEffect } from 'react';

export function LoginForm() {
  // 1. 创建 ref,初始值为 null
  const inputRef = useRef<HTMLInputElement>(null);

  // 2. 组件挂载后自动聚焦到输入框
  useEffect(() => {
    // ✅ 在 effect 中访问 DOM 是安全的(DOM 已渲染)
    inputRef.current?.focus();
  }, []);

  const handleSubmit = () => {
    // 3. 在事件处理函数中读取 DOM 属性
    const value = inputRef.current?.value;
    console.log('提交的值:', value);
  };

  return (
    <form>
      {/* 4. 将 ref 附加到 JSX 元素 */}
      <input ref={inputRef} type="text" placeholder="输入用户名" />
      <button type="button" onClick={handleSubmit}>
        提交
      </button>
    </form>
  );
}

2. 生产级案例:自定义 Hook - usePrevious

封装一个可复用的 Hook 来存储之前的值,这在对比数据变化时非常有用:

import { useRef, useEffect } from 'react';

/**
 * 存储上一次渲染的值
 * @param value 当前值
 * @returns 上一次的值(首次渲染返回 undefined)
 */
function usePrevious<T>(value: T): T | undefined {
  // 1. 使用 ref 存储上次的值
  const ref = useRef<T>();

  // 2. 在每次渲染后更新 ref.current
  useEffect(() => {
    ref.current = value;
  }, [value]);

  // 3. 返回上一次的值(在更新 effect 之前执行)
  return ref.current;
}

// ============= 使用示例 =============
export function Counter() {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);

  return (
    <div>
      <p>当前: {count}</p>
      <p>上次: {prevCount ?? '无'}</p>
      <p>变化: {prevCount !== undefined ? count - prevCount : '首次渲染'}</p>
      <button onClick={() => setCount(c => c + 1)}>增加</button>
    </div>
  );
}

3. 生产级案例:定时器管理

使用 ref 存储定时器 ID,确保在组件卸载时正确清理,避免内存泄漏:

import { useState, useRef, useEffect } from 'react';

export function Stopwatch() {
  const [seconds, setSeconds] = useState(0);
  const [isRunning, setIsRunning] = useState(false);

  // ✅ 使用 ref 存储定时器 ID
  const intervalRef = useRef<number | null>(null);

  // 启动定时器
  const start = () => {
    // ✅ 防止重复启动
    if (intervalRef.current !== null) return;

    intervalRef.current = window.setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);

    setIsRunning(true);
  };

  // 停止定时器
  const stop = () => {
    // ✅ 清理定时器
    if (intervalRef.current !== null) {
      clearInterval(intervalRef.current);
      intervalRef.current = null;
    }
    setIsRunning(false);
  };

  // 重置
  const reset = () => {
    stop();
    setSeconds(0);
  };

  // ✅ 组件卸载时清理定时器
  useEffect(() => {
    // 清理函数:组件卸载时执行
    return () => {
      if (intervalRef.current !== null) {
        clearInterval(intervalRef.current);
      }
    };
  }, []);

  return (
    <div className="p-4">
      <div className="text-3xl font-mono mb-4">{seconds}s</div>
      <div className="space-x-2">
        <button
          onClick={isRunning ? stop : start}
          className="px-4 py-2 bg-blue-500 text-white rounded"
        >
          {isRunning ? '暂停' : '开始'}
        </button>
        <button
          onClick={reset}
          className="px-4 py-2 bg-gray-500 text-white rounded"
        >
          重置
        </button>
      </div>
    </div>
  );
}

4. 生产级案例:DOM 测量

使用 ref 结合 useLayoutEffect 实现 DOM 元素的尺寸测量,避免布局闪烁:

import { useState, useRef, useLayoutEffect } from 'react';

interface Dimension {
  width: number;
  height: number;
}

export function MeasureBox() {
  const [dimensions, setDimensions] = useState<Dimension>({ width: 0, height: 0 });
  const boxRef = useRef<HTMLDivElement>(null);

  // ✅ 使用 useLayoutEffect 在浏览器绘制前同步读取布局
  useLayoutEffect(() => {
    if (boxRef.current) {
      // 读取元素尺寸
      const { width, height } = boxRef.current.getBoundingClientRect();
      setDimensions({ width, height });
    }
  }, []);

  return (
    <div className="p-4">
      {/* 渲染元素内容 */}
      <div
        ref={boxRef}
        className="p-8 bg-blue-100 dark:bg-blue-900 rounded"
      >
        我是可测量盒子
      </div>

      {/* 显示尺寸 */}
      <div className="mt-4 text-sm">
        <p>宽度: {Math.round(dimensions.width)}px</p>
        <p>高度: {Math.round(dimensions.height)}px</p>
      </div>
    </div>
  );
}

避坑指南

陷阱 1: 在渲染过程中读取 ref

问题:在组件渲染函数中直接读取 ref.current 可能导致逻辑错误, 因为 ref 的值可能在 useEffect 执行前还是旧值。

// ❌ 错误:在渲染中读取 ref
function Component({ items }) {
  const countRef = useRef(0);

  // ❌ 在渲染中读取 ref,可能得到过期值
  if (countRef.current > 0) {
    return <div>有 {countRef.current} 个项目</div>;
  }

  return <div>无项目</div>;
}
// ✅ 正确:使用 state 存储需要渲染的值
function Component({ items }) {
  const [count, setCount] = useState(0);

  // ✅ 使用 state 值进行渲染判断
  if (count > 0) {
    return <div>有 {count} 个项目</div>;
  }

  return <div>无项目</div>;
}

核心原则:ref 用于"存储但不渲染"的值,state 用于"需要渲染"的值。 不要在渲染逻辑中读取 ref.current。

陷阱 2: 期望 ref 更新触发渲染

问题:修改 ref.current 不会触发组件重新渲染, 这是 ref 的特性而非 bug,但初学者常误以为它会像 state 一样触发更新。

// ❌ 错误期望
function Counter() {
  const countRef = useRef(0);

  const handleClick = () => {
    countRef.current++; // ❌ 不会触发重新渲染!
  };

  return (
    <div>
      <p>计数: {countRef.current}</p>
      {/* 点击后 UI 不会更新 */}
      <button onClick={handleClick}>增加</button>
    </div>
  );
}
// ✅ 正确:使用 state
function Counter() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(c => c + 1); // ✅ 会触发重新渲染
  };

  return (
    <div>
      <p>计数: {count}</p>
      <button onClick={handleClick}>增加</button>
    </div>
  );
}

陷阱 3: 在渲染时访问 DOM ref

问题:在首次渲染时,DOM ref.current 还是 null, 因为 DOM 元素尚未创建。必须在 useEffect 或事件处理函数中访问。

// ❌ 错误:首次渲染时访问 DOM ref
function Form() {
  const inputRef = useRef<HTMLInputElement>(null);

  // ❌ 首次渲染时 inputRef.current 是 null!
  const inputValue = inputRef.current?.value ?? '';

  return <input ref={inputRef} defaultValue={inputValue} />;
}
// ✅ 正确:在 effect 中访问 DOM
import { useEffect } from 'react';

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

  // ✅ DOM 已经渲染完成
  useEffect(() => {
    if (inputRef.current) {
      console.log('输入框值:', inputRef.current.value);
    }
  }, []);

  return <input ref={inputRef} />;
}

陷阱 4: 忘记清理 ref 中的副作用

问题:ref 中存储的定时器、事件监听器、第三方库实例等副作用, 如果在组件卸载时不清理,会导致内存泄漏。

// ❌ 错误:组件卸载后定时器仍在运行
function Timer() {
  const intervalRef = useRef<number | null>(null);

  const start = () => {
    intervalRef.current = setInterval(() => {
      console.log('定时中...');
    }, 1000);
  };

  return <button onClick={start}>开始</button>;
  // ❌ 组件卸载后定时器没有被清理!
}
// ✅ 正确:在 useEffect 清理函数中释放资源
import { useEffect } from 'react';

function Timer() {
  const intervalRef = useRef<number | null>(null);

  const start = () => {
    intervalRef.current = setInterval(() => {
      console.log('定时中...');
    }, 1000);
  };

  // ✅ 组件卸载时清理定时器
  useEffect(() => {
    return () => {
      if (intervalRef.current !== null) {
        clearInterval(intervalRef.current);
      }
    };
  }, []);

  return <button onClick={start}>开始</button>;
}

最佳实践

✅ 推荐模式

  • 用 ref 存储 DOM 元素引用
  • 用 ref 存储定时器 ID、WebSocket 连接等
  • 用 ref 存储不需要触发渲染的值
  • 在 useEffect 清理函数中释放 ref 资源
  • 使用 TypeScript 为 ref 添加类型注解
  • 封装自定义 Hook 复用 ref 逻辑

❌ 避免模式

  • 不要在渲染逻辑中读取 ref.current
  • 不要期望 ref 更新触发重新渲染
  • 不要在首次渲染时访问 DOM ref
  • 不要忘记清理 ref 中的副作用
  • 不要滥用 ref(能用 state 解决就用 state)
  • 不要在渲染中直接修改 ref.current

📊 ref vs state 对比

特性refstate
更新触发渲染?❌ 否✅ 是
在渲染中读取?❌ 不推荐✅ 推荐且安全
在闭包中同步?✅ 始终最新值❌ 闭包陷阱
典型用途DOM、定时器 IDUI 状态、表单数据

延伸阅读