Skip to content

从零设计企业级 UI 组件库

很多团队做 UI 库失败,不是 Button 不够漂亮,而是没有统一的设计思想和工程化体系,最后组件越来越多,越来越难维护。一个真正的企业级 UI 组件库,需要从设计、开发、测试、文档到发布的全流程最佳实践。


一、 第一阶段:明确定位(最重要)

在动手写代码之前,先明确以下问题,这决定了 UI 库的技术选型和架构设计。

1. 目标用户与应用场景

首先要回答:你的 UI 库是给谁用的?

典型场景分类:

场景核心需求重点组件
ERP/CRM 系统复杂表单、大数据表格Table、Form、Tree、Dialog
后台管理系统数据展示、快速筛选Table、SearchForm、Pagination
IoT/大屏展示可视化、实时数据Dashboard、Card、图表组件
移动端 H5触摸友好、轻量化Cell、PullRefresh、Swipe
低代码平台可配置、可视化编辑Designer、SchemaForm

关键决策:

  • 是否需要支持多个框架(Vue3 + React)?
  • 是否需要支持移动端?
  • 是否需要支持暗色主题?

2. 技术栈选型

技术栈选型需要考虑:团队技术栈、生态成熟度、长期维护成本。

推荐方案:

React 生态(重点)

text
框架:React 18+
语言:TypeScript 5.0+
构建:Vite 5.0+
样式:CSS-in-JS (emotion) / Tailwind CSS
测试:Vitest + React Testing Library
文档:Docusaurus

Vue3 生态

text
框架:Vue 3.4+
语言:TypeScript 5.0+
构建:Vite 5.0+
样式:SCSS + CSS Variables
测试:Vitest + Vue Test Utils
文档:VitePress

多框架支持方案:

  • 使用 Web Components (Lit)
  • 使用 Headless UI 模式 + 框架适配器
  • 使用统一的 Design Token + 各框架独立实现

3. 浏览器支持策略

根据目标用户群体决定浏览器支持范围,这直接影响技术选型。

现代浏览器方案(推荐):

text
Chrome >= 112
Edge >= 112
Firefox >= 110
Safari >= 16.4

需要兼容的场景:

  • IE 11:使用 Vue 2 + Babel + Polyfill(不推荐新库)
  • 旧版浏览器:使用 core-js + browserslist

二、 第二阶段:设计系统(Design System)

真正的大厂 UI 库,第一件事不是写 Button,而是建立完整的设计系统

1. Design Token 体系

Design Token 是设计系统的基石,它将设计语言转化为可复用的数据。

Token 层级结构:

typescript
// tokens/colors.ts
export const colors = {
  // 基础色板
  primary: {
    50: '#e6f7ff',
    100: '#bae7ff',
    200: '#91d5ff',
    300: '#69c0ff',
    400: '#40a9ff',
    500: '#1890ff', // 主色
    600: '#096dd9',
    700: '#0050b3',
    800: '#003a8c',
    900: '#002766',
  },
  success: { /* ... */ },
  warning: { /* ... */ },
  danger: { /* ... */ },
  // 中性色
  neutral: {
    50: '#fafafa',
    100: '#f5f5f5',
    200: '#e5e5e5',
    300: '#d4d4d4',
    400: '#a3a3a3',
    500: '#737373',
    600: '#525252',
    700: '#404040',
    800: '#262626',
    900: '#171717',
  }
}

// tokens/spacing.ts
export const spacing = {
  xs: '4px',
  sm: '8px',
  md: '16px',
  lg: '24px',
  xl: '32px',
}

// tokens/typography.ts
export const typography = {
  fontSize: {
    xs: '12px',
    sm: '14px',
    md: '16px',
    lg: '18px',
    xl: '20px',
  },
  fontWeight: {
    normal: 400,
    medium: 500,
    semibold: 600,
    bold: 700,
  }
}

转换为 CSS Variables:

scss
// theme/variables.scss
:root {
  --color-primary-50: #e6f7ff;
  --color-primary-100: #bae7ff;
  --color-primary-500: #1890ff;
  --color-primary-600: #096dd9;
  
  --spacing-xs: 4px;
  --spacing-sm: 8px;
  --spacing-md: 16px;
  
  --font-size-sm: 14px;
  --font-size-md: 16px;
  
  --radius-sm: 4px;
  --radius-md: 6px;
  --radius-lg: 8px;
}

