人人都会AI编程

18.4 泛型组件、高阶组件类型封装

更新时间:2026-07-10

在 TypeScript 环境下编写可复用的 React 组件时,有两种模式经常遇到:泛型组件高阶组件。前者允许组件的 Props 类型跟随传入的数据动态变化,后者通过类型封装确保包装逻辑不会破坏被包装组件的类型安全。这一节将结合实战场景给出清晰的写法。

18.4.1 泛型组件

当你需要一个组件能够处理不同类型的数据,同时又不想丢失类型信息时,泛型组件是最佳选择。典型场景包括:

  • 列表组件,渲染不同类型的数据项。
  • 选择器组件(Select/Autocomplete),选项类型由外部传入。
  • 表单控件的 valueonChange 需要类型联动。

在 React 中定义泛型组件的关键是:让组件函数本身携带泛型参数。在 TSX 中,可通过如下方式:

// 定义一个泛型参数的 Props 类型
type ListProps<T> = {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyExtractor: (item: T) => string | number;
};

// 组件函数添加泛型 <T>
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
  return (
    <ul>
      {items.map((item, index) => (
        <li key={keyExtractor(item)}>{renderItem(item, index)}</li>
      ))}
    </ul>
  );
}

使用时,TypeScript 会根据传入的 items 自动推断 T 的类型:

interface User {
  id: number;
  name: string;
}

const users: User[] = [
  { id: 1, name: 'Alice' },
  { id: 2, name: 'Bob' },
];

function App() {
  return (
    <List
      items={users}
      renderItem={(user) => <span>{user.name}</span>}  // user 自动推断为 User
      keyExtractor={(user) => user.id}
    />
  );
}

如果需要显式指定泛型类型(在 TSX 中无法直接写出 <List<User>,因为尖括号会被解析为 JSX),可以借助函数类型断言的方式:

const UserList = List<User>; // 提前绑定泛型
<UserList items={users} ... />

或者在调用组件时使用 as 类型断言,但更推荐通过 items 自动推断,保持代码简洁。

常见泛型组件场景:表单控件

type FieldProps<T> = {
  value: T;
  onChange: (value: T) => void;
};

function InputField<T extends string | number>({ value, onChange }: FieldProps<T>) {
  return (
    <input
      value={value}
      onChange={(e) => onChange(e.target.value as T)}
    />
  );
}

通过 extends 约束泛型上限,避免传入无法处理的类型。

18.4.2 高阶组件类型封装

高阶组件(HOC)本质上是一个接收组件作为参数,返回新组件的函数。TypeScript 最复杂的部分在于:正确映射被包装组件的 Props 类型,同时注入(或移除)某些属性。

基本模式:注入额外 Props

假设我们有一个 withLogging HOC,为组件注入 logEvent 方法,同时透传原有 Props。

import { ComponentType } from 'react';

// 需要注入的 Props
type InjectedProps = {
  logEvent: (message: string) => void;
};

// HOC 函数
function withLogging<P extends InjectedProps>(
  WrappedComponent: ComponentType<P>
) {
  // 返回的组件不需要 InjectedProps,因为它由 HOC 自己提供
  return function WithLogging(props: Omit<P, keyof InjectedProps>) {
    const logEvent = (msg: string) => console.log(`[LOG]: ${msg}`);
    
    // 将注入的 props 与外部传入的 props 合并
    const mergedProps = { ...props, logEvent } as P;

    return <WrappedComponent {...mergedProps} />;
  };
}

使用:

interface ButtonProps {
  label: string;
  onClick: () => void;
  logEvent?: (msg: string) => void; // 由 HOC 注入
}

const Button: React.FC<ButtonProps> = ({ label, onClick, logEvent }) => {
  const handleClick = () => {
    logEvent?.('button clicked');
    onClick();
  };
  return <button onClick={handleClick}>{label}</button>;
};

const EnhancedButton = withLogging(Button);

// 使用时,logEvent 成为可选或自动注入,外部不需要传递
<EnhancedButton label="提交" onClick={() => {}} />

这里的关键点:

  • 使用 Omit<P, keyof InjectedProps> 从外部传入的 Props 中移除 HOC 将自行提供的属性。
  • ComponentType<P> 可以接收函数组件或类组件。
  • 在合并 props 时使用 as P 断言,因为 TypeScript 不能直接推导出合并后的对象满足完整的 P 类型(可选属性的存在可能导致冲突)。更好的做法是显式定义返回组件的 Props:
type WithoutInjected<P> = Omit<P, keyof InjectedProps>;

function withLogging<P extends InjectedProps>(
  WrappedComponent: ComponentType<P>
): React.FC<WithoutInjected<P>> {
  const WithLogging: React.FC<WithoutInjected<P>> = (props) => {
    // ...
  };
  return WithLogging;
}

处理 ref 转发

当 HOC 需要转发 ref 到被包装组件时,需要结合 forwardRef 和正确的类型定义。

import React, { forwardRef, Ref, ComponentType } from 'react';

function withBorder<P extends { className?: string }>(
  WrappedComponent: ComponentType<P>
) {
  // 使用 forwardRef 并指定 ref 类型
  const WithBorder = forwardRef<
    HTMLElement,        // ref 指向的真实 DOM 元素类型(或任意组件实例)
    Omit<P, 'className'> // 外部 Props 不需要 className,因为 HOC 会处理它
  >((props, ref) => {
    return (
      <div className="border-wrapper">
        <WrappedComponent
          {...(props as P)}
          ref={ref}
          className="inner" // 强制覆盖样式
        />
      </div>
    );
  });

  WithBorder.displayName = `WithBorder(${WrappedComponent.displayName || WrappedComponent.name})`;
  return WithBorder;
}

使用时:

interface MyInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  custom: string;
}

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

const BorderedInput = withBorder(MyInput);

<BorderedInput
  ref={inputRef}        // 正确转发
  custom="hello"
  placeholder="输入内容"
/>

常见 HOC 类型封装技巧

| 场景 | 类型处理方式 |
|------|-------------|
| 注入额外 Props | Omit<P, keyof Injected> 告诉使用者不需要传 |
| 移除部分 Props | 使用 Pick 或直接定义返回组件的 Props 接口 |
| 包裹后返回相同 Props | 直接使用 P 作为返回组件 Props 类型 |
| HOC 自带可配置选项 | 柯里化函数,第一层接收配置,第二层接收组件 |

例如,带配置的 HOC:

function withTimer<P>(interval: number) {
  return (WrappedComponent: ComponentType<P>) => {
    return (props: P) => {
      const [tick, setTick] = useState(0);
      useEffect(() => {
        const id = setInterval(() => setTick(t => t + 1), interval);
        return () => clearInterval(id);
      }, []);
      return <WrappedComponent {...props} tick={tick} />;
    };
  };
}

此时注入的 tick 也需要通过泛型约束告知组件接受该属性,写法同理。

总结

  • 泛型组件 让组件支持多类型输入,保持类型推导链完整;TSX 中通过函数泛型参数实现。
  • 高阶组件 的类型封装核心在于使用 ComponentType<P>Omit 和控制 Props 映射,同时结合 forwardRef 保持 ref 转发。
  • 实际开发中,优先使用自定义 Hooks 替代 HOC 以满足“逻辑复用”,但在组件增强(如条件渲染、样式包裹)等场景 HOC 依然有效,掌握其类型写法可大幅提升代码健壮性。