React 类型化包装组件与多态组件
解释 React 包装原生或第三方组件时如何复用 props、透传 ref、推导变体,并处理多态 as 带来的类型耦合。
[!info] related notes
- 所属 MOC:React MOC
- 基础类型:React 中的 TypeScript 组件与 Hook 类型
- 组件设计:React 组件设计原则
- 项目入口:BodySense 项目 MOC
React 类型化包装组件与多态组件
[!abstract] 文档规格
- 类型:组件类型设计关系笔记
- 读者:会定义 Props,但包装 button/input 时仍会手抄 HTML 属性
- 工具:React 19、TypeScript、Base UI、class-variance-authority
- 结果:能独立设计一个不丢失原组件能力的包装组件,并判断是否真的需要多态
as
为什么包装组件容易丢失能力
设计系统常在原生元素或第三方 primitive 上增加视觉变体、loading 和错误提示。如果重新手写 props:
interface ButtonProps {
disabled?: boolean;
onClick?: () => void;
}
组件会漏掉 type、name、键盘事件、aria-*、表单行为以及库未来新增的属性。更稳定的做法是从被包装对象推导已有契约,再添加自己的业务 props。
三种常见 Props 来源
包装原生元素
type NativeButtonProps = React.ComponentProps<'button'>;
也可使用 ButtonHTMLAttributes<HTMLButtonElement>、InputHTMLAttributes<HTMLInputElement> 等专用类型。ComponentProps<'button'> 更接近“JSX 中这个元素能接收什么”。
包装第三方组件
type PrimitiveProps = React.ComponentProps<typeof PrimitiveButton>;
如果库公开稳定的 PrimitiveButton.Props,优先遵循库的公共 API。不要从内部文件路径导入未承诺稳定的类型。
添加自己的能力
interface LoadingProps {
isLoading?: boolean;
}
type ButtonProps = PrimitiveProps & LoadingProps;
当新增字段与原 props 冲突且语义不同,先 Omit<PrimitiveProps, 'size'> 再定义新的 size,不要依赖交叉类型产生难懂的 never。
透传 Props 时谁拥有最终值
function Button({ isLoading, disabled, ...props }: ButtonProps) {
return (
<PrimitiveButton
disabled={disabled || isLoading}
{...props}
/>
);
}
属性展开顺序决定覆盖权。这里 disabled 已从 props 中取出,所以剩余 props 不能再次覆盖它。若把 {...props} 放在显式属性之后且该字段仍留在 props 中,调用者可能覆盖组件不变量。
包装组件必须明确:哪些属性由调用者控制,哪些由组件根据 loading、权限或校验状态派生。
VariantProps 把配置变成联合类型
class-variance-authority 的 VariantProps<typeof variants> 会从变体配置推导可选值:
cva 配置中的 variant / size 键
-> typeof buttonVariants
-> VariantProps<...>
-> Button props 自动获得字面量联合
新增或删除视觉变体时,调用点会同步获得补全和错误提示。它适合视觉 API,但不能替代组件的可访问性、DOM 行为和业务不变量。
ref 是对底层实例的能力承诺
BodySense 的 Input.tsx 使用:
InputHTMLAttributes<HTMLInputElement>
+ forwardRef<HTMLInputElement, InputProps>
-> ref.current 是 HTMLInputElement
调用者因此可以 focus、选择文本或与表单库集成。若包装组件可能渲染不同元素,ref 类型也会随元素变化,这会显著提高泛型复杂度。
React 19 支持把 ref 作为 prop 的新写法,但现有代码库可能仍采用 forwardRef。迁移与否应跟随项目版本、第三方库兼容性和团队约定,不必为了新 API 机械重写。
多态 as 的类型关系
多态组件允许同一个视觉组件渲染为不同元素:
<Text as="label" htmlFor="email" />
<Text as="a" href="/profile" />
正确类型必须让 as 决定其余 props:
E(元素类型)
-> ComponentProps<E>
-> 删除与自定义 Props 冲突的键
-> 加入 { as?: E }
这不是简单加一个 as?: ElementType。如果无论 as 是什么都允许 href、htmlFor,类型只是看起来灵活,实际上失去约束。
优先考虑更简单的替代方案:
- 只包装一种语义元素。
- 用第三方 primitive 提供的 render / slot 能力。
- 为 LinkButton、IconButton 等稳定语义建立独立组件。
只有多个元素确实共享行为和视觉 API 时,才值得承担多态泛型成本。
BodySense 实例
components/ui/Button.tsx
ButtonPrimitive.Props
+ VariantProps<typeof buttonVariants>
+ isLoading
-> ButtonProps
阅读重点:
- 原 primitive 的可访问性与事件 props 没有被重写。
- CVA 推导
variant与size。 isLoading同时影响图标与 disabled。className进入cn合并,而不是粗暴覆盖。
components/ui/Input.tsx
InputHTMLAttributes<HTMLInputElement>
+ label / error
+ HTMLInputElement ref
-> InputProps
它展示了原生 props 继承与 ref;同时也暴露了设计问题:label、error 和 input 被一个组件绑定后,是否仍满足表单布局、错误关联和可访问性需求,需要用行为测试验证。
常见错误
- 手抄一小部分 HTML 属性,导致 aria、表单和事件能力缺失。
- 交叉两个同名但不兼容的 props,得到难以理解的类型。
{...props}的位置让调用者覆盖组件不变量。- 用
as ElementType声称多态,却没有让元素类型约束其余 props。 - ref 指向包装层 div,而调用者以为能拿到 input/button。
- 为追求“万能组件”制造复杂泛型,实际只有一个使用场景。
从阅读到独立实现
- 预测:删除
ButtonPrimitive.Props后,BodySense Button 会失去哪类能力? - 模仿:用
TextareaHTMLAttributes与 ref 实现一个带错误提示的 Textarea。 - 重建:不看 Button 源码,实现一个支持原生 props、loading、variant 的按钮,并写类型错误用例。
- 调试:构造一个 props 展开顺序导致
disabled被覆盖的问题。 - 迁移:比较“单一 button”“LinkButton”“泛型 as”三种方案,为实际需求选择最小设计。