康心伴Logo
康心伴WellAlly
组件开发

构建健康仪表板 React 组件库:Storybook + Tailwind CSS

完整指南如何从零构建和维护一个 React 组件库,专门用于健康数据仪表板。包含 Storybook 集成、Tailwind CSS 配置、组件测试和文档生成。

W
WellAlly 开发团队
2026-03-08
25 分钟阅读

关键要点

  • 原子设计理论指导组件分层:将组件分为原子、分子、组织、模板和页面五个层级,便于复用和维护
  • 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 组件库:

  1. 使用 Lerna 管理 Monorepo
  2. 配置 Tailwind CSS 和设计令牌
  3. 按原子设计理论组织组件
  4. 使用 Storybook 编写文档和示例
  5. 配置 Rollup 打包和发布

最佳实践

  • 版本控制:遵循语义化版本规范
  • 变更日志:使用 conventional commits 自动生成
  • 持续集成:自动化测试和视觉回归测试
  • 文档完善:每个组件都有清晰的 Story 和示例

参考资料

相关文章

#

文章标签

react
storybook
tailwind
组件库
设计系统

觉得这篇文章有帮助?

立即体验康心伴,开始您的健康管理之旅