工具推荐:


2. 组件设计原则

在开始写组件前,先制定统一的设计原则:

Ant Design 风格(企业级):

  • 确定性:减少用户思考成本
  • 自然:遵循用户习惯
  • 效率:简化操作流程
  • 可控:让用户掌控一切

Material Design 风格(现代感):

  • Material 质感
  • 动效指引
  • 灵活适配

选择一种风格并坚持到底,不要混合多种设计语言。


三、 第三阶段:组件规范

这一层比写组件更重要,它决定了组件库的一致性和可维护性。

1. API 设计规范

Props 命名规范:

typescript
// ✅ 好的命名
interface ButtonProps {
  type?: 'primary' | 'secondary' | 'danger'
  size?: 'sm' | 'md' | 'lg'
  disabled?: boolean
  loading?: boolean
  block?: boolean
  onClick?: (e: React.MouseEvent) => void
}

// ❌ 避免不一致的命名
interface BadButtonProps {
  btnType?: string // 应该是 type
  isDisabled?: boolean // 应该是 disabled
  onButtonClick?: (e: Event) => void // 应该是 onClick
}

统一的 Props:

所有组件都应该支持的基础 Props:

typescript
interface BaseProps {
  // 样式相关
  className?: string
  style?: React.CSSProperties
  
  // 测试相关
  'data-testid'?: string
  
  // 无障碍相关
  'aria-label'?: string
}

2. 事件命名规范

typescript
// ✅ 好的命名
// 使用动词 + 名词,或纯动词
interface InputProps {
  onChange?: (value: string) => void
  onFocus?: (e: React.FocusEvent) => void
  onBlur?: (e: React.FocusEvent) => void
  onKeyDown?: (e: React.KeyboardEvent) => void
  onClear?: () => void
}

// ❌ 避免
interface BadInputProps {
  onValueChange?: (value: string) => void // 应该是 onChange
  inputChanged?: (value: string) => void // 格式不一致
  handleInput?: (value: string) => void // handle 是业务代码的命名
}

3. Children 和渲染 Props 规范

React 使用 children 和 render props 替代 Vue 的 slots:

tsx
// ✅ 好的命名
interface CardProps {
  header?: React.ReactNode
  children?: React.ReactNode
  footer?: React.ReactNode
  extra?: React.ReactNode
}

function Card({ header, children, footer, extra }: CardProps) {
  return (
    <div className="card">
      {header && <div className="card-header">{header}</div>}
      <div className="card-body">{children}</div>
      {footer && <div className="card-footer">{footer}</div>}
      {extra && <div className="card-extra">{extra}</div>}
    </div>
  )
}

// 使用示例
<Card
  header={<h2>标题</h2>}
  footer={<Button>操作</Button>}
  extra={<Link>更多</Link>}
>
  内容区域
</Card>

Render Props 用于动态渲染:

tsx
interface ListProps<T> {
  data: T[]
  renderItem: (item: T, index: number) => React.ReactNode
}

function List<T>({ data, renderItem }: ListProps<T>) {
  return (
    <div className="list">
      {data.map((item, index) => renderItem(item, index))}
    </div>
  )
}

4. CSS 类名规范

使用 BEM 命名规范:

scss
// Block
.ui-button {
  /* ... */
}

// Element
.ui-button__icon {
  /* ... */
}

// Modifier
.ui-button--primary {
  /* ... */
}

.ui-button--large {
  /* ... */
}

.ui-button--loading {
  /* ... */
}

四、 第四阶段:组件分层架构

合理的分层架构是组件库可维护的关键。

1. 四层架构

┌─────────────────────────────────────┐
│      业务组件层 (Business)          │
│  OrganizationTree, UserPicker      │
└────────────────┬────────────────────┘

┌────────────────▼────────────────────┐
│    业务增强层 (Enhanced)            │
│  SearchForm, ProTable, PageContainer│
└────────────────┬────────────────────┘

┌────────────────▼────────────────────┐
│     基础组件层 (Components)         │
│  Button, Input, Table, Dialog      │
└────────────────┬────────────────────┘

