#React#TypeScript

React Ref 模式:forwardRef、useImperativeHandle 与 Ref 转发

Ref 不只是"引用 DOM"——在组件库里它决定 API 体验。本文讲清 useRef(值容器/不触发重渲染)、forwardRef(转发 ref 给子组件)、useImperativeHandle(暴露受控 API)、ref callback,以及 React 19 的 ref as prop(不用 forwardRef 了)。

本文要点

  • 主题聚焦:React、TypeScript
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年1月25日·更新于 2026年7月23日

很多开发者对 ref 的认知停留在「拿到 DOM 调一下 focus」这个层面。一旦开始写组件库,你会发现 ref 才是组件 API 体验的真正分水岭:一个 Input 组件到底是让使用者拿到 <input> 元素,还是拿到一个 { focus(), clear() } 的受控句柄,决定了这个组件好不好用、好不好维护。

这篇文章把 ref 的几条主线一次讲透:useRef 的两副面孔、forwardRef 的转发、useImperativeHandle 的受控暴露、函数 ref 的精确控制,以及 React 19 里「ref 作为普通 prop」带来的简化。每一段都配 TSX,不绕弯子。

一、useRef 的两副面孔

useRef 返回一个在整个组件生命周期内稳定不变的对象 { current: T }。稳定到什么程度?每次渲染拿到的是同一个引用。它有两个截然不同的用途,混淆它们是新手最常见的错误。

面孔一:引用 DOM 元素

把 ref 挂到 JSX 的 ref 属性上,React 在 commit 阶段会把对应 DOM 节点赋给 ref.current:

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

  useEffect(() => {
    // 挂载后自动聚焦
    inputRef.current?.focus();
  }, []);

  return <input ref={inputRef} placeholder="搜索…" />;
}

注意初始值是 null,类型是 RefObject<HTMLInputElement | null>。组件卸载时 React 会把 current 重新置为 null——这点很重要,后面讲陷阱时会用到。

面孔二:不触发重渲染的可变容器

这是 useRef 更隐蔽、也更容易被滥用的用途:存一个任意值,改它不会让组件重新渲染。

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

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

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

  return <p>看控制台</p>;
}

这里的 timer ID 不需要驱动 UI,放进 state 纯属浪费(还要触发一次没意义的重渲染)。放进 ref 刚好:它活着、可变、稳定、且不吵不闹。

判别原则:如果一个值需要驱动渲染,用 useState;如果它只是「组件的附注信息」(timer、上一次的某个值、第三方库的实例),用 useRef。我见过把请求结果存进 ref 然后纳闷界面为什么不更新的代码——根因都是把 ref 当成了「不会触发更新的 state」,而恰恰漏掉了「不会触发更新」这一句。

useRef 的常见场景清单:

  • DOM/组件实例引用(focus、滚动、测量尺寸)
  • 定时器、订阅、AbortController 的句柄
  • useEffect 之间共享的「上次值」(previousRef.current)
  • 防抖/节流里存「上一次调用时间」

二、forwardRef:把 ref 传下去

直接给一个自定义函数组件传 ref 是不行的——React 默认不把 ref 当成普通 prop,它会报错或被静默忽略。从 React 16.8 到 React 18,标准做法是用 forwardRef 包一层:

tsx
type FancyInputProps = {
  label: string;
  defaultValue?: string;
};

const FancyInput = forwardRef<HTMLInputElement, FancyInputProps>(
  function FancyInput({ label, defaultValue }, ref) {
    return (
      <label>
        <span>{label}</span>
        <input ref={ref} defaultValue={defaultValue} />
      </label>
    );
  }
);

forwardRef<RefType, PropsType> 的两个泛型参数:第一个是 ref 指向的类型(这里是 HTMLInputElement),第二个是 props 类型。第二个参数 ref 就是父组件传进来的 ref,我们原封不动地挂到内部的 <input> 上——这就是「转发」。

使用者侧:

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

  return (
    <>
      <FancyInput ref={inputRef} label="邮箱" defaultValue="" />
      <button onClick={() => inputRef.current?.focus()}>聚焦</button>
    </>
  );
}

为什么要给组件传 ref? 因为很多场景下,父组件确实需要操作子组件渲染出来的那个 DOM:聚焦、清空、滚动到视野内、读取 selection。如果不提供 ref 通道,使用者就只能被迫通过受控 props + 回调去绕,API 变得很别扭。组件库的黄金法则是:任何渲染单一宿主组件(input、textarea、div 等)的自定义组件,都应当支持 ref 转发,否则它就是一个「黑盒」,使用者拿不到里面那个最关键的节点。

三、useImperativeHandle:暴露受控 API,而不是裸 DOM

