前端开发··2 阅读·预计 11 分钟

React forwardRef 的类型困境:用 TypeScript 泛型重载实现零断言的 Ref 转发

前言

forwardRef 是 React 组件封装中使用频率极高的 API,但它的类型定义从诞生起就充满妥协:

// 这段代码在 TypeScript 下报错,但运行时完全正确
const Input = forwardRef<HTMLInputElement, { label: string }>(
  (props, ref) => <input ref={ref} placeholder={props.label} />
);

本文不重复官方文档,而是聚焦真实场景中的三个类型困境及其解决方案。

一、困境:forwardRef 泛型参数位置的认知错位

先说清楚类型参数的真实含义:

forwardRef<RefType, PropsType>((props, ref) => JSX)
//         ^^^^^^^  ^^^^^^^^^
//         给 ref 用的  给 props 用的
//         注意:顺序与参数列表 (props, ref) 相反!

最常见的错误是把 RefType 和 PropsType 顺序搞反,或者直接传 any 逃逸:

// ❌ 错误 1:泛型顺序反了
const Input = forwardRef<{ value: string }, HTMLInputElement>(
  (props, ref) => <input ref={ref} value={props.value} />
  // 报错:ref 类型不匹配,props.value 不存在于 HTMLInputElement
);

// ❌ 错误 2:any 逃逸,前功尽弃
const AnyInput = forwardRef<any, any>(
  (props, ref) => <input ref={ref} />
);

正确写法:

// ✅ 第一泛型 = ref 指向的 DOM 类型,第二泛型 = Props
const Input = forwardRef<HTMLInputElement, { value: string; onChange: (v: string) => void }>(
  (props, ref) => <input ref={ref} value={props.value} onChange={e => props.onChange(e.target.value)} />
);

二、进阶:useImperativeHandle 的类型约束

useImperativeHandle 允许向父组件暴露自定义方法,但默认情况下暴露的内容没有任何类型校验:

// ❌ focus 拼写错误,但 TypeScript 不报错
const CustomInput = forwardRef<HTMLInputElement, {}>((props, ref) => {
  useImperativeHandle(ref, () => ({
    fcous: () => {},  // typo,编译器沉默
    validate: () => true,
  }));
  return <input />;
});

解决方案:定义一个独立的 Ref 接口,让 useImperativeHandle 和调用方共享同一份类型契约:

// ✅ 显式定义 Ref 接口,双向约束
interface CustomInputRef {
  focus: () => void;
  validate: () => boolean;
  reset: () => void;
}

const CustomInput = forwardRef<CustomInputRef, { label: string }>((props, ref) => {
  const inputRef = useRef<HTMLInputElement>(null);

  useImperativeHandle(ref, () => ({
    focus: () => inputRef.current?.focus(),
    validate: () => {
      if (!inputRef.current?.value) { alert('必填'); return false; }
      return true;
    },
    reset: () => { if (inputRef.current) inputRef.current.value = ''; },
  }));

  return <input ref={inputRef} placeholder={props.label} />;
});

// 调用方获得完整的类型提示
const Parent = () => {
  const ref = useRef<CustomInputRef>(null);
  // ref.current.focus()     ✅ 有提示
  // ref.current.validate()  ✅ 有提示
  return <CustomInput ref={ref} label="用户名" />;
};

关键点:forwardRef 的第一个泛型参数不再是 HTMLInputElement,而是自定义接口 CustomInputRef。父组件拿到的是接口定义的方法,内部实现被封装。

三、高阶:泛型 forwardRef 组件的类型工厂

当你需要封装一个接收泛型 Props 的 forwardRef 组件时,类型写法会变得非常诡异:

// ❌ 这段代码无法编译——泛型组件参数不能直接传给 forwardRef
function Select<T extends { id: number; label: string }>(
  props: { options: T[]; value: T | null; onChange: (v: T) => void },
  ref: React.Ref<HTMLDivElement>
) {
  return <div ref={ref}>{props.options.map(o => <div key={o.id}>{o.label}</div>)}</div>;
}
export default forwardRef(Select);
// 类型被擦除:forwardRef 后的 Select 丢失了泛型 T

这个问题困扰了大量开发者。解决方案是用类型断言 + 函数签名重载:

// ✅ 类型工厂模式:保留 forwardRef 的转发能力 + 组件自身的泛型
interface SelectProps<T> {
  options: T[];
  value: T | null;
  onChange: (value: T) => void;
  renderLabel?: (item: T) => string;
}

interface SelectRef {
  scrollToIndex: (index: number) => void;
  getSelectedIndex: () => number;
}

// 内部实现——用泛型函数
function SelectInner<T extends { id: number }>(
  props: SelectProps<T>,
  ref: React.ForwardedRef<SelectRef>
) {
  const containerRef = useRef<HTMLDivElement>(null);

  useImperativeHandle(ref, () => ({
    scrollToIndex: (idx) => {
      const child = containerRef.current?.children[idx] as HTMLElement | undefined;
      child?.scrollIntoView({ behavior: 'smooth' });
    },
    getSelectedIndex: () => props.options.findIndex(o => o.id === props.value?.id),
  }));

  return (
    <div ref={containerRef}>
      {props.options.map(o => (
        <div key={o.id} onClick={() => props.onChange(o)}>
          {props.renderLabel?.(o) ?? String(o.id)}
        </div>
      ))}
    </div>
  );
}

// 类型断言——将泛型函数强制转为 forwardRef 签名
export const Select = forwardRef(SelectInner) as <T extends { id: number }>(
  props: SelectProps<T> & { ref?: React.ForwardedRef<SelectRef> }
) => ReturnType<typeof SelectInner<T>>;

// 调用时泛型 T 被正确推导
const Demo = () => {
  const ref = useRef<SelectRef>(null);
  const [value, setValue] = useState<{ id: number; name: string } | null>(null);

  return (
    <Select
      ref={ref}
      options={[{ id: 1, name: 'A' }, { id: 2, name: 'B' }]}
      value={value}
      onChange={setValue}
      renderLabel={item => item.name}
      // ^ T 被推导为 { id: number; name: string }
    />
  );
};

这个模式的核心是 as 断言——你手动告诉 TypeScript "这个 wrapper 保留了内部函数的泛型签名"。虽然多了一行 as,但换来了调用侧完整的类型推导链。

四、不推荐的替代方案

有些文章建议绕过 forwardRef,通过 ref 作为普通 Props 传递:

// ⚠️ Hack 方案:ref 作为普通 prop 传递
interface Props {
  inputRef: React.RefObject<HTMLInputElement>;  // 非标准命名
  label: string;
}
const HackInput = ({ inputRef, label }: Props) => <input ref={inputRef} placeholder={label} />;

这确实避开了 forwardRef 的类型问题,但代价是:破坏了 React DevTools 中的组件树展示、与第三方库(如 react-hook-form)的 ref 传递机制不兼容、在 StrictMode 下可能出现双重挂载问题。不要这样做。

总结

场景方案
基础 ref 转发正确填写 forwardRef<RefType, PropsType> 泛型顺序
暴露自定义方法用接口约束 useImperativeHandle,同步约束调用方 useRef
泛型 Props 组件内层泛型函数 + as 类型工厂保留推导链
ref 作为普通 prop不推荐,用 forwardRef

forwardRef 的类型问题本质上不是 TypeScript 的缺陷,而是 React 类型定义为了向后兼容做出的妥协。理解这些边界后,用类型工厂方案可以一劳永逸地解决所有泛型 ref 转发的类型推导问题。

0 评论

评论区

登录 后参与评论