┌────────────────▼────────────────────┐
│     基础能力层 (Foundation)         │
│  Hooks, Utils, Composables, Theme  │
└─────────────────────────────────────┘

2. 基础能力层详解

React Hooks:

typescript
// hooks/useToggle.ts
import { useState, useCallback } from 'react'

export function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue)
  const toggle = useCallback(() => setValue(v => !v), [])
  const setTrue = useCallback(() => setValue(true), [])
  const setFalse = useCallback(() => setValue(false), [])
  return { value, toggle, setTrue, setFalse }
}

// hooks/useClickOutside.ts
import { useEffect, useRef, useCallback } from 'react'

export function useClickOutside(
  callback: () => void
) {
  const ref = useRef<HTMLElement>(null)

  const handleClick = useCallback((e: MouseEvent) => {
    if (ref.current && !ref.current.contains(e.target as Node)) {
      callback()
    }
  }, [callback])

  useEffect(() => {
    document.addEventListener('click', handleClick)
    return () => document.removeEventListener('click', handleClick)
  }, [handleClick])

  return ref
}

Utils:

typescript
// utils/classNames.ts
export function classNames(...classes: (string | undefined | null | false)[]) {
  return classes.filter(Boolean).join(' ')
}

// utils/debounce.ts
export function debounce<T extends (...args: any[]) => any>(
  fn: T,
  delay: number
): (...args: Parameters<T>) => void {
  let timer: ReturnType<typeof setTimeout>
  return (...args) => {
    clearTimeout(timer)
    timer = setTimeout(() => fn(...args), delay)
  }
}

3. 基础组件层示例

tsx
// components/Button/Button.tsx
import React from 'react'
import { classNames } from '../../utils/classNames'

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  type?: 'primary' | 'secondary' | 'danger' | 'link'
  size?: 'sm' | 'md' | 'lg'
  loading?: boolean
}

const LoadingIcon = () => (
  <span className="ui-button__loading">
    <svg className="animate-spin" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
      <path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8z"/>
    </svg>
  </span>
)

export function Button({
  type = 'secondary',
  size = 'md',
  loading = false,
  disabled = false,
  className,
  children,
  onClick,
  ...props
}: ButtonProps) {
  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    if (disabled || loading) return
    onClick?.(e)
  }

  return (
    <button
      className={classNames(
        'ui-button',
        `ui-button--${type}`,
        `ui-button--${size}`,
        {
          'ui-button--loading': loading,
          'ui-button--disabled': disabled
        },
        className
      )}
      disabled={disabled || loading}
      onClick={handleClick}
      {...props}
    >
      {loading && <LoadingIcon />}
      {children}
    </button>
  )
}

// 样式文件(SCSS 或 CSS-in-JS)
// style.module.scss
.ui-button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: var(--spacing-sm) var(--spacing-md);
  border-radius: var(--radius-md);
  font-size: var(--font-size-sm);
  cursor: pointer;
  transition: all 0.2s;
  
  &--primary {
    background-color: var(--color-primary-500);
    color: white;
    
    &:hover {
      background-color: var(--color-primary-600);
    }
  }
  
  &--loading,
  &--disabled {
    cursor: not-allowed;
    opacity: 0.6;
  }
}

五、 第五阶段:基础能力建设

这部分工作量占比 50%,但很多团队会忽略。

1. 主题系统

CSS Variables 方案(推荐):

scss
// theme/light.scss
:root {
  --color-bg: #ffffff;
  --color-text: #262626;
  --color-border: #d4d4d4;
  --color-primary: #1890ff;
}

// theme/dark.scss
[data-theme="dark"] {
  --color-bg: #171717;
  --color-text: #fafafa;
  --color-border: #404040;
  --color-primary: #40a9ff;
}

主题切换 React Hook:

typescript
// hooks/useTheme.ts
import { useState, useEffect, useCallback } from 'react'

type Theme = 'light' | 'dark'

export function useTheme() {
  const [theme, setTheme] = useState<Theme>(() => {
    return (localStorage.getItem('theme') as Theme) || 'light'
  })

  const toggleTheme = useCallback(() => {
    setTheme(prev => prev === 'light' ? 'dark' : 'light')
  }, [])

  useEffect(() => {
    document.documentElement.setAttribute('data-theme', theme)
    localStorage.setItem('theme', theme)
  }, [theme])

  return { theme, toggleTheme, setTheme }
}

