关键要点
- 原子设计理论指导组件分层:将组件分为原子、分子、组织、模板和页面五个层级,便于复用和维护
- Storybook 加速开发流程:独立开发组件,实时预览不同状态和变体
- Tailwind CSS 实现设计一致性:使用设计令牌(design tokens)和自定义配置统一视觉风格
- TypeScript 提供类型安全:完整的类型定义使组件更易使用和维护
- 自动化测试保障质量:结合 Jest、React Testing Library 和 Chromatic 进行视觉回归测试
构建专业的健康数据仪表板需要一套完善的组件库。本教程将指导你从零开始,创建一个可复用、可测试、文档完善的 React 组件库。
前置条件:
- React 18+ 和 TypeScript 基础
- Tailwind CSS 了解
- npm/yarn 包管理器
- Git 版本控制
项目架构
code
health-dashboard-ui/
├── packages/
│ ├── components/ # 主组件库
│ │ ├── src/
│ │ │ ├── atoms/ # 原子组件(按钮、输入框等)
│ │ │ ├── molecules/ # 分子组件(搜索框、卡片等)
│ │ │ ├── organisms/ # 组织组件(导航栏、表格等)
│ │ │ ├── templates/ # 模板(页面布局)
│ │ │ ├── themes/ # 主题配置
│ │ │ └── utils/ # 工具函数
│ │ ├── .storybook/ # Storybook 配置
│ │ └── package.json
│ └── icons/ # 图标库(可选)
├── apps/
│ └── demo/ # 演示应用
├── lerna.json # Monorepo 配置
└── package.json
Code collapsed
步骤 1:初始化 Monorepo 项目
code
# 创建项目目录
mkdir health-dashboard-ui && cd health-dashboard-ui
# 初始化项目
npm init -y
# 安装 Lerna(管理 monorepo)
npm install -D lerna
# 初始化 Lerna
npx lerna init
# 配置为独立版本管理
Code collapsed
修改 lerna.json:
code
{
"version": "independent",
"packages": ["packages/*", "apps/*"],
"npmClient": "npm",
"useWorkspaces": true
}
Code collapsed
步骤 2:创建组件库包
code
# 创建组件库目录
mkdir -p packages/components/src
# 初始化组件库包
cd packages/components
npm init -y
# 安装依赖
npm install react react-dom
npm install -D typescript @types/react @types/react-dom
npm install -D tailwindcss postcss autoprefixer
npm install -D @storybook/react @storybook/addon-essentials @storybook/addon-a11y
npm install -D @testing-library/react @testing-library/jest-dom jest
npm install -D @chromatic-com/storybook
# 返回项目根目录
cd ../..
Code collapsed
配置 packages/components/package.json:
code
{
"name": "@health-dashboard/components",
"version": "0.1.0",
"main": "dist/index.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"scripts": {
"build": "rollup -c",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"test": "jest",
"test:watch": "jest --watch",
"chromatic": "chromatic"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"@chromatic-com/storybook": "^1.0.0",
"@storybook/addon-a11y": "^8.0.0",
"@storybook/addon-essentials": "^8.0.0",
"@storybook/react": "^8.0.0",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^14.0.0",
"@types/react": "^18.0.0",
"autoprefixer": "^10.0.0",
"jest": "^29.0.0",
"postcss": "^8.0.0",
"rollup": "^4.0.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.0.0"
}
}
Code collapsed
步骤 3:配置 TypeScript
code
// packages/components/tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"composite": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"skipLibCheck": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.stories.tsx"]
}
Code collapsed
步骤 4:配置 Tailwind CSS
code
// packages/components/tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{js,jsx,ts,tsx}'],
theme: {
extend: {
colors: {
// 健康应用品牌色
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
success: {
light: '#86efac',
DEFAULT: '#22c55e',
dark: '#16a34a',
},
warning: {
light: '#fcd34d',
DEFAULT: '#f59e0b',
dark: '#d97706',
},
danger: {
light: '#fca5a5',
DEFAULT: '#ef4444',
dark: '#dc2626',
},
// 健康数据特定颜色
'heart-rate': '#ef4444',
'blood-pressure': '#8b5cf6',
steps: '#22c55e',
calories: '#f59e0b',
sleep: '#6366f1',
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
spacing: {
'18': '4.5rem',
'88': '22rem',
'128': '32rem',
},
borderRadius: {
'4xl': '2rem',
},
boxShadow: {
'soft': '0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04)',
},
},
},
plugins: [
require('@tailwindcss/forms'),
require('@tailwindcss/typography'),
],
};
Code collapsed
code
/* packages/components/src/styles/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
/* 自定义基础样式 */
* {
@apply border-gray-200;
}
body {
@apply text-gray-900 bg-gray-50 antialiased;
}
/* 无障碍焦点样式 */
*:focus-visible {
@apply outline-none ring-2 ring-primary-500 ring-offset-2;
}
}
@layer components {
/* 组件级样式 */
.btn {
@apply inline-flex items-center justify-center rounded-lg font-medium transition-colors;
@apply focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2;
@apply disabled:opacity-50 disabled:pointer-events-none;
}
.btn-primary {
@apply btn bg-primary-600 text-white hover:bg-primary-700;
}
.btn-secondary {
@apply btn bg-gray-200 text-gray-900 hover:bg-gray-300;
}
.btn-ghost {
@apply btn bg-transparent hover:bg-gray-100;
}
.card {
@apply bg-white rounded-xl shadow-soft p-6;
}
.input {
@apply block w-full rounded-lg border-gray-300 shadow-sm;
@apply focus:border-primary-500 focus:ring-primary-500;
@apply disabled:bg-gray-100 disabled:cursor-not-allowed;
}
}
@layer utilities {
/* 自定义工具类 */
.text-balance {
text-wrap: balance;
}
.bg-gradient-radial {
background-image: radial-gradient(var(--tw-gradient-stops));
}
}
Code collapsed
步骤 5:创建原子组件
Button 组件
code
// packages/components/src/atoms/Button/Button.tsx
import React from 'react';
import { cn } from '../../utils/cn';
import { loader } from '../../atoms/Icon';
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
fullWidth?: boolean;
}
const sizeStyles = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
};
const variantStyles = {
primary: 'bg-primary-600 text-white hover:bg-primary-700',
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
ghost: 'bg-transparent hover:bg-gray-100',
danger: 'bg-danger-600 text-white hover:bg-danger-700',
};
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
variant = 'primary',
size = 'md',
isLoading = false,
leftIcon,
rightIcon,
fullWidth = false,
disabled,
children,
className,
...props
},
ref
) => {
return (
<button
ref={ref}
disabled={disabled || isLoading}
className={cn(
'btn',
sizeStyles[size],
variantStyles[variant],
fullWidth && 'w-full',
isLoading && 'relative',
className
)}
{...props}
>
{isLoading && (
<span className="absolute inset-0 flex items-center justify-center">
{loader}
</span>
)}
<span className={cn(isLoading && 'invisible')}>
{leftIcon && <span className="mr-2">{leftIcon}</span>}
{children}
{rightIcon && <span className="ml-2">{rightIcon}</span>}
</span>
</button>
);
}
);
Button.displayName = 'Button';
Code collapsed
Badge 组件
code
// packages/components/src/atoms/Badge/Badge.tsx
import React from 'react';
import { cn } from '../../utils/cn';
export interface BadgeProps {
variant?: 'success' | 'warning' | 'danger' | 'info' | 'neutral';
size?: 'sm' | 'md' | 'lg';
children: React.ReactNode;
}
const variantStyles = {
success: 'bg-success-light text-success-dark',
warning: 'bg-warning-light text-warning-dark',
danger: 'bg-danger-light text-danger-dark',
info: 'bg-primary-100 text-primary-800',
neutral: 'bg-gray-100 text-gray-800',
};
const sizeStyles = {
sm: 'px-2 py-0.5 text-xs',
md: 'px-2.5 py-1 text-sm',
lg: 'px-3 py-1.5 text-base',
};
export const Badge: React.FC<BadgeProps> = ({
variant = 'neutral',
size = 'md',
children,
}) => {
return (
<span
className={cn(
'inline-flex items-center rounded-full font-medium',
variantStyles[variant],
sizeStyles[size]
)}
>
{children}
</span>
);
};
Code collapsed
步骤 6:创建分子组件
MetricCard 组件
code
// packages/components/src/molecules/MetricCard/MetricCard.tsx
import React from 'react';
import { cn } from '../../utils/cn';
export interface MetricCardProps {
title: string;
value: string | number;
unit?: string;
trend?: {
value: number;
isPositive: boolean;
};
icon?: React.ReactNode;
color?: 'blue' | 'green' | 'purple' | 'orange' | 'red';
size?: 'sm' | 'md' | 'lg';
className?: string;
}
const colorStyles = {
blue: 'bg-blue-50 text-blue-600',
green: 'bg-green-50 text-green-600',
purple: 'bg-purple-50 text-purple-600',
orange: 'bg-orange-50 text-orange-600',
red: 'bg-red-50 text-red-600',
};
const sizeStyles = {
sm: 'p-4',
md: 'p-6',
lg: 'p-8',
};
export const MetricCard: React.FC<MetricCardProps> = ({
title,
value,
unit,
trend,
icon,
color = 'blue',
size = 'md',
className,
}) => {
return (
<div className={cn('card', sizeStyles[size], className)}>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-gray-600">{title}</p>
<div className="mt-2 flex items-baseline gap-2">
<p className="text-3xl font-bold text-gray-900">{value}</p>
{unit && (
<p className="text-sm text-gray-500">{unit}</p>
)}
</div>
{trend && (
<div className="mt-2 flex items-center gap-1">
<span
className={cn(
'text-sm font-medium',
trend.isPositive ? 'text-green-600' : 'text-red-600'
)}
>
{trend.isPositive ? '+' : '-'}
{Math.abs(trend.value)}%
</span>
<span className="text-sm text-gray-500">vs 上周</span>
</div>
)}
</div>
{icon && (
<div className={cn('rounded-lg p-3', colorStyles[color])}>
{icon}
</div>
)}
</div>
</div>
);
};
Code collapsed
步骤 7:创建组织组件
HealthDataTable 组件
code
// packages/components/src/organisms/HealthDataTable/HealthDataTable.tsx
import React, { useState, useMemo } from 'react';
import { cn } from '../../utils/cn';
export interface Column<T> {
key: keyof T | string;
header: string;
sortable?: boolean;
render?: (value: any, row: T) => React.ReactNode;
className?: string;
}
export interface HealthDataTableProps<T> {
data: T[];
columns: Column<T>[];
searchable?: boolean;
searchPlaceholder?: string;
pageSize?: number;
onRowClick?: (row: T) => void;
emptyState?: React.ReactNode;
className?: string;
}
export function HealthDataTable<T extends Record<string, any>>({
data,
columns,
searchable = true,
searchPlaceholder = '搜索...',
pageSize = 10,
onRowClick,
emptyState,
className,
}: HealthDataTableProps<T>) {
const [searchTerm, setSearchTerm] = useState('');
const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [currentPage, setCurrentPage] = useState(1);
// 搜索和排序逻辑
const filteredAndSortedData = useMemo(() => {
let result = [...data];
// 搜索
if (searchTerm) {
result = result.filter((row) =>
columns.some((col) => {
const value = row[col.key as keyof T];
return String(value).toLowerCase().includes(searchTerm.toLowerCase());
})
);
}
// 排序
if (sortColumn) {
result.sort((a, b) => {
const aVal = a[sortColumn];
const bVal = b[sortColumn];
if (aVal === bVal) return 0;
const comparison = aVal > bVal ? 1 : -1;
return sortDirection === 'asc' ? comparison : -comparison;
});
}
return result;
}, [data, searchTerm, sortColumn, sortDirection, columns]);
// 分页
const totalPages = Math.ceil(filteredAndSortedData.length / pageSize);
const paginatedData = filteredAndSortedData.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize
);
const handleSort = (columnKey: string) => {
if (sortColumn === columnKey) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortColumn(columnKey);
setSortDirection('asc');
}
};
return (
<div className={cn('space-y-4', className)}>
{searchable && (
<div className="relative">
<input
type="text"
placeholder={searchPlaceholder}
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(1);
}}
className="input pl-10"
/>
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
)}
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
{columns.map((col) => (
<th
key={String(col.key)}
scope="col"
className={cn(
'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500',
col.sortable && 'cursor-pointer hover:bg-gray-100',
col.className
)}
onClick={() => col.sortable && handleSort(String(col.key))}
>
<div className="flex items-center gap-2">
{col.header}
{col.sortable && sortColumn === String(col.key) && (
<svg
className={cn(
'h-4 w-4 transition-transform',
sortDirection === 'desc' && 'rotate-180'
)}
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{paginatedData.map((row, index) => (
<tr
key={index}
className={cn(
'transition-colors',
onRowClick && 'cursor-pointer hover:bg-gray-50'
)}
onClick={() => onRowClick?.(row)}
>
{columns.map((col) => (
<td
key={String(col.key)}
className={cn(
'whitespace-nowrap px-6 py-4 text-sm text-gray-900',
col.className
)}
>
{col.render
? col.render(row[col.key as keyof T], row)
: String(row[col.key as keyof T] ?? '-')}
</td>
))}
</tr>
))}
</tbody>
</table>
{paginatedData.length === 0 && (
<div className="px-6 py-12 text-center">
{emptyState || (
<p className="text-gray-500">没有找到匹配的数据</p>
)}
</div>
)}
</div>
{/* 分页控制 */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-gray-700">
显示第 {(currentPage - 1) * pageSize + 1} 到{' '}
{Math.min(currentPage * pageSize, filteredAndSortedData.length)} 条,
共 {filteredAndSortedData.length} 条
</p>
<div className="flex gap-2">
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="btn-secondary btn-sm"
>
上一页
</button>
<span className="flex items-center px-4 text-sm text-gray-700">
第 {currentPage} / {totalPages} 页
</span>
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="btn-secondary btn-sm"
>
下一页
</button>
</div>
</div>
)}
</div>
);
}
Code collapsed
步骤 8:配置 Storybook
code
// packages/components/.storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-a11y',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
docs: {
autodocs: 'tag',
},
typescript: {
check: false,
checkOptions: {},
reactDocgen: 'react-docgen-typescript',
reactDocgenTypescriptOptions: {
shouldExtractLiteralValuesFromEnum: true,
propFilter: (prop) => (prop.parent ? !/node_modules/.test(prop.parent.fileName) : true),
},
},
};
export default config;
Code collapsed
code
// packages/components/.storybook/preview.ts
import type { Preview } from '@storybook/react';
import '../src/styles/globals.css';
import { withThemeByDataAttribute } from '@storybook/addon-themes';
import { themes } from '@storybook/theming';
const preview: Preview = {
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
docs: {
theme: themes.normal,
},
},
decorators: [
withThemeByDataAttribute({
themes: {
light: 'light',
dark: 'dark',
},
defaultTheme: 'light',
attributeName: 'data-mode',
}),
],
};
export default preview;
Code collapsed
步骤 9:编写 Story
code
// packages/components/src/atoms/Button/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
import { Heart } from 'lucide-react';
const meta: Meta<typeof Button> = {
title: 'Atoms/Button',
component: Button,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'ghost', 'danger'],
},
size: {
control: 'select',
options: ['sm', 'md', 'lg'],
},
},
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Primary: Story = {
args: {
variant: 'primary',
children: '主要按钮',
},
};
export const Secondary: Story = {
args: {
variant: 'secondary',
children: '次要按钮',
},
};
export const WithIcon: Story = {
args: {
variant: 'primary',
leftIcon: <Heart className="h-4 w-4" />,
children: '收藏',
},
};
export const Loading: Story = {
args: {
variant: 'primary',
isLoading: true,
children: '加载中',
},
};
export const AllVariants: Story = {
render: () => (
<div className="flex gap-4">
<Button variant="primary">主要</Button>
<Button variant="secondary">次要</Button>
<Button variant="ghost">幽灵</Button>
<Button variant="danger">危险</Button>
</div>
),
};
export const AllSizes: Story = {
render: () => (
<div className="flex items-center gap-4">
<Button size="sm">小按钮</Button>
<Button size="md">中按钮</Button>
<Button size="lg">大按钮</Button>
</div>
),
};
Code collapsed
code
// packages/components/src/molecules/MetricCard/MetricCard.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { MetricCard } from './MetricCard';
import { Heart, Footprints, Flame } from 'lucide-react';
const meta: Meta<typeof MetricCard> = {
title: 'Molecules/MetricCard',
component: MetricCard,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof MetricCard>;
export const HeartRate: Story = {
args: {
title: '静息心率',
value: 68,
unit: 'BPM',
color: 'red',
icon: <Heart className="h-6 w-6" />,
trend: { value: 5, isPositive: false },
},
};
export const Steps: Story = {
args: {
title: '今日步数',
value: '8,234',
color: 'green',
icon: <Footprints className="h-6 w-6" />,
trend: { value: 12, isPositive: true },
},
};
export const Calories: Story = {
args: {
title: '消耗卡路里',
value: '1,850',
unit: 'kcal',
color: 'orange',
icon: <Flame className="h-6 w-6" />,
},
};
export const CardGrid: Story = {
render: () => (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<MetricCard
title="静息心率"
value={68}
unit="BPM"
color="red"
icon={<Heart className="h-6 w-6" />}
/>
<MetricCard
title="今日步数"
value="8,234"
color="green"
icon={<Footprints className="h-6 w-6" />}
trend={{ value: 12, isPositive: true }}
/>
<MetricCard
title="消耗卡路里"
value="1,850"
unit="kcal"
color="orange"
icon={<Flame className="h-6 w-6" />}
/>
</div>
),
};
Code collapsed
步骤 10:配置 Rollup 打包
code
// packages/components/rollup.config.js
import resolve from '@rollup/plugin-node-resolve';
import typescript from '@rollup/plugin-typescript';
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
import postcss from 'rollup-plugin-postcss';
export default {
input: 'src/index.ts',
output: [
{
file: 'dist/index.js',
format: 'cjs',
sourcemap: true,
exports: 'named',
},
{
file: 'dist/index.esm.js',
format: 'esm',
sourcemap: true,
},
],
plugins: [
peerDepsExternal(),
resolve({
browser: true,
}),
typescript({
tsconfig: './tsconfig.json',
declaration: true,
declarationDir: 'dist',
}),
postcss({
extract: 'styles.css',
minimize: true,
}),
],
external: ['react', 'react-dom'],
};
Code collapsed
步骤 11:创建入口文件
code
// packages/components/src/index.ts
// 导出所有组件
export { Button } from './atoms/Button';
export { Badge } from './atoms/Badge';
export { Input } from './atoms/Input';
export { MetricCard } from './molecules/MetricCard';
export { HealthDataTable } from './organisms/HealthDataTable';
// 导出类型
export type { ButtonProps } from './atoms/Button';
export type { MetricCardProps } from './molecules/MetricCard';
export type { Column, HealthDataTableProps } from './organisms/HealthDataTable';
// 导出工具函数
export { cn } from './utils/cn';
Code collapsed
步骤 12:配置 Chromatic
在项目根目录创建 .chromarc.json:
code
{
"projectId": "your-project-id",
"buildScriptName": "build-storybook",
"autoAcceptChanges": "patch",
"diffThreshold": 0.1
}
Code collapsed
总结
通过本教程,你学会了如何构建一个专业的 React 组件库:
- 使用 Lerna 管理 Monorepo
- 配置 Tailwind CSS 和设计令牌
- 按原子设计理论组织组件
- 使用 Storybook 编写文档和示例
- 配置 Rollup 打包和发布
最佳实践
- 版本控制:遵循语义化版本规范
- 变更日志:使用 conventional commits 自动生成
- 持续集成:自动化测试和视觉回归测试
- 文档完善:每个组件都有清晰的 Story 和示例