跳至正文
来两杯美式
返回

React 核心(6):useRef 完全指南

By 来两杯美式
发布于

useRef 是组件的”私人抽屉”——往里放东西不会惊动任何人,但下次打开还在。

一、useRef 做的事:跨渲染持久引用

函数组件每次渲染都是全新执行——函数里的 letconst 全部重新声明,值全部重置。useRef 给你一个跨渲染持久存在的引用

const ref = useRef(initialValue);
// ref 是一个对象:{ current: initialValue }
// 每次渲染拿到的都是同一个对象

关键特性:ref.current 不会触发重渲染

类比:组件里有两个存储空间——

useStateuseRef
类比公告板私人抽屉
改了之后所有人都看到(触发重渲染)没人知道(不触发重渲染)
读取方式渲染时读 state渲染时读 ref.current
可变性不可变,必须用 setter随便改,直接赋值

二、与 useState 的核心区别

2.1 改值是否触发重渲染

function Demo() {
  const [count, setCount] = useState(0);
  const ref = useRef(0);

  return (
    <div>
      <p>state: {count}</p>
      <p>ref: {ref.current}</p>

      {/* 点这个 → 页面更新,count 变成 1 */}
      <button onClick={() => setCount(count + 1)}>改 state</button>

      {/* 点这个 → ref.current 变了,但页面不会更新! */}
      <button onClick={() => ref.current++}>改 ref</button>
    </div>
  );
}

点”改 ref”按钮,ref.current 确实变了,但 React 不知道——所以页面上的 ref: 0 不会自动变成 1。直到下一次因其他原因重渲染时,才会显示新值。

2.2 可变性

// state:必须通过 setter 改,不能直接赋值
const [count, setCount] = useState(0);
count = 1; // ❌ 毫无效果,变量被覆盖,React 不知道
setCount(1); // ✅ 正确方式

// ref:直接改 current 属性
const ref = useRef(0);
ref.current = 1; // ✅ 完全合法,ref 对象引用不变,只是 current 变了

2.3 渲染时的身份

function Demo() {
  const [count, setCount] = useState(0);
  const ref = useRef(0);

  // 每次渲染:
  // count 是"那一帧的快照"——渲染后就不变了
  // ref 是"同一个对象"——每次渲染拿到的是同一个引用
}
useStateuseRef
每次渲染拿到的是那一帧的快照同一个对象
值的更新时机setter 调用后,下次渲染才读到新值赋值后立刻生效
适合存什么参与 UI 渲染的数据不参与渲染的辅助数据

判断标准:这个值变了需不需要更新 UI?需要 → useState,不需要 → useRef

三、两大用途

useRef 的用途就两类:

  1. 访问 DOM 元素——命令式操作 DOM(focus、measure、scroll、集成非 React 库)
  2. 存储不参与渲染的可变值——previous value、timer ID、abort controller 等

为什么需要 ref 去摸 DOM?

React 的规矩是:你只管告诉它”页面长什么样”,它帮你画出来、帮你更新。你写 <input />,它帮你生成输入框;你改了 state,它帮你刷新页面。这叫声明式——你说”要什么”,不说”怎么做”。

但有些事,你没法靠”描述想要什么”来达成。比如页面一打开,你希望光标自动跑到输入框里——你怎么跟 React 说”请把光标放进去”?没有哪个 JSX 属性能表达”获得焦点”这件事。因为焦点不是一个”样子”,而是一个”动作”。

React 其实处理动作(<input autoFocus /> 就是它包了一层的例子),但它选择只包那些简单且通用的,剩下的一概不包——因为包了就得永远维护,而且有些事包起来反而碍手碍脚。所以 React 画了一条线:

这条线是 React 主动选择不越过的,不是它做不到。

那右边这些事怎么办?你得自己拿到那个真实的 DOM 节点,亲自下命令:input.focus()element.scrollIntoView()element.getBoundingClientRect()……ref 就是 React 给你的通行证——你把它挂到元素上,React 就把那个真实节点塞给你,剩下的你自己操作。