// 创建 Context 以便在整个应用中共享
// context/ThemeContext.tsx
import React, { createContext, useContext, ReactNode } from 'react'
import { useTheme } from '../hooks/useTheme'

interface ThemeContextType {
  theme: Theme
  toggleTheme: () => void
  setTheme: (theme: Theme) => void
}

const ThemeContext = createContext<ThemeContextType | undefined>(undefined)

export function ThemeProvider({ children }: { children: ReactNode }) {
  const theme = useTheme()
  return (
    <ThemeContext.Provider value={theme}>
      {children}
    </ThemeContext.Provider>
  )
}

export function useThemeContext() {
  const context = useContext(ThemeContext)
  if (!context) {
    throw new Error('useThemeContext must be used within ThemeProvider')
  }
  return context
}

2. 国际化 (i18n)

typescript
// locale/zh-CN.ts
export default {
  ui: {
    button: {
      confirm: '确认',
      cancel: '取消'
    },
    table: {
      empty: '暂无数据',
      loading: '加载中...'
    },
    pagination: {
      total: '共 {total} 条',
      page: '第 {current} 页'
    }
  }
}

// locale/en-US.ts
export default {
  ui: {
    button: {
      confirm: 'Confirm',
      cancel: 'Cancel'
    },
    table: {
      empty: 'No data',
      loading: 'Loading...'
    },
    pagination: {
      total: 'Total {total}',
      page: 'Page {current}'
    }
  }
}

// hooks/useLocale.ts
import { useState, useCallback, createContext, useContext, ReactNode } from 'react'
import zhCN from '../locale/zh-CN'
import enUS from '../locale/en-US'

type Locale = 'zh-CN' | 'en-US'

const locales = { 'zh-CN': zhCN, 'en-US': enUS }

interface LocaleContextType {
  locale: Locale
  setLocale: (locale: Locale) => void
  t: (path: string, params?: Record<string, any>) => string
}

const LocaleContext = createContext<LocaleContextType | undefined>(undefined)

export function LocaleProvider({ children }: { children: ReactNode }) {
  const [locale, setLocale] = useState<Locale>('zh-CN')

  const t = useCallback((path: string, params?: Record<string, any>) => {
    const keys = path.split('.')
    let value: any = locales[locale]
    for (const key of keys) {
      value = value?.[key]
    }
    if (params && typeof value === 'string') {
      return value.replace(/{(\w+)}/g, (_, key) => params[key] || '')
    }
    return value || path
  }, [locale])

  return (
    <LocaleContext.Provider value={{ locale, setLocale, t }}>
      {children}
    </LocaleContext.Provider>
  )
}

export function useLocale() {
  const context = useContext(LocaleContext)
  if (!context) {
    throw new Error('useLocale must be used within LocaleProvider')
  }
  return context
}

3. 图标系统

React SVG 组件方案:

tsx
// icons/index.ts
import React from 'react'

interface IconProps {
  name: 'check' | 'close' | 'loading'
  size?: number
  color?: string
}

const iconPaths: Record<string, string> = {
  check: '<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>',
  close: '<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>',
  loading: '<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8z"/>'
}

export function Icon({ name, size = 16, color = 'currentColor' }: IconProps) {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill={color}
      dangerouslySetInnerHTML={{ __html: iconPaths[name] }}
    />
  )
}

Iconify 方案(推荐):

bash
npm install @iconify/react
tsx
// 使用示例
import { Icon } from '@iconify/react'

function App() {
  return <Icon icon="mdi:home" />
}

4. 弹窗层管理 (Popup Manager)

解决弹窗的 z-index 层级、焦点管理、Esc 关闭、滚动锁定等问题。

typescript
// utils/popupManager.ts
class PopupManager {
  private zIndex = 2000
  private stack: Array<{ close: () => void }> = []
  
  getNextZIndex() {
    return ++this.zIndex
  }
  
  open(closeFn: () => void) {
    this.stack.push({ close: closeFn })
    this.lockScroll()
  }
  
  close() {
    this.stack.pop()
    if (this.stack.length === 0) {
      this.unlockScroll()
    }
  }
  
  private lockScroll() {
    document.body.style.overflow = 'hidden'
  }
  