直接转发 ref 的问题在于:使用者拿到了整个原生 DOM。他可以随便改 value、改样式、改 innerHTML,你的组件内部状态和外部的直接操作之间会出现不一致。更糟的是,一旦你想把内部的 <input> 换成 <div contenteditable>,所有依赖「这是 input」的调用方都会炸。

useImperativeHandle 给你一种更克制的方式:只暴露你愿意暴露的方法,把内部实现藏起来。

tsx
export type InputHandle = {
  focus: () => void;
  clear: () => void;
  getValue: () => string;
};

type FancyInputProps = { label: string };

const FancyInput = forwardRef<InputHandle, FancyInputProps>(
  function FancyInput({ label }, ref) {
    const inputRef = useRef<HTMLInputElement>(null);

    useImperativeHandle(
      ref,
      () => ({
        focus: () => inputRef.current?.focus(),
        clear: () => {
          if (inputRef.current) inputRef.current.value = '';
          inputRef.current?.focus();
        },
        getValue: () => inputRef.current?.value ?? '',
      }),
      [] // 依赖:通常为空,因为这些方法只依赖稳定的 inputRef
    );

    return (
      <label>
        <span>{label}</span>
        <input ref={inputRef} />
      </label>
    );
  }
);

注意三个关键点:

  1. ref 泛型现在指向自定义句柄类型 InputHandle,不再是 HTMLInputElement。这是契约:你向使用者承诺的是「这组方法」,而不是「这个节点」。
  2. useImperativeHandle(ref, createHandle, deps) 的第三个参数是依赖数组。React 在依赖变化时才会重新创建 handle。绝大多数情况下 handle 内部只引用稳定的 ref,所以传 [] 即可;如果 handle 方法闭包了 props,记得把那些 props 加进依赖,否则会拿到旧值。
  3. handle 在 commit 阶段才挂到 ref 上,所以渲染阶段读 ref.current 永远是 null(详见第七节)。

什么时候该用 useImperativeHandle,什么时候直接转发? 我的判断标准是「有没有抽象边界」:

  • 如果你做的是一个薄包装(FancyDiv、Button 就是套了个 className),直接转发 DOM 没问题——使用者本来就该把它当成那个宿主元素。
  • 如果你做的是一个有自己心智模型的组件(Form、Combobox、Modal、VideoPlayer),暴露受控 API 几乎总是对的。你的内部可能是 <input> 也可能是十个 div 嵌套,调用方不应该关心;他们只要 focus()、open()、play()。

这就是组件库 API 设计的核心张力:ref 暴露什么,就是你对使用者承诺什么。承诺越窄,将来重构越自由。

四、ref callback:函数 ref 的精确控制

ref 不只能传一个 useRef 对象,也能传一个函数。React 在节点挂载时调用它并传入 DOM 节点,在卸载时调用它并传入 null。

tsx
function Measure() {
  const [width, setWidth] = useState(0);

  return (
    <div
      ref={(node) => {
        if (node) {
          setWidth(node.getBoundingClientRect().width);
        }
      }}
    >
      宽度:{width}px
    </div>
  );
}

函数 ref 的真正价值在于**「拿到节点的那个瞬间」要做点事**:测量尺寸、注册第三方库(图表、编辑器)、绑定原生事件监听。

React 19 的 cleanup:把 setup 和 teardown 写在一起

React 19 给函数 ref 加了一个长期缺失的能力:返回一个清理函数。以前的写法是「挂载回调」和「卸载回调」割裂在两个地方(一个在 ref 函数里,一个在 useEffect return 里),现在可以合二为一:

tsx
function Editor() {
  return (
    <div
      ref={(node) => {
        if (!node) return;
        const editor = createThirdPartyEditor(node);

        // React 19:返回的函数会在卸载或 ref 变化时调用
        return () => {
          editor.destroy();
        };
      }}
    />
  );
}

把「创建」和「销毁」写在相邻几行,可读性比拆到 useEffect 里好得多,也更不容易漏掉清理逻辑。

用 useState 当「可触发渲染的 ref」

如果你需要「拿到节点」并且「这个节点变化要触发渲染」(比如根据节点尺寸做布局),一个常见技巧是把 ref 函数本身交给 useState 的 setter:

tsx
function ResponsiveBox() {
  const [node, setNode] = useState<HTMLDivElement | null>(null);

  useEffect(() => {
    if (!node) return;
    const observer = new ResizeObserver((entries) => {
      console.log(entries[0].contentRect);
    });
    observer.observe(node);
    return () => observer.disconnect();
  }, [node]);

  return <div ref={setNode}>观察我</div>;
}