为什么命令式操作 DOM 必须用 ref,不能直接 getElementById?

技术上你确实可以直接用原生 API:

function MyComponent() {
  useEffect(() => {
    // 这样也能拿到节点,也能 focus
    document.getElementById("myInput").focus();
  }, []);

  return <input id="myInput" />;
}

但问题在于:React 管着 DOM 的生杀大权。

React 每次 state/props 变了,就重新执行组件函数,生成新的虚拟 DOM,diff 之后更新真实 DOM。这个过程中真实节点可能被替换、移动、销毁——而你用 getElementById 拿到的旧引用完全不知道:

function MyComponent() {
  const [show, setShow] = useState(true);

  useEffect(() => {
    const el = document.getElementById("myInput");
    // 某次渲染后 React 销毁重建了这个 input
    // 你手里的 el 指向的是已经不在页面上的僵尸节点
    el.focus(); // 可能报错,可能无效
  }, []);

  return (
    <div>
      {show && <input id="myInput" />}
      <button onClick={() => setShow(!show)}>切换</button>
    </div>
  );
}

ref 解决了这个问题——让 React 知道你在用这个节点。 你用 ref,React 会在每次虚拟 DOM 渲染到真实 DOM 之后,主动把最新的节点更新到 ref.current 上:节点换了,ref.current 自动更新;节点销毁了,ref.current 自动变 null。你永远拿着的是当前真实在页面上的那个节点。

document.getElementById()  → 你自己去捞,React 不知道,可能捞到过期的
ref                       → React 主动给你,永远是最新鲜的

所以不是”命令式操作 DOM 必须用 ref”,而是在 React 的地盘上操作 DOM,用 ref 才安全——因为 React 通过虚拟 DOM 管着真实 DOM 的生老病死,你得通过它的渠道拿节点,否则你手里的引用随时可能失效。

四、DOM Ref 详解

4.1 ref prop 的用法

React 的 JSX 元素有个特殊的 ref prop——把 useRef 创建的 ref 对象传给它,React 就会把对应的 DOM 节点赋值给 ref.current

function TextInput() {
  const inputRef = useRef(null);
  // 渲染后:inputRef.current → <input> DOM 节点

  function handleFocus() {
    inputRef.current.focus(); // 命令式调用 DOM API
  }

  return (
    <div>
      <input ref={inputRef} type="text" />
      <button onClick={handleFocus}>聚焦输入框</button>
    </div>
  );
}

流程:

  1. 组件渲染,React 创建 <input> DOM 节点
  2. React 把这个节点赋给 inputRef.current
  3. 你在事件处理器里通过 inputRef.current 操作 DOM

4.2 经典场景:自动聚焦

function AutoFocusInput() {
  const inputRef = useRef(null);

  useEffect(() => {
    // 组件挂载后,自动聚焦
    inputRef.current.focus();
  }, []); // 空依赖:只在挂载时执行一次

  return <input ref={inputRef} type="text" placeholder="自动聚焦" />;
}

为什么要在 useEffect 里调 focus() 而不是渲染时直接调?因为渲染时 DOM 还没挂载,ref.current 还是 nulluseEffect 在 DOM 更新后执行,此时节点已就绪。

4.3 回调 ref(Callback Ref)

ref prop 除了传 ref 对象,还可以传函数——这就是回调 ref:

function TextInput() {
  function handleRef(node) {
    // node 是 DOM 节点(卸载时是 null)
    if (node) {
      node.focus(); // 节点挂载时自动聚焦
    }
  }

  return <input ref={handleRef} type="text" />;
}

回调 ref 的优势:你可以在节点挂载/卸载时做任意操作,不限于赋值给 .current

典型场景——测量 DOM 尺寸:

function MeasureBox() {
  const [height, setHeight] = useState(0);

  const measuredRef = useCallback(node => {
    if (node) {
      setHeight(node.getBoundingClientRect().height);
    }
  }, []);

  return (
    <div>
      <div ref={measuredRef}>内容区域</div>
      <p>高度:{height}px</p>
    </div>
  );
}