  private unlockScroll() {
    document.body.style.overflow = ''
  }
}

export const popupManager = new PopupManager()

六、 第六阶段:组件优先级与开发顺序

不要一开始写 50 个组件,按照依赖关系和使用频率分批次开发。

第一批:基础原子组件(MVP)

这些是所有组件的基石:

text
1. Icon          - 图标
2. Button        - 按钮
3. Typography    - 文字排版
4. Space         - 间距
5. Divider       - 分割线

为什么先做这些?

  • 它们是其他组件的依赖
  • 可以快速验证设计系统
  • 可以在项目中先用起来

第二批:表单组件(高频)

表单组件是业务系统使用最多的:

text
6. Input         - 输入框
7. Textarea      - 文本域
8. Checkbox      - 复选框
9. Radio         - 单选框
10. Switch       - 开关
11. Select       - 选择器
12. DatePicker   - 日期选择器
13. Form         - 表单
14. Upload       - 上传

第三批:展示组件(核心)

text
15. Table        - 表格(最复杂!)
16. Tree         - 树形控件
17. Tabs         - 标签页
18. Menu         - 菜单
19. Pagination   - 分页
20. Card         - 卡片
21. Badge        - 徽标

第四批:反馈组件(体验)

text
22. Dialog       - 对话框
23. Drawer       - 抽屉
24. Popover      - 气泡卡片
25. Tooltip      - 文字提示
26. Message      - 全局提示
27. Notification - 通知
28. Progress     - 进度条
29. Spin         - 加载中

第五批:高级组件(增强)

text
30. VirtualList  - 虚拟列表
31. ProTable     - 高级表格
32. SearchForm   - 搜索表单
33. Transfer     - 穿梭框
34. Cascader     - 级联选择
35. Watermark    - 水印
36. Skeleton     - 骨架屏

七、 第七阶段:工程化架构

真正的企业级项目,这部分工作量很大。

1. Monorepo 目录结构

text
ui-lib/
├── packages/
│   ├── button/
│   │   ├── src/
│   │   │   ├── Button.tsx
│   │   │   └── index.ts
│   │   ├── __tests__/
│   │   │   └── Button.spec.tsx
│   │   ├── style/
│   │   └── package.json
│   │
│   ├── input/
│   ├── table/
│   ├── dialog/
│   │
│   ├── hooks/          # 共享 Hooks
│   ├── utils/          # 工具函数
│   ├── theme/          # 主题系统
│   ├── locale/         # 国际化
│   │
│   └── ui/             # 全量导出包
│       └── index.ts

├── docs/               # 文档
├── playground/         # 开发预览
├── examples/           # 示例项目
├── scripts/            # 构建脚本
├── .changeset/         # Changeset 版本管理
├── package.json
├── pnpm-workspace.yaml
└── tsconfig.json

2. 构建配置

Vite 配置(React 组件库模式):

typescript
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import dts from 'vite-plugin-dts'

export default defineConfig({
  plugins: [
    react(),
    dts({
      insertTypesEntry: true,
    }),
  ],
  build: {
    lib: {
      entry: 'src/index.ts',
      name: 'MyUI',
      fileName: (format) => `my-ui.${format}.js`,
    },
    rollupOptions: {
      external: ['react', 'react-dom'],
      output: {
        globals: {
          react: 'React',
          'react-dom': 'ReactDOM'
        },
        // 按需加载
        preserveModules: true,
        preserveModulesRoot: 'src',
      },
    },
  },
})

3. 按需加载支持

package.json exports 配置:

json
{
  "name": "my-ui",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    },
    "./button": {
      "import": "./dist/button/index.mjs",
      "require": "./dist/button/index.cjs",
      "types": "./dist/button/index.d.ts"
    },
    "./style/index.css": "./dist/style/index.css",
    "./style/button.css": "./dist/style/button.css"
  }
}

使用方式:

typescript
// 全量引入
import { Button, Input } from 'my-ui'
import 'my-ui/style/index.css'

// 按需引入
import { Button } from 'my-ui/button'
import 'my-ui/style/button.css'

八、 第八阶段:文档与示例

很多 UI 库失败,就是文档太差。

1. 文档工具选择

React 生态:Docusaurus(重点推荐)

bash
npx create-docusaurus@latest docs classic

