TypeScript 5.9 终极实战:构建一个类型安全的 UI 组件库 (含多态组件实现)

📝 引言

📌 场景/痛点

你一定用过 Ant Design 或 Material UI,但你有没有想过它们是怎么做到的?

比如一个 <Button> 组件:

  1. 它可以渲染成 <a> 标签,也可以渲染成 <button> 标签(这叫 多态组件 / Polymorphic Component)。
  2. 当它变成 <a> 时,必须有 href 属性;变成 <button> 时,必须有 type 属性。TypeScript 怎么知道当前应该校验哪个?
  3. 再比如一个 <Table>,传入 User[] 数据,列定义里就只能选 User 的字段,不能乱选。

如果用普通的 anyReact.FC 写,这简直是噩梦。但用上 TS 5.9 的高级特性,我们可以写出既灵活又类型严密的顶级组件库。

✨ 最终效果

掌握本文后,你将能写出一个通用的 <Box> 组件,它能变成任何 HTML 标签,并且拥有完美的类型提示。

📖 内容概览

本系列收官之作,我们将综合演练:

  1. 泛型组件:让组件支持动态数据类型。
  2. 多态组件 :实现 as 属性,让组件"变身"。
  3. 属性智能合并:解决自定义属性与原生属性冲突的问题。
  4. 系列总结:回顾我们的 20 篇文章进阶之路。

🛠️ 正文

1. 环境准备

使用 React + TypeScript (Vite)。

npm create vite@latest my-lib -- --template react-ts

2. 实战一:泛型数据组件

假设我们要写一个通用的 <Select> 下拉框组件。

tsx 复制代码
// components/Select.tsx

// 1. 定义泛型 T,代表选项的数据类型
interface SelectProps<T> {
    options: T[];
    // labelKey 和 valueKey 必须是 T 的 key
    labelKey: keyof T; 
    valueKey: keyof T;
    value: T[keyof T];
    onChange: (value: T[keyof T]) => void;
}

export function Select<T extends object>({ 
    options, 
    labelKey, 
    valueKey, 
    value, 
    onChange 
}: SelectProps<T>) {
    return (
        <select 
            value={String(value)} 
            onChange={(e) => onChange(e.target.value as T[keyof T])}
        >
            {options.map((opt, idx) => (
                <option key={idx} value={String(opt[valueKey])}>
                    {/* ✅ 类型安全:opt[labelKey] 必定存在 */}
                    {String(opt[labelKey])} 
                </option>
            ))}
        </select>
    );
}

// 使用
interface User {
    id: number;
    name: string;
}

const users: User[] = [{ id: 1, name: "Alice" }];

// ✅ IDE 会提示 labelKey 只能是 'id' | 'name'
<Select 
    options={users} 
    labelKey="name" 
    valueKey="id" 
    value={1} 
    onChange={(v) => console.log(v)} // v 的类型自动推导为 number | string
/>

3. 实战二:多态组件 (Polymorphic Component)

这是难点中的难点。我们要实现一个 <Box> 组件,它默认是 div,但可以通过 as 属性变成 buttonasection 等。

3.1 核心类型定义

我们需要利用条件类型React.ElementType 来实现类型分发。

tsx 复制代码
// components/Box.tsx
import React from 'react';

// 1. 定义自己的属性
type BoxOwnProps<E extends React.ElementType> = {
    as?: E; // 允许用户指定标签
    children?: React.ReactNode;
};

// 2. 提取原生标签的属性 (如 button 的 type, href)
type NativeProps<E extends React.ElementType> = React.ComponentPropsWithoutRef<E>;

// 3. 智能合并属性:如果自定义属性和原生属性重名(如 'as'),优先使用自定义的
// 这里使用了 Omit 排除掉冲突,再取并集
type BoxProps<E extends React.ElementType> = BoxOwnProps<E> & 
    Omit<NativeProps<E>, keyof BoxOwnProps<E>>;

// 4. 组件实现
export const Box = <E extends React.ElementType = 'div'>({ 
    as, 
    children, 
    ...rest 
}: BoxProps<E>) => {
    // 如果没传 as,默认 'div'
    const Component = as || 'div';

    // ⚠️ 注意:这里使用 as any 是为了绕过 React 对 spread rest 属性的严格检查
    // 在实际的高级库源码中,会用更复杂的类型断言,但这里为了可读性简化
    return <Component {...(rest as any)}>{children}</Component>;
};
3.2 使用多态组件
tsx 复制代码
import { Box } from './Box';