为什么要用 useCallback 包裹?这是社区最佳实践(官方文档未明确推荐):如果回调 ref 函数每次渲染都是新引用,React 会先卸载旧 ref(传 null),再挂载新 ref——导致不必要的执行。useCallback 保证引用稳定。

4.4 React 19+ ref cleanup 函数

React 19 新增了 ref callback 返回清理函数的支持——类似 useEffect 的清理机制:

function TextInput() {
  return (
    <input
      ref={node => {
        // 节点挂载时执行
        node.focus();

        // 返回清理函数——节点卸载时执行(React 19+)
        return () => {
          console.log("input 卸载了");
        };
      }}
      type="text"
    />
  );
}

注意:ref callback 只能返回函数(清理函数)或不返回值。React 19 会检查返回值——如果返回了非函数的值(比如隐式返回了 DOM 节点),会报错。

4.5 其他 DOM 操作场景

// 滚动到底部
const bottomRef = useRef(null);
useEffect(() => {
  bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);

// 集成非 React 库(如 D3、Leaflet)
const mapRef = useRef(null);
useEffect(() => {
  const map = L.map(mapRef.current).setView([51.505, -0.09], 13);
  return () => map.remove(); // 清理
}, []);

五、可变值 Ref 详解

5.1 记录上一次的值

React 没有内置 usePrevious,用 useRef 可以实现:

经典实现(useRef + useEffect)

function usePrevious(value) {
  const ref = useRef();

  useEffect(() => {
    ref.current = value; // 渲染完成后才更新,保证"上一次"
  });

  return ref.current;
}

⚠️ 局限:这个实现依赖 useEffect 的异步执行时序,在 React 18 Strict Mode(组件双次挂载)下可能出现边界问题。Chakra UI 等主流库已迁移到基于 useState 的实现。

推荐实现(useState-based,更可靠)

function usePrevious(value) {
  const [current, setCurrent] = useState(value);
  const [previous, setPrevious] = useState(undefined);

  // 渲染期间同步比较,值变了就更新
  if (!Object.is(value, current)) {
    setPrevious(current);
    setCurrent(value);
  }

  return previous;
}

为什么 state-based 更好?因为它在渲染期间同步执行,不依赖 useEffect 的异步时序,在 Strict Mode 和并发模式下行为更可预测。

使用:

function Counter() {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);

  return (
    <div>
      <p>
        当前:{count},上一次:{prevCount}
      </p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
    </div>
  );
}

经典实现中赋值放在 useEffect 里的原因:useEffect 在渲染之后执行——这样本次渲染读到的 ref.current 还是旧值,渲染完才更新为新值。如果直接在渲染时赋值,那读到的就是当前值,不是”上一次”了。

5.2 存定时器 ID

function Timer() {
  const [seconds, setSeconds] = useState(0);
  const intervalRef = useRef(null);

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

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

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

  return (
    <div>
      <p>{seconds}</p>
      <button onClick={start}>开始</button>
      <button onClick={stop}>暂停</button>
    </div>
  );
}

为什么不用 let 存 timer ID?因为每次渲染 let 都会重置,你存不住。useRef 跨渲染持久,完美适配。

5.3 存 AbortController

function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const abortRef = useRef(null);

  useEffect(() => {
    // 取消上一次请求
    abortRef.current?.abort();

    const controller = new AbortController();
    abortRef.current = controller;

    fetch(`/api/search?q=${query}`, { signal: controller.signal })
      .then(res => res.json())
      .then(data => setResults(data))
      .catch(err => {
        if (err.name !== "AbortError") throw err;
      });

    return () => controller.abort();
  }, [query]);

  return <ResultList results={results} />;
}

快速输入时,前一次请求被取消,只有最后一次请求的结果会更新 UI——经典的竞态处理模式。

六、ref 不触发重渲染的深层原因

React 的渲染触发机制追踪四类数据源

  1. StateuseState / useReducer 的值变了
  2. Props — 父组件传下来的值变了
  3. ContextuseContext 订阅的值变了
  4. 父组件重渲染 — 即使子组件的 props 没变,父组件重渲染也会导致子组件重渲染(除非用 React.memo 包裹)