Vue 生态:VitePress

bash
npm install -D vitepress

2. 文档结构

text
docs/
├── guide/
│   ├── getting-started.md    # 快速开始
│   ├── installation.md       # 安装
│   ├── theme.md              # 主题
│   ├── i18n.md               # 国际化
│   └── contribute.md         # 贡献指南

├── components/
│   ├── button.md
│   ├── input.md
│   ├── table.md
│   └── ...

├── examples/
│   ├── admin-dashboard/
│   ├── form-example/
│   └── ...

└── index.md

3. 组件文档模板

markdown
# Button 按钮

按钮用于开始一个即时操作。

## 基础用法

:::demo
```tsx
import { Button, Space } from 'my-ui'

function App() {
  return (
    <Space>
      <Button type="primary">主要按钮</Button>
      <Button>次要按钮</Button>
      <Button type="danger">危险按钮</Button>
    </Space>
  )
}

:::

API

Props

参数说明类型默认值
type按钮类型primary | secondary | danger | linksecondary
size按钮尺寸sm | md | lgmd
disabled是否禁用booleanfalse
loading是否加载中booleanfalse

Events

事件名说明回调参数
onClick点击事件(e: React.MouseEvent) => void

Children

名称说明
children按钮内容

---

### 4. Playground 实时预览

使用 `@codesandbox/sandpack-react` 或 `@stackblitz/sdk` 实现:

```tsx
import { Sandpack } from '@codesandbox/sandpack-react'

function Demo() {
  return (
    <Sandpack
      theme="light"
      template="react-ts"
      files={{
        'App.tsx': `import { Button } from 'my-ui';
export default function App() {
  return <Button type="primary">Click me</Button>;
}`,
        'package.json': {
          dependencies: {
            react: '^18.2.0',
            'react-dom': '^18.2.0',
            'my-ui': '^1.0.0'
          }
        }
      }}
    />
  )
}

九、 第九阶段:测试策略

没有测试的组件库,重构都是噩梦。

1. 测试金字塔

        /\
       /E2E\        ← Playwright (10%)
      /------\
     / 组件测试 \    ← React Testing Library (30%)
    /----------\
   /  单元测试   \   ← Vitest (60%)
  /--------------\

2. 单元测试示例

tsx
// packages/button/__tests__/Button.spec.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { Button } from '../src/Button'

describe('Button', () => {
  it('renders correctly', () => {
    render(<Button>Click Me</Button>)
    expect(screen.getByText('Click Me')).toBeInTheDocument()
  })

  it('calls onClick when clicked', async () => {
    const onClick = vi.fn()
    render(<Button onClick={onClick}>Click Me</Button>)
    await fireEvent.click(screen.getByText('Click Me'))
    expect(onClick).toHaveBeenCalled()
  })

  it('does not call onClick when disabled', async () => {
    const onClick = vi.fn()
    render(<Button disabled onClick={onClick}>Click Me</Button>)
    await fireEvent.click(screen.getByText('Click Me'))
    expect(onClick).not.toHaveBeenCalled()
  })

  it('shows loading state', () => {
    render(<Button loading>Click Me</Button>)
    expect(screen.getByRole('button')).toHaveClass('ui-button--loading')
  })
})

3. 测试覆盖率目标

text
- 工具函数:100%
- Hooks:90%+
- 基础组件:80%+
- 复杂组件(Table):70%+

4. E2E 测试示例

typescript
// e2e/button.spec.ts
import { test, expect } from '@playwright/test'

test('button should work', async ({ page }) => {
  await page.goto('/components/button')
  
  // 点击按钮
  await page.getByRole('button', { name: 'Click Me' }).click()
  
  // 验证
  await expect(page.locator('.click-count')).toHaveText('1')
  
  // 测试禁用状态
  await expect(page.getByRole('button', { name: 'Disabled' })).toBeDisabled()
})

十、 第十阶段:性能优化

性能是企业级组件库的关键指标。

1. 按需加载与 Tree Shaking

typescript
// packages/ui/index.ts - 全量导出
export { Button } from '../button'
export { Input } from '../input'
export { Table } from '../table'

// packages/button/index.ts - 单独导出
export { Button } from './src/Button'
export type { ButtonProps } from './src/types'

Vite 配置确保 Tree Shaking:

