在 TypeScript 环境下编写可复用的 React 组件时,有两种模式经常遇到:泛型组件和高阶组件。前者允许组件的 Props 类型跟随传入的数据动态变化,后者通过类型封装确保包装逻辑不会破坏被包装组件的类型安全。这一节将结合实战场景给出清晰的写法。
18.4.1 泛型组件
当你需要一个组件能够处理不同类型的数据,同时又不想丢失类型信息时,泛型组件是最佳选择。典型场景包括:
- 列表组件,渲染不同类型的数据项。
- 选择器组件(Select/Autocomplete),选项类型由外部传入。
- 表单控件的
value和onChange需要类型联动。
在 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 依然有效,掌握其类型写法可大幅提升代码健壮性。