注意:第 4 条和第 2 条不同——Props 变了是”数据驱动”,父组件重渲染是”渲染传播”。一个子组件可能 props 完全没变,但仍然因为父组件的 state 变了而跟着重渲染。

只有这四类变化才会让 React 重新执行组件函数。ref.current 不在这个追踪列表里——React 根本不监听它。

setState()  → React 检测到变化 → 重新渲染组件 → 更新 DOM
ref.current = xxx → React 不知道 → 什么都不做

这不是 bug,而是设计:ref 就是用来存”React 不需要管的数据”的。如果 ref 变了也触发渲染,那它和 state 有什么区别?

本质useRef 返回的对象和 useState 返回的 state 存在 React 的同一个 Fiber 节点上——区别不在存储,而在 React 是否追踪变化。State 被追踪,ref 不被追踪。

七、使用规则与陷阱

7.1 不要在渲染过程中读写 ref.current

function Bad() {
  const ref = useRef(0);

  // ❌ 渲染期间修改 ref——可能导致不一致的行为
  ref.current = ref.current + 1;

  // ❌ 渲染期间读取 ref 来决定 UI——值可能不是最新的
  return <p>{ref.current}</p>;
}

为什么?因为 ref.current 的修改不触发渲染,React 无法保证渲染结果和 ref 值的一致性。如果渲染依赖 ref.current,你就绕过了 React 的数据流——这和直接操作 DOM 一样,是反模式。

function Good() {
  const ref = useRef(0);

  // ✅ 在事件处理器中读写——安全
  function handleClick() {
    ref.current += 1;
    console.log("点击次数:", ref.current);
  }

  // ✅ 在 effect 中读写——安全
  useEffect(() => {
    ref.current = someValue;
  });

  return <button onClick={handleClick}>点击</button>;
}

规则:渲染时只读 ref 做初始化(useRef(initialValue)),其他读写都放在事件处理器或 effect 里。

7.2 ref.current 可能是 null

const ref = useRef(null);
// 首次渲染时,如果 ref 还没挂载到 DOM,ref.current 就是 null

useEffect(() => {
  // ✅ effect 里安全,DOM 已挂载
  ref.current?.focus();
});

function handleClick() {
  // ✅ 事件处理器里也安全,用户交互时 DOM 一定在
  ref.current?.scrollTo(0, 0);
}

养成用可选链 ?. 的习惯,防止 null 报错。

7.3 函数组件的 ref

默认情况下,函数组件不能接收 ref prop——因为函数组件没有实例。你需要用特定方式让父组件拿到子组件的 DOM 或暴露方法。

React 19+(推荐)ref 已成为普通 prop,不再需要 forwardRef

// React 19+ — ref 直接作为 prop 接收
function FancyInput({ ref, ...props }) {
  return <input ref={ref} className="fancy" {...props} />;
}

// 父组件
function Parent() {
  const inputRef = useRef(null);
  return <FancyInput ref={inputRef} />;
}

React 19 官方公告原文:

“Starting in React 19, you can now access ref as a prop for function components. New function components will no longer need forwardRef. In future versions we will deprecate and remove forwardRef.”

React 18 及以下(旧写法):需要 forwardRef 包裹:

const FancyInput = React.forwardRef((props, ref) => {
  return <input ref={ref} className="fancy" />;
});

function Parent() {
  const inputRef = useRef(null);
  return <FancyInput ref={inputRef} />;
}

forwardRef + useImperativeHandle 可以精确控制暴露哪些方法——详见进阶篇。

八、与 useEffect 的配合模式

useRefuseEffect 是经典搭档——ref 存”跨渲染需要但不应触发渲染”的值,effect 处理副作用和清理。

8.1 模式一:effect 中操作 DOM

useEffect(() => {
  inputRef.current.focus();
}, []);

8.2 模式二:effect 中清理副作用

useEffect(() => {
  const id = setInterval(callback, 1000);
  intervalRef.current = id;
  return () => clearInterval(intervalRef.current);
}, []);

