React 里的 ref 到底引用了什么:3 个常踩的坑与正确用法

编程狮(w3cschool.cn) 2026-10-31 07:05:58 浏览数 (45)
反馈

一句话讲清:ref 是 React 在多次渲染之间替你保管的同一个盒子,你把任何值放进 ref.current,下一次渲染再拿出来还是它;它不会参与渲染,也不会自动通知 React 重新渲染。所以 ref 的正确用途只有两类——拿到真实 DOM 节点做非受控组件操作,以及在多次渲染之间存放不该触发重渲染的值。下面用三个真实场景把边界讲透,每个场景都给出可跑的最小示例、预期行为和失败时的报错原文。适用版本是 React 16.8 及之后的 Hook 写法,类组件里的 createRef 语义相同但不写。

React 里的 ref到底引用了什么?

先看结论

  • ref 是「跨渲染保持不变的容器」,它既不是状态,也不是响应式数据。
  • 非受控组件靠 ref 读取 DOM 里的值,受控组件必须用 state,两者不要混着写。
  • 读取 ref.current 的时机必须晚于提交:在渲染阶段读,第一次必然是初始值或 null。
  • 改 ref 不触发重渲染,想在界面上看到变化就得用 state。
  • 父组件要拿子组件的 DOM 节点或方法,子组件得把 ref 转发出去,不能让父组件直接伸手改别人的盒子。

先明确一个前提:React 的渲染分成渲染阶段和提交阶段。渲染阶段是函数体执行的过程,此时 DOM 还没更新;提交阶段结束后 DOM 才落地。理解 ref,本质上就是理解这两个时间点。

一、ref 到底是什么:一个跨渲染不变的盒子

useRef(initialValue) 返回的是一个普通对象 { current: initialValue }。关键在于这个对象本身是稳定的:组件函数每次执行,拿到的都是同一个对象引用,只有 current 里的内容可以被改写。请你务必核对这一点——正因为盒子不变,里面的值才能跨过一次次重渲染活下来。

先看一个最小的、能跑的写法:

import { useRef } from "react";

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

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

  return (
    <>
      <input ref={inputRef} placeholder="输入关键词" />
      <button onClick={focusInput}>聚焦输入框</button>
    </>
  );
}

预期结果:点击按钮后光标落到输入框里。ref={inputRef} 这一步由 React 负责,提交阶段结束后它会把真实 DOM 节点写进 ref.current,所以事件回调里一定能读到。

失败分支也很典型:如果把 ref={inputRef} 写在一个自己定义的子组件 <MyInput ref={inputRef} /> 上,控制台会给出 Warning: Function components cannot be given refs. Attempts to access this ref will fail.,此时 ref.current 永远是 null。解决办法在第四节。

React 的组件体系比较大,涉及渲染时机的内容可以对照 React 教程 里的 Hook 章节再读一遍。

二、第一个坑:在渲染阶段读 ref.current

最常见的错误是想在函数体里直接打印或用 ref 的值:

function Counter() {
  const countRef = useRef(0);
  console.log("渲染时读到:", countRef.current);
  countRef.current = countRef.current + 1;
  return <div>{countRef.current}</div>;
}

预期结果(React 18 默认 StrictMode 下的行为,未在本机执行,按官方文档口径描述):首次渲染时打印的是初始值 0,界面显示自增后的值;后续由父组件触发的重渲染才会显示上一次留下来的数字。这里的关键不是数字对不对,而是渲染阶段修改 ref 属于副作用,会让结果依赖「这轮渲染跑了几次」,组件一进 StrictMode 或者将来切到并发特性,行为就不稳定了。

正确写法是把读写挪进事件处理函数或 useEffect:

function Timer() {
  const nRef = useRef(0);
  return (
    <button
      onClick={() => {
        nRef.current += 1;
        console.log("当前点击次数:", nRef.current);
      }}
    >
      点我累加
    </button>
  );
}

结论很硬:渲染期间只读它自己依赖的数据,ref 的读写留给 commit 之后。这条经验不来自某一条文档条款,而是把「渲染要幂等」这条原则落到 ref 上的编辑推断,你可以用上面的最小示例自行验证。

三、第二个坑:拿 ref 去驱动渲染

第二个坑是新手最容易混淆的地方:既然 ref 能存值,能不能把它当 state 用?答案是不能。

function BadCounter() {
  const countRef = useRef(0);
  return (
    <button onClick={() => { countRef.current += 1; }}>
      点击了 {countRef.current} 次
    </button>
  );
}

预期结果:按钮文字永远停在 0,只有等到下一次因别的原因(比如父组件重渲染)重渲染时,数字才会跳一次。原因是 ref.current 变了,但 React 不知道,不会安排一次新的渲染。

正确的分界线是这样划的:

  • 界面上要显示出来的值,一律放进 state。
  • 影响不了界面、但需要在多次渲染之间记住的值,放进 ref,例如定时器句柄、上一次的 props、某个第三方实例的引用。
  • 既要在界面上显示、又要跨事件累积,那就用 state,必要时用 ref 做辅助记录。
  • 判断口诀:这个值会不会参与决定画面长什么样?会,就必须是 state;不会,非受控组件的那一套才成立。

一个混合了两者受控与非受控写法的完整对照:

function LoginForm() {
  const [account, setAccount] = useState("");
  const pwdRef = useRef(null);

  function submit() {
    console.log("账号来自受控组件:", account);
    console.log("密码取自非受控组件:", pwdRef.current.value);
  }

  return (
    <form onSubmit={(e) => { e.preventDefault(); submit(); }}>
      <input value={account} onChange={(e) => setAccount(e.target.value)} />
      <input ref={pwdRef} type="password" defaultValue="" />
      <button type="submit">登录</button>
    </form>
  );
}

预期结果:账号输入框每敲一个字都会重渲染一次,密码框全程不参与渲染,提交时才一次性读取。这就是非受控组件的价值——把高频输入挡在渲染之外。反过来讲,非受控组件的劣势也很明显:你拿不到实时的输入值,所以「输入六位以上才点亮按钮」这类需求它做不了。适用边界也说清楚:需要实时校验、需要根据输入值联动禁用按钮的字段,必须走受控写法;而只在提交那一刻才关心内容的长表单字段,用非受控组件能省掉大量无谓的渲染。一个表单里混用两种写法完全正常,关键是把每个字段该属于哪一侧想清楚。

受控组件与非受控组件在四个维度上的差异

四、第三个坑:父组件直接改子组件的 ref

第三个坑出在跨组件边界上。函数组件默认不接收 ref,父组件传进去会被 React 拦下来。要拿到子组件的 DOM 节点,子组件必须转发:

const MyInput = forwardRef(function MyInput(props, ref) {
  return <input ref={ref} {...props} />;
});

function Parent() {
  const ref = useRef(null);
  return (
    <>
      <MyInput ref={ref} />
      <button onClick={() => ref.current?.focus()}>让子输入框聚焦</button>
    </>
  );
}

比转发 DOM 节点更常见的是「父组件要调子组件的方法」,比如让弹窗打开、让播放器暂停。这时候不要把整个组件实例暴露出去,而是用 useImperativeHandle 只暴露约定的一小撮方法:

const VideoCard = forwardRef(function VideoCard(props, ref) {
  const elRef = useRef(null);
  useImperativeHandle(ref, () => ({
    pause: () => elRef.current?.pause(),
    resume: () => elRef.current?.play(),
  }));
  return <video ref={elRef} src={props.src} />;
});

预期结果:父组件拿到的 ref.current 不再是 video 元素,而是 { pause, resume } 两个方法。这样做的好处是子组件的 DOM 结构以后改了,父组件照样不用动。

按是否要拿 DOM 节点、是否要调用方法来选转发方式

组件层级一旦和状态纠缠,React 基础教程 里的组件章节值得完整翻一遍。再往前推一层,跨组件调用本质上还是父子之间约定了什么,约定越窄,以后改动越自由。

动手核对清单

把上面三段收进一个可执行的验证步骤,非受控组件的坑基本就能在上线前发现:

  1. 前置:准备一个 React 项目(任意脚手架均可),把本文的 SearchBox 组件贴进去。
  2. 命令:运行 npm run dev 起本地服务,浏览器打开对应页面。
  3. 操作:点击「聚焦输入框」按钮,观察光标是否进入输入框。
  4. 预期:光标进入输入框,控制台没有警告输出。
  5. 失败:如果控制台出现 Function components cannot be given refs,说明 ref 被挂到了未转发的子组件上,处理方式是给子组件加 forwardRef。
  6. 验证:再把 SearchBox 复制一份同时渲染两次,各自点击各自的按钮,两个输入框互不干扰才算通过。

以上步骤为本机的常规操作流程,未在本文写作环境实际执行,输出为预期结果。

总结

  • ref 的核心语义是「跨渲染保持不变的可变盒子」,它不参与渲染,变了也不会通知 React。
  • 非受控组件靠它读取 DOM 值、避开高频重渲染;受控组件必须用 state。
  • 渲染阶段不该读写 ref,动作放事件回调或 useEffect。
  • 跨组件用 ref 必须转发,暴露行为优先用 useImperativeHandle 收口。

常见问题

Q:ref.current 一直是 null,最常见的三种原因?

一是 ref 没挂到真正的 DOM 元素上,比如挂到了未转发的函数组件;二是在渲染阶段或更早的时机读取,此时 DOM 还没提交;三是元素受条件渲染影响,本轮根本没渲染出来。排查顺序建议按这三步走,每一步都能用一条 console.log 验证。

Q:为什么改了 ref 页面不动,但加个 console.log 又能看到值变了?

这正是 ref 与 state 的分界线。console.log 打印的是内存里的当前值,它变了;但 React 不知道它变了,所以不会安排重渲染,界面依旧是上一次渲染的产物。要在界面上体现变化,就把值搬进 state。非受控组件正是因为跳过了这一步,才得以避开高频重渲染。

Q:useRef 和直接用模块顶部的一个全局变量有什么区别?

区别在实例隔离。全局变量在页面上是唯一的,同一个组件渲染两次会共用一处存储;ref 跟着组件实例走,每个实例各自一个盒子,卸载时随之释放。写复用组件时必须用 ref,不能用全局变量。想把「同一个引用、里面的值可以换」这件事彻底想透,可以补一点 JavaScript 教程 里的引用类型基础。

延伸学习

  • CodeBuddy网页实战课:一套从页面到联调走完的配套课程,适合把组件间的调用关系放到真实项目里练一遍。
  • React无限渲染黑洞:把无限重渲染、层层包装这几类坑连起来看,和本文第二节的坑是同一类问题。
  • React 框架入门:把组件、渲染、数据流放在一条线上理解,读完再看 ref 的定位会更清楚。

0 人点赞