setNode 是稳定函数,可以直接当 ref callback 用;node 变化会触发渲染并把最新节点带进 useEffect。这比手写一个 useRef + forceUpdate 干净得多。

五、React 19:ref 作为普通 prop

前面四节里的 forwardRef,在 React 19 里不再是必须的。从 19 开始,ref 可以像任何普通 prop 一样直接传给函数组件,也能直接从 props 里解构出来:

tsx
type FancyInputProps = {
  label: string;
  ref?: React.Ref<HTMLInputElement>;
};

function FancyInput({ label, ref }: FancyInputProps) {
  return (
    <label>
      <span>{label}</span>
      <input ref={ref} />
    </label>
  );
}

调用方代码完全不变:

tsx
const inputRef = useRef<HTMLInputElement>(null);
<FancyInput ref={inputRef} label="邮箱" />;

这一改动看似微小,实际上解决了几件烦人的事:

  1. 泛型少一层。以前 forwardRef<Ref, Props> 的两个泛型参数顺序经常写反;现在 props 类型里直接声明 ref?: React.Ref<T>,跟其他 prop 写法一致。
  2. displayName 不再需要单独设。forwardRef 包出来的组件默认 displayName 是 "FancyInput",但调试工具里有时候会显示成匿名函数;普通函数组件天然有名字。
  3. spread props 更顺手。{...props} 现在可以连带 ref 一起传下去,不需要单独拎出来。

forwardRef 在 React 19 里仍然可用(被标记为「不再推荐」,会有一条控制台提示,但没有破坏性),存量代码不必急着改。新代码建议直接用 prop 形式——更少包装、更少心智负担。

如果你做组件库要同时兼容 React 18 和 19,短期内还得继续用 forwardRef。一旦放弃 18 支持,果断删掉这层壳。

六、实战:一个能 focus 和 clear 的输入框

把前面几节串起来。我们做一个受控输入框,对外只暴露 focus 和 clear,不暴露裸 DOM,并支持 React 19 的 ref-as-prop(用兼容写法,同时跑在 18/19 上)。

tsx
import {
  forwardRef,
  useImperativeHandle,
  useRef,
  type ChangeEvent,
} from 'react';

// 对外契约:调用方能做的就这两件事
export type SmartInputHandle = {
  focus: () => void;
  clear: () => void;
};

type SmartInputProps = {
  value: string;
  onChange: (value: string) => void;
  placeholder?: string;
  disabled?: boolean;
};

export const SmartInput = forwardRef<SmartInputHandle, SmartInputProps>(
  function SmartInput({ value, onChange, placeholder, disabled }, ref) {
    const inputRef = useRef<HTMLInputElement>(null);

    useImperativeHandle(
      ref,
      () => ({
        focus: () => inputRef.current?.focus(),
        clear: () => {
          onChange('');
          // 清空后顺手聚焦,符合用户直觉
          inputRef.current?.focus();
        },
      }),
      [onChange] // onChange 来自闭包,必须进依赖
    );

    const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
      onChange(e.target.value);
    };

    return (
      <input
        ref={inputRef}
        value={value}
        onChange={handleChange}
        placeholder={placeholder}
        disabled={disabled}
      />
    );
  }
);

使用者侧:

tsx
function Demo() {
  const inputRef = useRef<SmartInputHandle>(null);
  const [text, setText] = useState('');

  return (
    <>
      <SmartInput ref={inputRef} value={text} onChange={setText} />

      <button onClick={() => inputRef.current?.focus()}>聚焦</button>
      <button onClick={() => inputRef.current?.clear()}>清空</button>
    </>
  );
}

这个设计有几个值得说道的地方:

  • clear() 同时调用了 onChange('')。注意我们把「清空」定义成了业务语义(重置受控值 + 聚焦),而不是 DOM 操作(input.value = '')。这就是受控 API 的价值:调用方一句 clear() 拿到的是「符合直觉的行为」,而不是「对 DOM 的低级操作」。如果只是裸转发 DOM,调用方得自己写「清 value、同步 state、再 focus」三步,还容易漏。
  • 依赖数组写了 [onChange]。clear 闭包里用了 onChange,如果调用方每次渲染都传新函数(很常见),不写依赖会拿到旧的 onChange,清空后 state 不更新。这是 useImperativeHandle 最隐蔽的坑。
  • 内部用 useRef + useImperativeHandle 双 ref。一个 ref 挂 DOM(私有的 inputRef),一个 ref 暴露 API(外部传入的 ref)。两者职责分离,互不污染。

如果只面向 React 19,可以去掉 forwardRef:

tsx
type SmartInputProps = {
  value: string;
  onChange: (value: string) => void;
  placeholder?: string;
  disabled?: boolean;
  ref?: React.Ref<SmartInputHandle>;
};

