学习TypeScript + React:类型化的通用forwardRefs

2,809 阅读4分钟

如果你在React中创建组件库和设计系统,你可能已经对组件内的DOM元素进行了fowarded Refs。

如果你将基本组件或叶子包裹在代理组件中,但想像你习惯的那样使用ref 属性,这就特别有用。

const Button = React.forwardRef((props, ref) => (
  <button type="button" {...props} ref={ref}>
    {props.children}
  </button>
));

// Usage: You can use your proxy just like you use
// a regular button!
const reference = React.createRef();
<Button className="primary" ref={reference}>Hello</Button>

React.forwardRef 提供类型通常是非常直接的。由@types/react 运送的类型有通用的类型变量,你可以在调用React.forwardRef 时设置。在这种情况下,明确地注解你的类型才是正确的做法!

type ButtonProps = JSX.IntrinsicElements["button"];

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  (props, ref) => (
    <button type="button" {...props} ref={ref}>
      {props.children}
    </button>
  )
);

// Usage
const reference = React.createRef<HTMLButtonElement>();
<Button className="primary" ref={reference}>Hello</Button>

到目前为止,一切都很好。但如果你有一个接受通用属性的组件,事情就有点麻烦了。看看这个组件,它产生一个列表项,你可以用button 元素选择每一行。

type ClickableListProps<T> = {
  items: T[];
  onSelect: (item: T) => void;
};

function ClickableList<T>(props: ClickableListProps<T>) {
  return (
    <ul>
      {props.items.map((item) => (
        <li>
          <button onClick={() => props.onSelect(item)}>
            Choose
          </button>
          {item}
        </li>
      ))}
    </ul>
  );
}

// Usage
const items = [1, 2, 3, 4];
<ClickableList items={items} 
  onSelect={(item) => {
    // item is of type number
    console.log(item)
  } } />

你想要额外的类型安全,所以你可以在你的onSelect 回调中使用类型安全的item 。假设你想为内部的ul 元素创建一个ref ,你该如何进行呢?让我们把ClickableList 组件改成一个内部函数组件,它接收一个ForwardRef ,并把它作为React.forwardRef 函数的参数。

// The original component extended with a `ref`
function ClickableListInner<T>(
  props: ClickableListProps<T>,
  ref: React.ForwardedRef<HTMLUListElement>
) {
  return (
    <ul ref={ref}>
      {props.items.map((item, i) => (
        <li key={i}>
          <button onClick={(el) => props.onSelect(item)}>Select</button>
          {item}
        </li>
      ))}
    </ul>
  );
}

// As an argument in `React.forwardRef`
const ClickableList = React.forwardRef(ClickableListInner)

这样可以编译,但有一个缺点。我们不能为ClickableListProps 指定一个通用类型变量。它默认成为unknown 。与any 相比,这很好,但也有点令人讨厌。当我们使用ClickableList ,我们知道要传递哪些项目!我们想让它们被相应的类型化!那么,我们如何才能实现这一点呢?答案是很棘手的......你有几个选择。

选项1:类型断言#

一种选择是做一个类型断言,恢复原来的函数签名。

const ClickableList = React.forwardRef(ClickableListInner) as <T>(
  props: ClickableListProps<T> & { ref?: React.ForwardedRef<HTMLUListElement> }
) => ReturnType<typeof ClickableListInner>;

类型断言有点让人皱眉头,因为它们看起来类似于其他编程语言中的类型转换。它们有一点不同,Dan很好地解释了原因。类型断言在TypeScript中有自己的位置。通常情况下,我的方法是让TypeScript从我的JavaScript代码中找出它自己能找出的一切。如果它不能,我就用类型注解来帮忙。在我比TypeScript知道得更多的地方,我会做一个类型断言。

这就是其中的一种情况,我知道我的原始组件接受通用道具

选项2:创建一个自定义的 ref / 封装组件#

虽然ref 是React组件的保留词,但你可以使用你自己的、自定义的props来模仿类似的行为。这样做的效果也不错。

type ClickableListProps<T> = {
  items: T[];
  onSelect: (item: T) => void;
  mRef?: React.Ref<HTMLUListElement> | null;
};

export function ClickableList<T>(
  props: ClickableListProps<T>
) {
  return (
    <ul ref={props.mRef}>
      {props.items.map((item, i) => (
        <li key={i}>
          <button onClick={(el) => props.onSelect(item)}>Select</button>
          {item}
        </li>
      ))}
    </ul>
  );
}

不过,你引入了一个新的API。顺便说一下,也有可能使用一个包装组件,它允许你在内部组件中使用forwardRef,并向外部暴露一个自定义的ref属性。这个方法在网上流传,我只是觉得与前面的解决方案相比,没有什么明显的好处

function ClickableListInner<T>(
  props: ClickableListProps<T>,
  ref: React.ForwardedRef<HTMLUListElement>
) {
  return (
    <ul ref={ref}>
      {props.items.map((item, i) => (
        <li key={i}>
          <button onClick={(el) => props.onSelect(item)}>Select</button>
          {item}
        </li>
      ))}
    </ul>
  );
}

const ClickableListWithRef = forwardRef(ClickableListInner);

type ClickableListWithRefProps<T> = ClickableListProps<T> & {
  mRef?: React.Ref<HTMLUListElement>;
};

export function ClickableList<T>({
  mRef,
  ...props
}: ClickableListWithRefProps<T>) {
  return <ClickableListWithRef ref={mRef} {...props} />;
}

如果你想实现的唯一目的是传递引用,那么这两种解决方案都是有效的。如果你想有一个一致的API,你可以寻找其他的东西。

选项3:增强forwardRef#

这实际上是我最喜欢的解决方案。

TypeScript有一个功能叫 高阶函数类型推理的功能,它允许将自由类型的参数传播到外层函数上。

这听起来很像我们一开始想用forwardRef ,但由于某些原因,它在我们目前的类型中不起作用。原因是高阶函数类型推理只对普通函数类型起作用。forwardRef 内的函数声明也为defaultProps ,等等添加了属性。类组件时代的遗留问题。反正你可能不想使用的东西。

所以,如果没有这些额外的属性,应该可以使用高阶函数类型推理

而且,嘿!我们正在使用TypeScript,我们有可能自己重新声明和重新定义全局模块命名空间接口的声明。声明合并是一个强大的工具,我们要利用它。

// Redecalare forwardRef
declare module "react" {
  function forwardRef<T, P = {}>(
    render: (props: P, ref: React.Ref<T>) => React.ReactElement | null
  ): (props: P & React.RefAttributes<T>) => React.ReactElement | null;
}


// Just write your components like you're used to!

type ClickableListProps<T> = {
  items: T[];
  onSelect: (item: T) => void;
};
function ClickableListInner<T>(
  props: ClickableListProps<T>,
  ref: React.ForwardedRef<HTMLUListElement>
) {
  return (
    <ul ref={ref}>
      {props.items.map((item, i) => (
        <li key={i}>
          <button onClick={(el) => props.onSelect(item)}>Select</button>
          {item}
        </li>
      ))}
    </ul>
  );
}

export const ClickableList = React.forwardRef(ClickableListInner);

这个解决方案的好处是,你可以再次编写常规的JavaScript,并且完全在类型层面上工作。另外,重新声明是模块范围的。不会干扰来自其他模块的任何forwardRef