组件库架构设计实践:构建可扩展的企业级 UI 组件库

HTMLPAGE 团队
18 分钟阅读

全面讲解企业级组件库的架构设计,涵盖 Monorepo 管理、组件分层、样式方案、打包策略、测试体系等核心环节,帮助团队构建高质量的组件库。

#组件库 #前端架构 #Monorepo #工程化 #TypeScript

组件库架构设计实践:构建可扩展的企业级 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 }}

总结

构建企业级组件库需要:

  1. Monorepo 架构:统一管理多个相关包
  2. 清晰的类型系统:TypeScript 保障开发体验
  3. 灵活的样式方案:CVA + Tailwind 兼顾效率和可维护性
  4. 高效的构建配置:tsup 实现快速构建
  5. 完善的测试体系:单元测试 + 可访问性测试
  6. 规范的发布流程:Changesets 管理版本

这些实践可以帮助团队构建高质量、可维护的组件库。

延伸阅读