typescript
// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        preserveModules: true, // 保持模块分离
      }
    }
  }
})

使用 React.lazy 实现组件懒加载:

tsx
import { Suspense, lazy } from 'react'

const LazyTable = lazy(() => import('my-ui/table'))

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <LazyTable data={data} columns={columns} />
    </Suspense>
  )
}

2. 虚拟滚动(Virtual List)

大数据列表必备:

typescript
// hooks/useVirtualList.ts
import { useState, useMemo, useCallback } from 'react'

interface VirtualListOptions {
  containerHeight: number
  itemHeight: number
  total: number
}

export function useVirtualList(options: VirtualListOptions) {
  const { containerHeight, itemHeight, total } = options
  const [scrollTop, setScrollTop] = useState(0)

  const startIndex = useMemo(() => 
    Math.floor(scrollTop / itemHeight),
    [scrollTop, itemHeight]
  )
  
  const endIndex = useMemo(() => 
    Math.min(
      startIndex + Math.ceil(containerHeight / itemHeight) + 1,
      total
    ),
    [startIndex, containerHeight, itemHeight, total]
  )

  const handleScroll = useCallback((e: React.UIEvent<HTMLElement>) => {
    setScrollTop(e.currentTarget.scrollTop)
  }, [])

  return {
    startIndex,
    endIndex,
    scrollTop,
    handleScroll,
    totalHeight: total * itemHeight
  }
}

3. 性能优化最佳实践

tsx
// ✅ 使用 memo 避免不必要的重渲染
import { memo } from 'react'

const ExpensiveComponent = memo(function ExpensiveComponent({ data }: { data: any[] }) {
  return <div>{/* 渲染逻辑 */}</div>
})

// ✅ 使用 useMemo 缓存计算结果
const sortedData = useMemo(() => 
  data.sort(), 
  [data]
)

// ✅ 使用 useCallback 缓存函数
const handleClick = useCallback(() => {
  // 处理点击
}, [dependency])

// ✅ 使用 CSS Contain 优化渲染
.ui-table {
  contain: layout paint size;
}

// ✅ 使用 will-change 优化动画
.ui-dialog {
  will-change: transform, opacity;
}

4. SSR 支持

确保组件在服务端渲染环境下也能正常工作:

typescript
// 使用 typeof window 判断环境
const isSSR = typeof window === 'undefined'

if (!isSSR) {
  // 只在客户端执行的代码
  document.addEventListener('click', handler)
}

// 或者使用 React 的 useEffect
useEffect(() => {
  // 只在客户端执行的代码
  document.addEventListener('click', handler)
  return () => document.removeEventListener('click', handler)
}, [])

十一、 第十一阶段:版本管理与发布

1. Semantic Versioning (语义化版本)

text
v1.2.3
 │ │ └─ Patch (补丁:bug 修复)
 │ └─── Minor (小版本:新功能,向后兼容)
 └───── Major (大版本:破坏性变更)

2. Changeset 工作流

初始化:

bash
npm install -D @changesets/cli
npx changeset init

发布流程:

bash
# 1. 开发者提交变更
npx changeset

# 2. 生成 CHANGELOG 和版本更新
npx changeset version

# 3. 发布
npx changeset publish

3. CHANGELOG 规范

markdown
## 1.2.0 (2024-01-15)

### Features

