React 类型化包装组件与多态组件

解释 React 包装原生或第三方组件时如何复用 props、透传 ref、推导变体,并处理多态 as 带来的类型耦合。

#type / synthesis #status / growing #tech / dev / frontend #resource / typescript #package / react

[!info] related notes

React 类型化包装组件与多态组件

[!abstract] 文档规格

  • 类型:组件类型设计关系笔记
  • 读者:会定义 Props,但包装 button/input 时仍会手抄 HTML 属性
  • 工具:React 19、TypeScript、Base UI、class-variance-authority
  • 结果:能独立设计一个不丢失原组件能力的包装组件,并判断是否真的需要多态 as

为什么包装组件容易丢失能力

设计系统常在原生元素或第三方 primitive 上增加视觉变体、loading 和错误提示。如果重新手写 props:

interface ButtonProps {
  disabled?: boolean;
  onClick?: () => void;
}

组件会漏掉 typename、键盘事件、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 是什么都允许 hrefhtmlFor,类型只是看起来灵活,实际上失去约束。

优先考虑更简单的替代方案:

  • 只包装一种语义元素。
  • 用第三方 primitive 提供的 render / slot 能力。
  • 为 LinkButton、IconButton 等稳定语义建立独立组件。

只有多个元素确实共享行为和视觉 API 时,才值得承担多态泛型成本。

BodySense 实例

components/ui/Button.tsx

ButtonPrimitive.Props
  + VariantProps<typeof buttonVariants>
  + isLoading
  -> ButtonProps

阅读重点:

  • 原 primitive 的可访问性与事件 props 没有被重写。
  • CVA 推导 variantsize
  • 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。
  • 为追求“万能组件”制造复杂泛型,实际只有一个使用场景。

从阅读到独立实现

  1. 预测:删除 ButtonPrimitive.Props 后,BodySense Button 会失去哪类能力?
  2. 模仿:用 TextareaHTMLAttributes 与 ref 实现一个带错误提示的 Textarea。
  3. 重建:不看 Button 源码,实现一个支持原生 props、loading、variant 的按钮,并写类型错误用例。
  4. 调试:构造一个 props 展开顺序导致 disabled 被覆盖的问题。
  5. 迁移:比较“单一 button”“LinkButton”“泛型 as”三种方案,为实际需求选择最小设计。
创建于 2026/8/1 更新于 2026/8/1