8.3 模式三:跳过首次 effect

⚠️ React 官方不推荐此模式GitHub Issue #24527)——建议将逻辑放到事件处理函数中。

了解即可,不建议在生产环境使用:

function useUpdateEffect(effect, deps) {
  const isFirstRender = useRef(true);

  useEffect(() => {
    if (isFirstRender.current) {
      isFirstRender.current = false;
      return; // 首次渲染跳过
    }
    return effect();
  }, deps);
}

问题:React 18 Strict Mode 下组件会挂载两次,isFirstRender 标志在第二次挂载时已经是 false,行为不符合预期。

推荐替代:用 useRef 存前一个值,在 effect 中比较:

function MyComponent({ value }) {
  const prevValue = useRef(value);

  useEffect(() => {
    if (prevValue.current !== value) {
      // 值实际发生了变化
      doSomething(value);
    }
    prevValue.current = value;
  }, [value]);
}

8.4 模式四:判断组件是否已卸载

反模式——不推荐使用。React 官方 2015 年即已声明 isMounted is an Antipatterneslint-plugin-reactno-is-mounted 规则。

了解这个模式是为了避免使用它

// ❌ 反模式——不要这样做
function useIsMounted() {
  const mounted = useRef(false);

  useEffect(() => {
    mounted.current = true;
    return () => {
      mounted.current = false;
    };
  }, []);

  return mounted;
}

// ❌ 典型误用:防止卸载后 setState
function DataFetcher() {
  const [data, setData] = useState(null);
  const isMounted = useIsMounted();

  useEffect(() => {
    fetchData().then(result => {
      if (isMounted.current) {
        // 只是隐藏了警告,没解决根本问题
        setData(result);
      }
    });
  }, []);
}

问题isMounted 检查只是隐藏了警告,不解决根本问题——仍然持有对已卸载组件的引用(内存泄漏),且在并发模式下行为不可预测。

✅ 正确替代——用 AbortController(详见 §5.3):

function DataFetcher() {
  const [data, setData] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    fetch("/api/data", { signal: controller.signal })
      .then(res => res.json())
      .then(result => setData(result))
      .catch(err => {
        if (err.name !== "AbortError") throw err; // 忽略取消,处理真实错误
      });

    return () => controller.abort(); // 卸载时取消请求,从根本上避免问题
  }, []);
}

规律:凡是”需要跨渲染持久、但不参与 UI 渲染”的数据,都该用 useRef 存,然后在 useEffect 里读写。

九、一句话

useRef 是组件的私人抽屉——往里放东西不会惊动 React,下次打开还在。记住三件事:改 ref.current 不触发重渲染、不要在渲染过程中读写 ref、需要触发渲染的数据用 useState 不要用 useRef


分享这篇文章:
通过邮件分享这篇文章✓ 链接已复制
所属专题
React
第 6 / 17 篇
查看系列全部文章
  1. 01.React 核心(1):React 组件与函数的本质区别
  2. 02.React 核心(2):声明式与 React 的实现机制
  3. 03.React 核心(3):Props 完全指南
  4. 04.React 核心(4):useState 完全指南
  5. 05.React 核心(5):useEffect 完全指南
  6. 06.React 核心(6):useRef 完全指南
  7. 07.React 核心(7):Context API 与状态共享
  8. 08.React 核心(8):useCallback 与 useMemo
  9. 09.React 核心(9):Hooks 规则与闭包陷阱
  10. 10.React 核心(10):自定义 Hook 设计
  11. 11.React 核心(11):Key 与列表渲染
  12. 12.React 核心(12):表单与受控/非受控组件
  13. 13.React 核心(13):React 渲染机制深入——Fiber 与调度
  14. 14.React 核心(14):React 18 并发特性
  15. 15.React 核心(15):Suspense 机制详解
  16. 16.React 核心(16):TanStack Query 入门指南
  17. 17.React 核心(17):TanStack Query 缓存机制详解

上一篇
React 核心(7):Context API 与状态共享
下一篇
React 核心(5):useEffect 完全指南