- **Button**: 添加 loading 状态 (#123)
- **Table**: 支持虚拟滚动 (#124)

### Bug Fixes

- **Input**: 修复清空按钮不显示的问题 (#125)
- **Dialog**: 修复 Esc 键关闭失败的问题 (#126)

### BREAKING CHANGES

- **Table**: columns 配置格式变更

4. 发布到 npm

package.json 配置:

json
{
  "name": "my-ui",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "files": ["dist"],
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    }
  },
  "sideEffects": ["*.css", "*.scss"],
  "repository": "https://github.com/your-org/my-ui",
  "publishConfig": {
    "access": "public"
  },
  "peerDependencies": {
    "react": ">=18.0.0",
    "react-dom": ">=18.0.0"
  }
}

发布脚本:

bash
# npm run build
# npm login
# npm publish

十二、 常见陷阱与经验教训

1. 不要过早优化

陷阱: 一开始就追求完美的架构和极致的性能。

经验:

  • 先做 MVP,能用就行
  • 在实际使用中发现问题
  • 渐进式优化

2. 不要过度设计

陷阱: 组件支持 100 种配置,但 99% 都用不上。

经验:

  • 遵循 80/20 原则
  • 提供合理的默认值
  • 通过组合实现复杂需求
tsx
// ✅ 好的设计 - 简单的 API
<Button>Click Me</Button>

// ❌ 过度设计 - 太多配置
<Button 
  variant="primary"
  size="medium"
  shape="rounded"
  hasShadow={true}
  hoverEffect="scale"
  clickAnimation="ripple"
>
  Click Me
</Button>

3. 不要忽略无障碍 (A11y)

陷阱: 只考虑视觉效果,不考虑键盘导航和屏幕阅读器。

经验:

tsx
// ✅ 支持键盘导航和无障碍
interface DialogProps {
  isOpen: boolean
  onClose: () => void
  title: string
  children: React.ReactNode
}

function Dialog({ isOpen, onClose, title, children }: DialogProps) {
  const containerRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        onClose()
      }
    }
    
    if (isOpen) {
      document.addEventListener('keydown', handleKeyDown)
      return () => document.removeEventListener('keydown', handleKeyDown)
    }
  }, [isOpen, onClose])

  if (!isOpen) return null

  return (
    <div 
      role="dialog"
      aria-modal="true"
      aria-labelledby="dialog-title"
      tabIndex={-1}
      ref={containerRef}
    >
      <h2 id="dialog-title">{title}</h2>
      {children}
    </div>
  )
}

4. 不要写业务逻辑进基础组件

陷阱: 为了省事,把业务逻辑写进 Button、Input 等基础组件。

经验:

  • 基础组件只关心 UI 和交互
  • 业务逻辑封装成业务组件
  • 保持基础组件的通用性
Button (基础组件)

AuthButton (业务组件 - 封装权限逻辑)

5. 不要忽略 TypeScript 类型定义

陷阱: 类型定义写得很随意,用户用起来全是 any。

经验:

typescript
// ✅ 完整的类型定义
export interface ButtonProps {
  type?: 'primary' | 'secondary' | 'danger' | 'link'
  size?: 'sm' | 'md' | 'lg'
  disabled?: boolean
  loading?: boolean
  onClick?: (e: React.MouseEvent) => void
}

// ✅ 导出类型供用户使用
export type { ButtonProps } from './src/types'

// ✅ 泛型支持
interface TableProps<T> {
  data: T[]
  columns: Column<T>[]
  onRow?: (record: T, index: number) => React.HTMLAttributes<HTMLTableRowElement>
}

6. 不要害怕 Breaking Changes

陷阱: 为了不破坏现有代码,保留不合理的设计。

经验:

  • 使用 deprecation 警告
  • 提供迁移指南
  • 遵循 SemVer,大版本才允许 Breaking Change
typescript
// 兼容旧版本,同时警告
interface ButtonProps {
  /** @deprecated 使用 type 替代 */
  variant?: string
  type?: string
}

function Button({ variant, type, ...props }: ButtonProps) {
  if (variant) {
    console.warn('Button: variant is deprecated, use type instead')
  }
  // ...
}

十三、 企业级 UI 库的核心理念

一个优秀的企业级 UI 库,不只是"组件集合",而是一个完整的前端基础设施,至少应包含四层能力:

层级目标示例
Design System保持视觉与交互一致Design Token、主题、字体、颜色、间距
Component Library提供稳定、可复用的基础组件Button、Input、Table、Dialog
Infrastructure提供跨组件的底层能力国际化、主题切换、弹窗管理、图标、无障碍、按需加载
Engineering保证长期可维护Monorepo、自动化测试、文档站、CI/CD、版本管理

很多团队把重心放在组件数量上,但真正决定一个 UI 库能否支撑数十个项目、持续演进数年的,往往是统一的设计规范、底层能力和工程化体系,而不是组件本身。

一个设计良好的 Button 可能一天就能写完,而一个稳定、易扩展、可维护的企业级 UI 库,通常需要持续投入数月甚至更长时间来完善整个生态。选择合适的时机开始,从小处着手,逐步迭代,才是成功之道。

Powered by VitePress + GitHub Pages