组件库架构设计实践:构建可扩展的企业级 UI 组件库
构建一个成功的组件库不仅需要优秀的组件设计,更需要合理的工程架构。本文将从项目结构、构建配置到发布流程,详细讲解如何搭建一个可扩展、易维护的企业级组件库。
项目结构设计
Monorepo 架构
现代组件库普遍采用 Monorepo 架构,将多个相关包统一管理:
my-ui/
├── packages/
│ ├── core/ # 核心组件库
│ │ ├── src/
│ │ │ ├── components/ # 组件源码
│ │ │ ├── hooks/ # 公共 Hooks
│ │ │ ├── utils/ # 工具函数
│ │ │ └── index.ts # 入口文件
│ │ ├── package.json
│ │ └── tsconfig.json
│ │
│ ├── icons/ # 图标库
│ │ ├── src/
│ │ │ ├── icons/ # SVG 图标
│ │ │ └── index.ts
│ │ └── package.json
│ │
│ ├── themes/ # 主题包
│ │ ├── src/
│ │ │ ├── default/ # 默认主题
│ │ │ └── dark/ # 暗色主题
│ │ └── package.json
│ │
│ └── utils/ # 通用工具库
│ ├── src/
│ └── package.json
│
├── apps/
│ ├── docs/ # 文档站点
│ └── playground/ # 开发测试环境
│
├── scripts/ # 构建脚本
├── .changeset/ # 变更记录
├── pnpm-workspace.yaml # Workspace 配置
├── turbo.json # Turborepo 配置
└── package.json
Workspace 配置
# pnpm-workspace.yaml
packages:
- 'packages/*'
- 'apps/*'
// package.json (根目录)
{
"name": "my-ui-monorepo",
"private": true,
"scripts": {
"dev": "turbo run dev",
"build": "turbo run build",
"test": "turbo run test",
"lint": "turbo run lint",
"clean": "turbo run clean && rm -rf node_modules",
"changeset": "changeset",
"version": "changeset version",
"release": "turbo run build && changeset publish"
},
"devDependencies": {
"@changesets/cli": "^2.27.0",
"turbo": "^1.11.0",
"typescript": "^5.3.0"
},
"packageManager": "pnpm@8.12.0",
"engines": {
"node": ">=18.0.0",
"pnpm": ">=8.0.0"
}
}
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"test": {
"dependsOn": ["build"],
"outputs": ["coverage/**"]
},
"lint": {
"outputs": []
},
"clean": {
"cache": false
}
}
}
组件目录结构
单组件结构
packages/core/src/components/Button/
├── Button.tsx # 组件主体
├── Button.types.ts # 类型定义
├── Button.styles.ts # 样式定义
├── Button.test.tsx # 单元测试
├── Button.stories.tsx # Storybook stories
├── ButtonGroup.tsx # 相关组件
├── useButton.ts # 组件专属 Hook
├── context.ts # 组件上下文
└── index.ts # 模块入口
组件入口文件
// Button/index.ts
export { Button } from './Button';
export { ButtonGroup } from './ButtonGroup';
export type { ButtonProps, ButtonVariant, ButtonSize } from './Button.types';
// src/components/index.ts
export * from './Button';
export * from './Input';
export * from './Select';
export * from './Modal';
// ... 其他组件
// src/index.ts - 库入口
export * from './components';
export * from './hooks';
export * from './utils';
类型系统设计
基础类型定义
// types/common.ts
import type { ReactNode, CSSProperties } from 'react';
// 尺寸类型
export type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
// 颜色变体
export type ColorVariant =
| 'primary'
| 'secondary'
| 'success'
| 'warning'
| 'danger'
| 'info';
// 通用组件 Props
export interface BaseProps {
/** 自定义类名 */
className?: string;
/** 自定义样式 */
style?: CSSProperties;
/** 子元素 */
children?: ReactNode;
/** data-testid 用于测试 */
'data-testid'?: string;
}
// 可禁用 Props
export interface DisableableProps {
/** 是否禁用 */
disabled?: boolean;
}
// 可加载 Props
export interface LoadableProps {
/** 是否加载中 */
loading?: boolean;
}
// 表单控件 Props
export interface FormControlProps extends DisableableProps {
/** 表单字段名 */
name?: string;
/** 是否必填 */
required?: boolean;
/** 是否只读 */
readOnly?: boolean;
/** 是否有错误 */
error?: boolean;
/** 错误信息 */
errorMessage?: string;
}
组件 Props 类型
// Button/Button.types.ts
import type {
ButtonHTMLAttributes,
AnchorHTMLAttributes,
ReactNode
} from 'react';
import type {
BaseProps,
Size,
ColorVariant,
DisableableProps,
LoadableProps
} from '../../types';
export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'link';
export type ButtonSize = Exclude<Size, 'xs'>;
// 基础 Button Props
interface ButtonBaseProps extends
BaseProps,
DisableableProps,
LoadableProps {
/** 按钮变体 */
variant?: ButtonVariant;
/** 颜色方案 */
colorScheme?: ColorVariant;
/** 尺寸 */
size?: ButtonSize;
/** 左侧图标 */
leftIcon?: ReactNode;
/** 右侧图标 */
rightIcon?: ReactNode;
/** 是否全宽 */
fullWidth?: boolean;
/** 加载时的文字 */
loadingText?: string;
}
// 作为按钮使用
interface ButtonAsButton extends
ButtonBaseProps,
Omit<ButtonHTMLAttributes<HTMLButtonElement>, keyof ButtonBaseProps> {
as?: 'button';
href?: never;
}
// 作为链接使用
interface ButtonAsAnchor extends
ButtonBaseProps,
Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof ButtonBaseProps> {
as: 'a';
href: string;
}
// 联合类型
export type ButtonProps = ButtonAsButton | ButtonAsAnchor;
泛型组件类型
// Select/Select.types.ts
export interface SelectOption<T = string> {
value: T;
label: string;
disabled?: boolean;
}
export interface SelectProps<T = string> extends BaseProps, FormControlProps {
/** 选项列表 */
options: SelectOption<T>[];
/** 当前值 */
value?: T;
/** 默认值 */
defaultValue?: T;
/** 占位文本 */
placeholder?: string;
/** 值变化回调 */
onChange?: (value: T) => void;
/** 是否可搜索 */
searchable?: boolean;
/** 是否可清除 */
clearable?: boolean;
/** 自定义渲染选项 */
renderOption?: (option: SelectOption<T>) => ReactNode;
}
样式方案
CSS-in-JS vs CSS Modules vs Tailwind
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| CSS-in-JS | 动态样式、完全隔离 | 运行时开销、包体积 | 高度动态化需求 |
| CSS Modules | 零运行时、作用域隔离 | 动态样式不便 | 传统项目迁移 |
| Tailwind CSS | 开发效率高、一致性 | 类名冗长、需要配置 | 快速开发 |
| CVA + Tailwind | 类型安全、可维护 | 学习成本 | 现代组件库 |
推荐方案:CVA + Tailwind
// Button/Button.styles.ts
import { cva, type VariantProps } from 'class-variance-authority';
export const buttonVariants = cva(
// 基础类
[
'inline-flex items-center justify-center',
'font-medium rounded-md',
'transition-colors duration-150',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
'disabled:opacity-50 disabled:cursor-not-allowed',
],
{
variants: {
variant: {
solid: '',
outline: 'bg-transparent border-2',
ghost: 'bg-transparent',
link: 'bg-transparent underline-offset-4 hover:underline',
},
colorScheme: {
primary: '',
secondary: '',
success: '',
warning: '',
danger: '',
},
size: {
sm: 'h-8 px-3 text-sm gap-1.5',
md: 'h-10 px-4 text-sm gap-2',
lg: 'h-12 px-6 text-base gap-2.5',
},
fullWidth: {
true: 'w-full',
false: 'w-auto',
},
},
compoundVariants: [
// Primary + Solid
{
variant: 'solid',
colorScheme: 'primary',
className: 'bg-primary-500 text-white hover:bg-primary-600 active:bg-primary-700',
},
// Primary + Outline
{
variant: 'outline',
colorScheme: 'primary',
className: 'border-primary-500 text-primary-500 hover:bg-primary-50',
},
// Primary + Ghost
{
variant: 'ghost',
colorScheme: 'primary',
className: 'text-primary-500 hover:bg-primary-50',
},
// Danger + Solid
{
variant: 'solid',
colorScheme: 'danger',
className: 'bg-red-500 text-white hover:bg-red-600 active:bg-red-700',
},
// ... 更多组合
],
defaultVariants: {
variant: 'solid',
colorScheme: 'primary',
size: 'md',
fullWidth: false,
},
}
);
export type ButtonVariantProps = VariantProps<typeof buttonVariants>;
组件实现
// Button/Button.tsx
import { forwardRef, type ElementType } from 'react';
import { cn } from '@/utils/cn';
import { Spinner } from '../Spinner';
import { buttonVariants, type ButtonVariantProps } from './Button.styles';
import type { ButtonProps } from './Button.types';
export const Button = forwardRef<
HTMLButtonElement | HTMLAnchorElement,
ButtonProps
>(({
as = 'button',
variant,
colorScheme,
size,
fullWidth,
className,
children,
disabled,
loading,
loadingText,
leftIcon,
rightIcon,
...props
}, ref) => {
const Component = as as ElementType;
const isDisabled = disabled || loading;
return (
<Component
ref={ref}
className={cn(
buttonVariants({ variant, colorScheme, size, fullWidth }),
className
)}
disabled={as === 'button' ? isDisabled : undefined}
aria-disabled={isDisabled}
aria-busy={loading}
{...props}
>
{loading && (
<Spinner
size={size === 'lg' ? 'md' : 'sm'}
className="mr-2"
/>
)}
{!loading && leftIcon && (
<span className="shrink-0">{leftIcon}</span>
)}
<span>
{loading && loadingText ? loadingText : children}
</span>
{!loading && rightIcon && (
<span className="shrink-0">{rightIcon}</span>
)}
</Component>
);
});
Button.displayName = 'Button';
构建配置
使用 tsup 构建
// packages/core/tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: true,
splitting: true,
sourcemap: true,
clean: true,
treeshake: true,
external: ['react', 'react-dom'],
// CSS 处理
injectStyle: false,
// 环境变量
env: {
NODE_ENV: process.env.NODE_ENV || 'production',
},
// 输出文件名
outDir: 'dist',
// 压缩
minify: process.env.NODE_ENV === 'production',
// Banner
banner: {
js: '"use client";', // 支持 React Server Components
},
});
多入口构建
// tsup.config.ts - 按组件拆分入口
import { defineConfig } from 'tsup';
import { readdirSync, statSync } from 'fs';
import { join } from 'path';
// 自动发现组件入口
const componentsDir = './src/components';
const components = readdirSync(componentsDir).filter(name => {
const componentPath = join(componentsDir, name);
return statSync(componentPath).isDirectory();
});
const componentEntries = components.reduce((acc, name) => {
acc[`components/${name}/index`] = `${componentsDir}/${name}/index.ts`;
return acc;
}, {} as Record<string, string>);
export default defineConfig({
entry: {
index: 'src/index.ts',
...componentEntries,
},
format: ['cjs', 'esm'],
dts: true,
splitting: true,
treeshake: true,
external: ['react', 'react-dom'],
});
Package.json 配置
// packages/core/package.json
{
"name": "@my-ui/core",
"version": "1.0.0",
"description": "My UI Component Library",
"sideEffects": ["**/*.css"],
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./components/*": {
"types": "./dist/components/*/index.d.ts",
"import": "./dist/components/*/index.mjs",
"require": "./dist/components/*/index.js"
},
"./styles.css": "./dist/styles.css"
},
"files": ["dist", "README.md"],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint src --ext .ts,.tsx"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"react": "^18.2.0",
"tsup": "^8.0.0",
"typescript": "^5.3.0"
}
}
测试策略
测试金字塔
┌──────────┐
│ E2E 测试 │ ← 少量关键流程
└──────────┘
┌────────────────┐
│ 集成测试 │ ← 组件交互
└────────────────┘
┌───────────────────────┐
│ 单元测试 │ ← 大量覆盖
└───────────────────────┘
单元测试配置
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./test/setup.ts'],
include: ['src/**/*.test.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'test/',
'**/*.stories.tsx',
'**/*.d.ts',
],
},
},
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
});
// test/setup.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
});
组件测试示例
// Button/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { Button } from './Button';
describe('Button', () => {
it('渲染正确的文本', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button')).toHaveTextContent('Click me');
});
it('点击时触发 onClick', () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>Click</Button>);
fireEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('禁用状态下不触发 onClick', () => {
const onClick = vi.fn();
render(<Button onClick={onClick} disabled>Click</Button>);
fireEvent.click(screen.getByRole('button'));
expect(onClick).not.toHaveBeenCalled();
});
it('加载状态显示 Spinner', () => {
render(<Button loading>Submit</Button>);
expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true');
});
it('加载状态显示自定义文本', () => {
render(<Button loading loadingText="Submitting...">Submit</Button>);
expect(screen.getByRole('button')).toHaveTextContent('Submitting...');
});
it('应用正确的样式变体', () => {
const { container } = render(
<Button variant="outline" colorScheme="danger">Delete</Button>
);
const button = container.querySelector('button');
expect(button).toHaveClass('border-2');
});
it('支持 as="a" 渲染链接', () => {
render(<Button as="a" href="/page">Go to page</Button>);
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/page');
});
});
可访问性测试
// Button/Button.a11y.test.tsx
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { describe, it, expect } from 'vitest';
import { Button } from './Button';
expect.extend(toHaveNoViolations);
describe('Button 可访问性', () => {
it('无可访问性违规', async () => {
const { container } = render(<Button>Click me</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('禁用状态可访问', async () => {
const { container } = render(<Button disabled>Disabled</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('图标按钮有 aria-label', async () => {
const { container } = render(
<Button aria-label="Close" leftIcon={<span>×</span>} />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
发布流程
Changesets 配置
// .changeset/config.json
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["@my-ui/docs", "@my-ui/playground"]
}
发布脚本
#!/bin/bash
# scripts/release.sh
set -e
echo "🔍 检查工作区状态..."
git status
echo "📦 构建所有包..."
pnpm build
echo "🧪 运行测试..."
pnpm test
echo "📝 生成版本..."
pnpm changeset version
echo "📤 发布到 npm..."
pnpm changeset publish
echo "🏷️ 推送 tags..."
git push --follow-tags
echo "✅ 发布完成!"
CI/CD 配置
# .github/workflows/release.yml
name: Release
on:
push:
branches:
- main
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build packages
run: pnpm build
- name: Run tests
run: pnpm test
- name: Create Release Pull Request or Publish
uses: changesets/action@v1
with:
publish: pnpm changeset publish
version: pnpm changeset version
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
总结
构建企业级组件库需要:
- Monorepo 架构:统一管理多个相关包
- 清晰的类型系统:TypeScript 保障开发体验
- 灵活的样式方案:CVA + Tailwind 兼顾效率和可维护性
- 高效的构建配置:tsup 实现快速构建
- 完善的测试体系:单元测试 + 可访问性测试
- 规范的发布流程:Changesets 管理版本
这些实践可以帮助团队构建高质量、可维护的组件库。
延伸阅读
- 设计系统建立完整指南 - 设计系统的整体规划
- 组件文档化与 Storybook 实践 - 组件文档最佳实践
- 微前端架构设计 - 组件库在微前端中的应用


