从零设计企业级 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 生态(重点)
框架:React 18+
语言:TypeScript 5.0+
构建:Vite 5.0+
样式:CSS-in-JS (emotion) / Tailwind CSS
测试:Vitest + React Testing Library
文档:DocusaurusVue3 生态
框架: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. 浏览器支持策略
根据目标用户群体决定浏览器支持范围,这直接影响技术选型。
现代浏览器方案(推荐):
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 层级结构:
// 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:
// 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;
}工具推荐:
- Figma Tokens - 设计稿 Token 管理
- Style Dictionary - Token 多平台转换
2. 组件设计原则
在开始写组件前,先制定统一的设计原则:
Ant Design 风格(企业级):
- 确定性:减少用户思考成本
- 自然:遵循用户习惯
- 效率:简化操作流程
- 可控:让用户掌控一切
Material Design 风格(现代感):
- Material 质感
- 动效指引
- 灵活适配
选择一种风格并坚持到底,不要混合多种设计语言。
三、 第三阶段:组件规范
这一层比写组件更重要,它决定了组件库的一致性和可维护性。
1. API 设计规范
Props 命名规范:
// ✅ 好的命名
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:
interface BaseProps {
// 样式相关
className?: string
style?: React.CSSProperties
// 测试相关
'data-testid'?: string
// 无障碍相关
'aria-label'?: string
}2. 事件命名规范
// ✅ 好的命名
// 使用动词 + 名词,或纯动词
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:
// ✅ 好的命名
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 用于动态渲染:
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 命名规范:
// 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:
// 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:
// 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. 基础组件层示例
// 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 方案(推荐):
// 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:
// 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)
// 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 组件方案:
// 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 方案(推荐):
npm install @iconify/react// 使用示例
import { Icon } from '@iconify/react'
function App() {
return <Icon icon="mdi:home" />
}4. 弹窗层管理 (Popup Manager)
解决弹窗的 z-index 层级、焦点管理、Esc 关闭、滚动锁定等问题。
// 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)
这些是所有组件的基石:
1. Icon - 图标
2. Button - 按钮
3. Typography - 文字排版
4. Space - 间距
5. Divider - 分割线为什么先做这些?
- 它们是其他组件的依赖
- 可以快速验证设计系统
- 可以在项目中先用起来
第二批:表单组件(高频)
表单组件是业务系统使用最多的:
6. Input - 输入框
7. Textarea - 文本域
8. Checkbox - 复选框
9. Radio - 单选框
10. Switch - 开关
11. Select - 选择器
12. DatePicker - 日期选择器
13. Form - 表单
14. Upload - 上传第三批:展示组件(核心)
15. Table - 表格(最复杂!)
16. Tree - 树形控件
17. Tabs - 标签页
18. Menu - 菜单
19. Pagination - 分页
20. Card - 卡片
21. Badge - 徽标第四批:反馈组件(体验)
22. Dialog - 对话框
23. Drawer - 抽屉
24. Popover - 气泡卡片
25. Tooltip - 文字提示
26. Message - 全局提示
27. Notification - 通知
28. Progress - 进度条
29. Spin - 加载中第五批:高级组件(增强)
30. VirtualList - 虚拟列表
31. ProTable - 高级表格
32. SearchForm - 搜索表单
33. Transfer - 穿梭框
34. Cascader - 级联选择
35. Watermark - 水印
36. Skeleton - 骨架屏七、 第七阶段:工程化架构
真正的企业级项目,这部分工作量很大。
1. Monorepo 目录结构
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.json2. 构建配置
Vite 配置(React 组件库模式):
// 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 配置:
{
"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"
}
}使用方式:
// 全量引入
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(重点推荐)
npx create-docusaurus@latest docs classicVue 生态:VitePress
npm install -D vitepress2. 文档结构
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.md3. 组件文档模板
# 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 | link | secondary |
| size | 按钮尺寸 | sm | md | lg | md |
| disabled | 是否禁用 | boolean | false |
| loading | 是否加载中 | boolean | false |
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. 单元测试示例
// 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. 测试覆盖率目标
- 工具函数:100%
- Hooks:90%+
- 基础组件:80%+
- 复杂组件(Table):70%+4. E2E 测试示例
// 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
// 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:
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
preserveModules: true, // 保持模块分离
}
}
}
})使用 React.lazy 实现组件懒加载:
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)
大数据列表必备:
// 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. 性能优化最佳实践
// ✅ 使用 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 支持
确保组件在服务端渲染环境下也能正常工作:
// 使用 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 (语义化版本)
v1.2.3
│ │ └─ Patch (补丁:bug 修复)
│ └─── Minor (小版本:新功能,向后兼容)
└───── Major (大版本:破坏性变更)2. Changeset 工作流
初始化:
npm install -D @changesets/cli
npx changeset init发布流程:
# 1. 开发者提交变更
npx changeset
# 2. 生成 CHANGELOG 和版本更新
npx changeset version
# 3. 发布
npx changeset publish3. CHANGELOG 规范
## 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 配置:
{
"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"
}
}发布脚本:
# npm run build
# npm login
# npm publish十二、 常见陷阱与经验教训
1. 不要过早优化
陷阱: 一开始就追求完美的架构和极致的性能。
经验:
- 先做 MVP,能用就行
- 在实际使用中发现问题
- 渐进式优化
2. 不要过度设计
陷阱: 组件支持 100 种配置,但 99% 都用不上。
经验:
- 遵循 80/20 原则
- 提供合理的默认值
- 通过组合实现复杂需求
// ✅ 好的设计 - 简单的 API
<Button>Click Me</Button>
// ❌ 过度设计 - 太多配置
<Button
variant="primary"
size="medium"
shape="rounded"
hasShadow={true}
hoverEffect="scale"
clickAnimation="ripple"
>
Click Me
</Button>3. 不要忽略无障碍 (A11y)
陷阱: 只考虑视觉效果,不考虑键盘导航和屏幕阅读器。
经验:
// ✅ 支持键盘导航和无障碍
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。
经验:
// ✅ 完整的类型定义
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
// 兼容旧版本,同时警告
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 库,通常需要持续投入数月甚至更长时间来完善整个生态。选择合适的时机开始,从小处着手,逐步迭代,才是成功之道。