// 场景 1:作为 div 使用
<Box className="p-4">Hello</Box> 
// ✅ className 有效

// 场景 2:作为 button 使用
// ✅ 自动获得 onClick 类型提示
<Box as="button" onClick={() => alert('Hi')}>Click Me</Box>

// 场景 3:作为 a 标签使用
// ✅ 自动获得 href 类型提示,且如果不写 href 会报错(如果 html 要求的话)
<Box as="a" href="/home">Go Home</Box>

// 场景 4:作为自定义组件使用
// 甚至可以传 React 组件
<Box as={MyCustomLink}>Custom</Box>

4. 进阶技巧:使用 satisfies 定义 Props

在定义复杂的 Props 时,使用 satisfies 可以保持代码整洁。

tsx 复制代码
type ButtonVariant = 'primary' | 'secondary';

const buttonStyles = {
    primary: 'bg-blue-500 text-white',
    secondary: 'bg-gray-200 text-black'
} satisfies Record<ButtonVariant, string>;

// ✅ 如果写错类型,TS 会直接报错
// const styles = { outline: "bg-gray-100" } satisfies Record<ButtonVariant, string>;

❓ 常见问题

Q1: 为什么我用了 as 属性,TS 还是报错 Property 'href' does not exist on type...

A: 这通常是因为 Omit 没写对,或者你给 rest 属性的类型推断失败了。确保你的 BoxProps 正确地继承了 React.ComponentProps<E>。在极其复杂的场景下,可以使用 type-fest 库中的 OmitDistributePolymorphicComponentProps 类型。

Q2: 多态组件会影响性能吗?

A: TypeScript 的泛型类型只在编译时 存在。最终编译出来的 JS 代码中,<Box> 只是一个普通的组件,as 只是一个传递给组件的 props。所以零运行时开销

Q3: 泛型组件可以在 Vue 3 中使用吗?

A: 完全可以!Vue 3 的 <script setup lang="ts"> 支持 defineGenericComponent (在 3.3+),原理和 React 类似,都是利用泛型参数 T 来约束 Props。


🎯 总结:写在最后

恭喜你!从第一篇的 hello world 到这一篇的多态组件,你已经完成了从 TypeScript 小白到高手的蜕变。

在这 20 篇文章中,我们一起经历了:

  1. 筑基 :环境搭建、基础类型、字面量与 as const
  2. 进阶:类型守卫、联合/交叉类型、映射类型、模板字面量。
  3. 精通infersatisfiesNoInfer、工具类型。
  4. 架构:模块化、声明文件、性能优化、标准装饰器。
  5. 全栈与前沿:Prisma 共享、Node.js ESM、品牌类型、UI 组件库。

TypeScript 不仅仅是类型的补丁,它是一种思维方式。

它教会我们在写代码之前先思考结构,在运行代码之前先预判错误。在 2026 年及未来,掌握这种思维,是你作为软件工程师最核心的竞争力。

🎉 系列完结

感谢你的陪伴与坚持。技术之路漫漫,愿 TypeScript 成为你手中最锋利的剑。

如果这个系列对你有帮助,请点赞👍、收藏⭐、关注👀,并分享给身边正在学习 TS 的朋友!我们未来再见!

相关推荐
We་ct3 小时前
LeetCode 1. 两数之和:两种高效解法(双指针 + Map)
前端·算法·leetcode·typescript·哈希算法
Betelgeuse764 小时前
【Flutter For OpenHarmony】 阶段复盘:从单页Demo到模块化App
flutter·ui·华为·交互·harmonyos
止观止4 小时前
拒绝“都是 string”:品牌类型与领域驱动设计 (DDD)
前端·typescript
We་ct4 小时前
LeetCode 202. 快乐数:题解+思路拆解
前端·算法·leetcode·typescript
weixin_4255437314 小时前
TRAE CN3.3.25 构建的Electron简易DEMO应用
前端·typescript·electron·vite·nestjs
踏过山河,踏过海15 小时前
【用ui文件做个简单工具的开发,为什么修改完ui后,程序重新编译运行后,GUI界面还是不变呢?】
qt·ui
John_ToDebug19 小时前
WebContent 与 WebView:深入解析浏览器渲染架构的双层设计
c++·chrome·ui
小小测试开发21 小时前
UI自动化测试:CSS定位方式超详细解析(附实战示例)
css·ui·tensorflow
止观止1 天前
深入理解 interface vs type:终结之争
前端·typescript