export function SmartInput({ value, onChange, placeholder, disabled, ref }: SmartInputProps) {
  const inputRef = useRef<HTMLInputElement>(null);

  useImperativeHandle(ref, () => ({
    focus: () => inputRef.current?.focus(),
    clear: () => {
      onChange('');
      inputRef.current?.focus();
    },
  }), [onChange]);

  // …其余 JSX 不变
}

效果完全一致,少一层包装。

七、陷阱:render 阶段不要读写 ref.current

这是 ref 相关 bug 里最高频的一类,也是面试时能筛掉一大批人的考点。

铁律:ref.current 的赋值发生在 commit 阶段,不是 render 阶段。在组件函数体(render)里读写 ref.current 是不安全的。

反面教材:

tsx
function Bad() {
  const countRef = useRef(0);
  countRef.current += 1; // ❌ 在 render 阶段改 ref

  return <div>渲染次数:{countRef.current}</div>;
}

这段代码看起来能跑,实际上有两个严重问题:

  1. StrictMode 下会双调用。React 的开发模式会故意把 render 调用两次来帮你发现副作用,countRef.current 会 +2 而不是 +1,数字对不上。
  2. render 之间没有隔离。React 可以在「不 commit」的情况下调用你的 render(并发特性下可能丢弃某次渲染结果)。在 render 里改 ref,等于在一个可能被丢弃的渲染里留下了副作用——下一次 commit 时 ref 的值和你以为的不一致。

更隐蔽的版本——在 render 里读 ref 期望拿到「这次挂载后的节点」:

tsx
function AlsoBad() {
  const ref = useRef<HTMLDivElement>(null);

  // ❌ render 阶段 ref.current 还是上一次的值(首次渲染时是 null)
  const width = ref.current?.getBoundingClientRect().width ?? 0;

  return <div ref={ref}>宽度 {width}</div>;
}

首次渲染时 ref.current 必然是 null——因为 commit 还没发生。你读到的是个过期值,而且因为读 ref 不触发渲染,就算拿到值界面也不会更新。

正确做法:

  • 需要副作用(订阅、测量、改 ref)——放进 useEffect 或 useLayoutEffect。
  • 需要一个「驱动渲染的值」——用 useState,别用 ref。
  • 需要在 render 里算派生值——基于 props/state 算,不要读 ref。
tsx
function Good() {
  const ref = useRef<HTMLDivElement>(null);
  const [width, setWidth] = useState(0);

  useLayoutEffect(() => {
    // ✅ commit 之后,DOM 已就位,测量准确
    if (ref.current) {
      setWidth(ref.current.getBoundingClientRect().width);
    }
  }, []);

  return <div ref={ref}>宽度 {width}</div>;
}

一句话记忆:render 是纯函数,它不能产生任何可观测的副作用;ref 的读写都是副作用,所以不属于 render。把这条刻进脑子,能少踩一半的 ref 坑。

另一个相关的小陷阱:别在 render 里判断 ref.current 是否存在来决定渲染什么。比如 if (ref.current) return <A/>; return <B/>; 这种写法,因为 ref 在 render 时是上一次的值,会导致界面与实际 DOM 错位。如果要根据「是否挂载」渲染不同内容,用 useState 维护一个 mounted 标志,在 useEffect 里 setMounted(true)。

小结

  • useRef 有两副面孔:引用 DOM 元素,或当「不触发重渲染的可变容器」。判别标准——要不要驱动 UI?要就用 useState,不要就用 useRef。
  • forwardRef 解决「把 ref 传给自定义组件」:任何渲染单一宿主元素的组件都应支持 ref 转发,否则它就是个黑盒。React 19 起 ref 可作为普通 prop 直接传入,forwardRef 不再是必需(仍兼容,但已不推荐)。
  • useImperativeHandle 用来定义组件对外契约:暴露 focus/clear/open 这类受控方法,而不是裸 DOM。承诺越窄,重构越自由;记得把闭包里用到的 props 放进依赖数组。
  • 函数 ref 适合「拿到节点的那一瞬间」做事:测量、注册第三方库。React 19 起支持返回 cleanup,setup 与 teardown 终于能写在一起。
  • render 阶段绝不读写 ref.current:ref 在 commit 阶段赋值,render 期间读写拿到的是过期值,还会被 StrictMode 双调用放大。render 是纯函数,ref 的读写都是副作用——副作用的归宿是 useEffect。

金句:ref 不是「另一个 state」,它是「组件逃出 React 渲染周期的一根管子」。管子该不该接、接出去暴露什么,就是组件 API 设计的全部功夫。

Related

相关推荐