第 14 章:组件库选型与业务组件体系

在 Next.js App Router 中选择与集成组件库——深度对比 shadcn/ui、MUI、Ant Design,掌握 RSC/Client Component 边界下的组件设计、CVA 变体系统、Storybook 文档化。

本章目标:建立一套适合 Next.js App Router 的组件工程体系——从组件库选型、RSC/Client Component 边界处理,到 CVA 变体设计、业务组件封装、Storybook 文档化,最终形成可复用的组件体系。


14.1 组件库选型对比

三大主流组件库对比

维度shadcn/uiMUI (Material UI)Ant Design
本质组件源码集合传统 npm 包传统 npm 包
包体积零(代码复制到项目)较大(~150KB gzip)较大(~200KB gzip)
CSS 方案Tailwind CSSEmotion (CSS-in-JS)CSS-in-JS (v5)
RSC 兼容✅ 原生支持⚠️ 需 "use client" 包裹⚠️ 需 "use client" 包裹
可定制性极高(拥有源码)高(主题系统)中(Token 系统)
设计风格极简 / 可自定义Material Design企业级 / 稳重
TypeScript✅ 完整类型✅ 完整类型✅ 完整类型
组件数量~50+~80+~70+
无障碍 (a11y)基于 Radix UI(优秀)优秀良好
社区生态快速增长最大中国 / 企业广泛
学习曲线中(需了解 Tailwind)
适合场景现代 Web / SaaS / 初创通用企业后台 / ERP

选型决策树

你的项目需要...
├── 极致定制 + Tailwind → ✅ shadcn/ui
├── Material Design 风格 → ✅ MUI
├── 企业后台 / 中文生态 → ✅ Ant Design
├── 零依赖 + 完全控制 → ✅ shadcn/ui + Radix UI
└── 快速原型 / MVP → 任何一个都可以

本教程选择:shadcn/ui

理由:

  1. RSC 原生兼容:不需要在每个组件上添加 "use client"
  2. 零运行时开销:基于 Tailwind,无 CSS-in-JS 运行时
  3. 源码可控:组件代码直接复制到项目中,可随意修改
  4. CVA 变体系统:类型安全的组件变体管理
  5. 与 Next.js 生态最契合:Vercel 官方推荐

14.2 shadcn/ui 深度集成

初始化与配置

# 初始化
npx shadcn@latest init

# 选择配置
# ✓ Style: Default / New York
# ✓ Base color: Slate / Zinc / Neutral
# ✓ CSS variables: Yes
# ✓ Use Tailwind CSS: Yes
# ✓ Import alias: @/components, @/lib

项目结构

project/
├── components/
│   ├── ui/              # shadcn/ui 基础组件(可直接修改)
│   │   ├── button.tsx
│   │   ├── input.tsx
│   │   ├── card.tsx
│   │   ├── dialog.tsx
│   │   ├── dropdown-menu.tsx
│   │   ├── table.tsx
│   │   ├── toast.tsx
│   │   └── ...
│   └── [feature]/       # 业务组件
│       ├── article-card.tsx
│       ├── comment-list.tsx
│       └── ...
├── lib/
│   └── utils.ts         # cn() 工具函数
└── components.json      # shadcn 配置

安装常用组件

# 表单相关
npx shadcn@latest add button input textarea select checkbox radio-group label form

# 数据展示
npx shadcn@latest add card table badge avatar separator

# 反馈
npx shadcn@latest add dialog alert-dialog sheet popover tooltip toast sonner

# 导航
npx shadcn@latest add tabs navigation-menu breadcrumb dropdown-menu

# 布局
npx shadcn@latest add scroll-area resizable

# 数据输入
npx shadcn@latest add calendar command data-table pagination

14.3 CVA 变体系统

什么是 CVA?

CVA (class-variance-authority) 是一个类型安全的组件变体管理工具,让你以声明式的方式定义组件的多种样式变体。

npm install class-variance-authority

基础用法

// components/ui/button.tsx

import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';

const buttonVariants = cva(
  // 基础样式(所有变体共享)
  'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
        destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
        outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
        secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
        ghost: 'hover:bg-accent hover:text-accent-foreground',
        link: 'text-primary underline-offset-4 hover:underline',
      },
      size: {
        default: 'h-9 px-4 py-2',
        sm: 'h-8 rounded-md px-3 text-xs',
        lg: 'h-10 rounded-md px-8',
        icon: 'h-9 w-9',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  }
);

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : 'button';
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    );
  }
);

export { Button, buttonVariants };

复合变体(Compound Variants)

const badgeVariants = cva(
  'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors',
  {
    variants: {
      variant: {
        default: 'border-transparent bg-primary text-primary-foreground',
        secondary: 'border-transparent bg-secondary text-secondary-foreground',
        destructive: 'border-transparent bg-destructive text-destructive-foreground',
        outline: 'text-foreground',
        success: 'border-transparent bg-success text-success-foreground',
      },
      size: {
        sm: 'px-2 py-0.5 text-2xs',
        md: 'px-2.5 py-0.5 text-xs',
        lg: 'px-3 py-1 text-sm',
      },
      interactive: {
        true: 'cursor-pointer hover:opacity-80',
        false: '',
      },
    },
    // 复合变体:当 variant=success 且 interactive=true 时
    compoundVariants: [
      {
        variant: 'success',
        interactive: true,
        class: 'hover:bg-success/90',
      },
      {
        variant: 'default',
        interactive: true,
        class: 'hover:bg-primary/90',
      },
    ],
    defaultVariants: {
      variant: 'default',
      size: 'md',
      interactive: false,
    },
  }
);

使用方式

// 基础用法
<Button>默认按钮</Button>
<Button variant="destructive">删除</Button>
<Button variant="outline" size="lg">大按钮</Button>
<Button variant="ghost" size="icon">
  <Icon />
</Button>

// Badge
<Badge variant="success" size="sm" interactive>
  在线
</Badge>

// TypeScript 自动推导变体
<Button variant="invalid" />  // ❌ 编译错误

14.4 RSC 与 Client Component 边界

组件分类原则

Server Component(默认):
  ✅ 数据获取
  ✅ 静态内容渲染
  ✅ SEO 友好的内容
  ✅ 无交互的展示组件

Client Component("use client"):
  ✅ 用户交互(onClick、onChange)
  ✅ 状态管理(useState、useReducer)
  ✅ 生命周期(useEffect)
  ✅ 浏览器 API(window、localStorage)
  ✅ 事件监听

组件文件组织

components/
├── ui/                     # 基础 UI 组件(大多需要 "use client")
│   ├── button.tsx          # "use client"
│   ├── input.tsx           # "use client"
│   ├── dialog.tsx          # "use client"
│   └── ...
├── layout/                 # 布局组件
│   ├── header.tsx          # Server Component(数据获取)
│   ├── footer.tsx          # Server Component
│   ├── sidebar.tsx         # Server Component
│   └── theme-toggle.tsx    # "use client"(交互)
├── article/                # 文章相关
│   ├── article-list.tsx    # Server Component(数据获取 + 渲染)
│   ├── article-card.tsx    # Server Component(纯展示)
│   ├── article-form.tsx    # "use client"(表单交互)
│   └── like-button.tsx     # "use client"(交互 + 乐观更新)
└── common/
    ├── loading-skeleton.tsx # Server Component
    └── error-boundary.tsx   # "use client"(Error Boundary)

边界处理模式

模式一:Server Component 包裹 Client Component

// app/articles/page.tsx(Server Component)

import { getArticles } from '@/lib/services/article';
import { ArticleCard } from '@/components/article/article-card';
import { ArticleFilter } from '@/components/article/article-filter'; // Client Component

export default async function ArticlesPage({
  searchParams,
}: {
  searchParams: Promise<{ category?: string; q?: string }>;
}) {
  const params = await searchParams;

  // 服务端数据获取
  const { articles, pagination } = await getArticles({
    category: params.category,
    search: params.q,
  });

  return (
    <div>
      {/* Client Component:筛选器(需要交互) */}
      <ArticleFilter
        currentCategory={params.category}
        currentQuery={params.q}
      />

      {/* Server Component:文章列表(纯渲染) */}
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {articles.map((article) => (
          <ArticleCard key={article.id} article={article} />
        ))}
      </div>
    </div>
  );
}

模式二:Slot / Children 注入

// components/article/article-card.tsx(Server Component)

import Link from 'next/link';
import { LikeButton } from './like-button'; // Client Component

type ArticleCardProps = {
  article: {
    id: string;
    title: string;
    slug: string;
    excerpt: string;
    _count: { likes: number };
  };
};

export function ArticleCard({ article }: ArticleCardProps) {
  return (
    <div className="card p-5">
      <Link href={`/articles/${article.slug}`}>
        <h3 className="font-semibold text-lg">{article.title}</h3>
      </Link>
      <p className="mt-2 text-sm text-muted-foreground">{article.excerpt}</p>

      {/* Client Component 嵌入 Server Component */}
      <div className="mt-4">
        <LikeButton
          articleId={article.id}
          initialCount={article._count.likes}
        />
      </div>
    </div>
  );
}
// components/article/like-button.tsx(Client Component)
'use client';

import { useState, useTransition } from 'react';
import { toggleLike } from '@/app/actions/like';
import { Button } from '@/components/ui/button';

export function LikeButton({
  articleId,
  initialCount,
}: {
  articleId: string;
  initialCount: number;
}) {
  const [liked, setLiked] = useState(false);
  const [count, setCount] = useState(initialCount);
  const [isPending, startTransition] = useTransition();

  function handleClick() {
    setLiked(!liked);
    setCount(liked ? count - 1 : count + 1);

    startTransition(async () => {
      await toggleLike(articleId);
    });
  }

  return (
    <Button
      variant="ghost"
      size="sm"
      onClick={handleClick}
      disabled={isPending}
    >
      {liked ? '❤️' : '🤍'} {count}
    </Button>
  );
}

14.5 业务组件封装

组件设计原则

  1. 单一职责:每个组件只做一件事
  2. Props 驱动:通过 props 控制行为与外观
  3. 组合优于配置:使用 children / slots 代替大量 boolean props
  4. 类型安全:所有 props 有完整的 TypeScript 类型
  5. 可访问性:使用语义化 HTML、ARIA 属性

用户头像组件

// components/common/user-avatar.tsx

import { cn } from '@/lib/utils';
import Image from 'next/image';

type UserAvatarProps = {
  src?: string | null;
  name?: string | null;
  size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
  className?: string;
  showStatus?: boolean;
  status?: 'online' | 'offline' | 'busy';
};

const sizeMap = {
  xs: 'h-6 w-6 text-2xs',
  sm: 'h-8 w-8 text-xs',
  md: 'h-10 w-10 text-sm',
  lg: 'h-12 w-12 text-base',
  xl: 'h-16 w-16 text-lg',
};

const statusColorMap = {
  online: 'bg-green-500',
  offline: 'bg-gray-400',
  busy: 'bg-red-500',
};

export function UserAvatar({
  src,
  name,
  size = 'md',
  className,
  showStatus = false,
  status = 'offline',
}: UserAvatarProps) {
  const initials = name
    ? name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
    : '?';

  return (
    <div className={cn('relative inline-flex', className)}>
      {src ? (
        <Image
          src={src}
          alt={name || 'Avatar'}
          width={64}
          height={64}
          className={cn(
            'rounded-full object-cover',
            sizeMap[size]
          )}
        />
      ) : (
        <div
          className={cn(
            'rounded-full bg-primary/10 text-primary font-medium flex items-center justify-center',
            sizeMap[size]
          )}
        >
          {initials}
        </div>
      )}

      {showStatus && (
        <span
          className={cn(
            'absolute bottom-0 right-0 block rounded-full ring-2 ring-background',
            statusColorMap[status],
            size === 'xs' || size === 'sm' ? 'h-2 w-2' : 'h-3 w-3'
          )}
        />
      )}
    </div>
  );
}

空状态组件

// components/common/empty-state.tsx

import { cn } from '@/lib/utils';
import Link from 'next/link';

type EmptyStateProps = {
  icon?: React.ReactNode;
  title: string;
  description?: string;
  action?: {
    label: string;
    href?: string;
    onClick?: () => void;
  };
  className?: string;
};

export function EmptyState({
  icon,
  title,
  description,
  action,
  className,
}: EmptyStateProps) {
  const ActionButton = action ? (
    action.href ? (
      <Link
        href={action.href}
        className="btn-primary btn-sm mt-4 inline-flex"
      >
        {action.label}
      </Link>
    ) : (
      <button
        onClick={action.onClick}
        className="btn-primary btn-sm mt-4"
      >
        {action.label}
      </button>
    )
  ) : null;

  return (
    <div className={cn('flex flex-col items-center justify-center py-16 text-center', className)}>
      {icon && (
        <div className="mb-4 text-muted-foreground">
          {icon}
        </div>
      )}
      <h3 className="text-lg font-semibold">{title}</h3>
      {description && (
        <p className="mt-2 text-sm text-muted-foreground max-w-sm">
          {description}
        </p>
      )}
      {ActionButton}
    </div>
  );
}

数据表格组件

// components/common/data-table.tsx(Client Component)
'use client';

import { useState } from 'react';
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';

type Column<T> = {
  key: keyof T | string;
  header: string;
  render?: (value: any, row: T) => React.ReactNode;
  sortable?: boolean;
};

type DataTableProps<T> = {
  data: T[];
  columns: Column<T>[];
  searchable?: boolean;
  searchPlaceholder?: string;
  searchKey?: keyof T;
  actions?: {
    label: string;
    onClick: (row: T) => void;
    variant?: 'default' | 'destructive';
  }[];
};

export function DataTable<T extends Record<string, any>>({
  data,
  columns,
  searchable = false,
  searchPlaceholder = '搜索...',
  searchKey,
  actions = [],
}: DataTableProps<T>) {
  const [search, setSearch] = useState('');
  const [sortKey, setSortKey] = useState<string | null>(null);
  const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');

  // 过滤
  const filtered = search && searchKey
    ? data.filter((row) =>
        String(row[searchKey]).toLowerCase().includes(search.toLowerCase())
      )
    : data;

  // 排序
  const sorted = sortKey
    ? [...filtered].sort((a, b) => {
        const aVal = a[sortKey];
        const bVal = b[sortKey];
        const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
        return sortOrder === 'asc' ? cmp : -cmp;
      })
    : filtered;

  function handleSort(key: string) {
    if (sortKey === key) {
      setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
    } else {
      setSortKey(key);
      setSortOrder('asc');
    }
  }

  return (
    <div>
      {searchable && (
        <div className="mb-4">
          <Input
            placeholder={searchPlaceholder}
            value={search}
            onChange={(e) => setSearch(e.target.value)}
            className="max-w-sm"
          />
        </div>
      )}

      <div className="rounded-md border">
        <Table>
          <TableHeader>
            <TableRow>
              {columns.map((col) => (
                <TableHead
                  key={String(col.key)}
                  className={col.sortable ? 'cursor-pointer select-none' : ''}
                  onClick={() => col.sortable && handleSort(String(col.key))}
                >
                  {col.header}
                  {sortKey === String(col.key) && (
                    <span className="ml-1">
                      {sortOrder === 'asc' ? '↑' : '↓'}
                    </span>
                  )}
                </TableHead>
              ))}
              {actions.length > 0 && <TableHead>操作</TableHead>}
            </TableRow>
          </TableHeader>
          <TableBody>
            {sorted.length === 0 ? (
              <TableRow>
                <TableCell
                  colSpan={columns.length + (actions.length > 0 ? 1 : 0)}
                  className="text-center py-8 text-muted-foreground"
                >
                  暂无数据
                </TableCell>
              </TableRow>
            ) : (
              sorted.map((row, i) => (
                <TableRow key={i}>
                  {columns.map((col) => (
                    <TableCell key={String(col.key)}>
                      {col.render
                        ? col.render(row[col.key as keyof T], row)
                        : String(row[col.key as keyof T] ?? '')}
                    </TableCell>
                  ))}
                  {actions.length > 0 && (
                    <TableCell>
                      <DropdownMenu>
                        <DropdownMenuTrigger asChild>
                          <Button variant="ghost" size="sm">
                            
                          </Button>
                        </DropdownMenuTrigger>
                        <DropdownMenuContent align="end">
                          {actions.map((action, j) => (
                            <DropdownMenuItem
                              key={j}
                              onClick={() => action.onClick(row)}
                              className={action.variant === 'destructive' ? 'text-destructive' : ''}
                            >
                              {action.label}
                            </DropdownMenuItem>
                          ))}
                        </DropdownMenuContent>
                      </DropdownMenu>
                    </TableCell>
                  )}
                </TableRow>
              ))
            )}
          </TableBody>
        </Table>
      </div>
    </div>
  );
}

使用业务组件

// app/dashboard/articles/page.tsx

import { prisma } from '@/lib/prisma';
import { DataTable } from '@/components/common/data-table';
import { EmptyState } from '@/components/common/empty-state';
import { Badge } from '@/components/ui/badge';
import Link from 'next/link';

export default async function DashboardArticlesPage() {
  const articles = await prisma.article.findMany({
    orderBy: { createdAt: 'desc' },
    include: {
      _count: { select: { comments: true, likes: true } },
    },
  });

  if (articles.length === 0) {
    return (
      <EmptyState
        title="还没有文章"
        description="创建你的第一篇文章,开始分享你的想法。"
        action={{ label: '创建文章', href: '/dashboard/articles/new' }}
      />
    );
  }

  return (
    <DataTable
      data={articles}
      searchable
      searchKey="title"
      searchPlaceholder="搜索文章标题..."
      columns={[
        {
          key: 'title',
          header: '标题',
          sortable: true,
          render: (_, row) => (
            <Link
              href={`/dashboard/articles/${row.id}`}
              className="font-medium hover:text-primary"
            >
              {row.title}
            </Link>
          ),
        },
        {
          key: 'published',
          header: '状态',
          render: (val) => (
            <Badge variant={val ? 'success' : 'secondary'}>
              {val ? '已发布' : '草稿'}
            </Badge>
          ),
        },
        {
          key: 'views',
          header: '阅读量',
          sortable: true,
        },
        {
          key: 'createdAt',
          header: '创建时间',
          sortable: true,
          render: (val) => new Date(val).toLocaleDateString('zh-CN'),
        },
      ]}
      actions={[
        { label: '编辑', onClick: (row) => console.log('Edit:', row.id) },
        { label: '查看', onClick: (row) => console.log('View:', row.slug) },
        { label: '删除', onClick: (row) => console.log('Delete:', row.id), variant: 'destructive' },
      ]}
    />
  );
}

14.6 MUI 在 Next.js 中的集成

如果你选择 MUI,需要注意 RSC 兼容性:

安装

npm install @mui/material @emotion/react @emotion/styled
npm install @mui/material-nextjs @emotion/cache

配置 App Router 兼容

// app/theme.tsx
'use client';

import { createTheme } from '@mui/material/styles';

const theme = createTheme({
  palette: {
    primary: {
      main: '#3b82f6',
    },
  },
  typography: {
    fontFamily: 'Inter, PingFang SC, sans-serif',
  },
});

export default theme;
// app/theme-registry.tsx
'use client';

import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import theme from './theme';

export function ThemeRegistry({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider theme={theme}>
      <CssBaseline />
      {children}
    </ThemeProvider>
  );
}
// app/layout.tsx

import { ThemeRegistry } from './theme-registry';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="zh-CN">
      <body>
        <ThemeRegistry>{children}</ThemeRegistry>
      </body>
    </html>
  );
}

MUI 组件必须标记为 Client

// components/MuiButton.tsx
'use client';  // 必须!

import Button from '@mui/material/Button';

export function MuiButton({ children }: { children: React.ReactNode }) {
  return <Button variant="contained">{children}</Button>;
}

14.7 Ant Design 在 Next.js 中的集成

安装

npm install antd @ant-design/cssinjs

配置

// app/antd-registry.tsx
'use client';

import { StyleProvider } from '@ant-design/cssinjs';
import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';

export function AntdRegistry({ children }: { children: React.ReactNode }) {
  return (
    <StyleProvider hashPriority="high">
      <ConfigProvider
        locale={zhCN}
        theme={{
          token: {
            colorPrimary: '#3b82f6',
            borderRadius: 8,
          },
        }}
      >
        {children}
      </ConfigProvider>
    </StyleProvider>
  );
}

使用 Ant Design 组件

// app/dashboard/page.tsx
'use client';

import { Table, Button, Space, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';

const columns: ColumnsType<any> = [
  {
    title: '标题',
    dataIndex: 'title',
    key: 'title',
    sorter: true,
  },
  {
    title: '状态',
    dataIndex: 'published',
    key: 'published',
    render: (val: boolean) => (
      <Tag color={val ? 'green' : 'default'}>
        {val ? '已发布' : '草稿'}
      </Tag>
    ),
  },
  {
    title: '操作',
    key: 'action',
    render: (_, record) => (
      <Space>
        <Button type="link">编辑</Button>
        <Button type="link" danger>删除</Button>
      </Space>
    ),
  },
];

export default function DashboardPage() {
  return (
    <Table columns={columns} dataSource={[]} rowKey="id" />
  );
}

14.8 Storybook 文档化

安装

npx storybook@latest init

配置 Tailwind

// .storybook/preview.ts

import '../app/globals.css';
import type { Preview } from '@storybook/react';

const preview: Preview = {
  parameters: {
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
  },
};

export default preview;

编写 Story

// components/ui/button.stories.tsx

import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './button';

const meta: Meta<typeof Button> = {
  title: 'UI/Button',
  component: Button,
  parameters: {
    layout: 'centered',
  },
  tags: ['autodocs'],
  argTypes: {
    variant: {
      control: 'select',
      options: ['default', 'destructive', 'outline', 'secondary', 'ghost', 'link'],
    },
    size: {
      control: 'select',
      options: ['default', 'sm', 'lg', 'icon'],
    },
  },
};

export default meta;
type Story = StoryObj<typeof Button>;

// 默认变体
export const Default: Story = {
  args: {
    children: '按钮',
  },
};

// 所有变体
export const Variants: Story = {
  render: () => (
    <div className="flex flex-wrap gap-4">
      <Button variant="default">Default</Button>
      <Button variant="secondary">Secondary</Button>
      <Button variant="outline">Outline</Button>
      <Button variant="ghost">Ghost</Button>
      <Button variant="destructive">Destructive</Button>
      <Button variant="link">Link</Button>
    </div>
  ),
};

// 所有尺寸
export const Sizes: Story = {
  render: () => (
    <div className="flex items-center gap-4">
      <Button size="sm">Small</Button>
      <Button size="default">Default</Button>
      <Button size="lg">Large</Button>
      <Button size="icon">🔍</Button>
    </div>
  ),
};

// 禁用状态
export const Disabled: Story = {
  args: {
    children: '禁用按钮',
    disabled: true,
  },
};

// 加载状态
export const Loading: Story = {
  args: {
    children: '加载中...',
    disabled: true,
  },
};

业务组件 Story

// components/common/user-avatar.stories.tsx

import type { Meta, StoryObj } from '@storybook/react';
import { UserAvatar } from './user-avatar';

const meta: Meta<typeof UserAvatar> = {
  title: 'Common/UserAvatar',
  component: UserAvatar,
  tags: ['autodocs'],
};

export default meta;
type Story = StoryObj<typeof UserAvatar>;

export const WithImage: Story = {
  args: {
    src: 'https://i.pravatar.cc/150?u=alice',
    name: 'Alice Chen',
    size: 'lg',
  },
};

export const WithInitials: Story = {
  args: {
    name: 'Zhang San',
    size: 'lg',
  },
};

export const WithStatus: Story = {
  args: {
    name: 'Li Si',
    size: 'lg',
    showStatus: true,
    status: 'online',
  },
};

export const AllSizes: Story = {
  render: () => (
    <div className="flex items-end gap-4">
      <UserAvatar name="XS" size="xs" />
      <UserAvatar name="SM" size="sm" />
      <UserAvatar name="MD" size="md" />
      <UserAvatar name="LG" size="lg" />
      <UserAvatar name="XL" size="xl" />
    </div>
  ),
};

14.9 设计系统落地

设计系统层次结构

设计系统层次:

1. Design Token(tailwind.config.ts + CSS 变量)
   → 颜色、字体、间距、圆角、阴影

2. 基础组件(components/ui/)
   → Button、Input、Card、Dialog、Table
   → 基于 Radix UI + Tailwind + CVA

3. 业务组件(components/[feature]/)
   → ArticleCard、CommentList、UserAvatar
   → 组合基础组件 + 业务逻辑

4. 页面模板(app/[route]/page.tsx)
   → 组合业务组件 + 数据获取

5. 布局(app/[route]/layout.tsx)
   → 导航栏、侧边栏、页脚

Token 文档化

// app/design/page.tsx(设计系统文档页面)

export default function DesignSystemPage() {
  return (
    <div className="max-w-4xl mx-auto py-12 px-4 space-y-16">
      {/* 颜色 */}
      <section>
        <h2 className="text-2xl font-bold mb-6">颜色系统</h2>

        <div className="space-y-4">
          <div>
            <h3 className="text-sm font-medium mb-2">品牌色</h3>
            <div className="flex gap-2">
              {[50, 100, 200, 300, 400, 500, 600, 700, 800, 900].map((shade) => (
                <div key={shade} className="text-center">
                  <div className={`w-12 h-12 rounded-lg bg-brand-${shade}`} />
                  <span className="text-xs mt-1">{shade}</span>
                </div>
              ))}
            </div>
          </div>
        </div>
      </section>

      {/* 字体 */}
      <section>
        <h2 className="text-2xl font-bold mb-6">字体系统</h2>
        <div className="space-y-4">
          <p className="font-display text-5xl">Display - 大标题</p>
          <p className="font-sans text-base">Sans - 正文内容</p>
          <p className="font-mono text-sm">Mono - 代码片段</p>
        </div>
      </section>

      {/* 间距 */}
      <section>
        <h2 className="text-2xl font-bold mb-6">间距系统</h2>
        <div className="space-y-2">
          {[1, 2, 4, 8, 12, 16, 20, 24, 32].map((space) => (
            <div key={space} className="flex items-center gap-4">
              <span className="text-sm w-8">{space}</span>
              <div
                className="h-4 bg-primary rounded"
                style={{ width: `${space * 4}px` }}
              />
            </div>
          ))}
        </div>
      </section>

      {/* 按钮 */}
      <section>
        <h2 className="text-2xl font-bold mb-6">按钮</h2>
        <div className="flex flex-wrap gap-4">
          <button className="btn-primary btn-md">Primary</button>
          <button className="btn-secondary btn-md">Secondary</button>
          <button className="btn-outline btn-md">Outline</button>
          <button className="btn-ghost btn-md">Ghost</button>
          <button className="btn-destructive btn-md">Destructive</button>
        </div>
      </section>
    </div>
  );
}

14.10 组件测试

使用 React Testing Library

npm install -D @testing-library/react @testing-library/jest-dom jest jest-environment-jsdom
// components/ui/__tests__/button.test.tsx

import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from '../button';

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

  it('applies variant classes', () => {
    render(<Button variant="destructive">Delete</Button>);
    const button = screen.getByText('Delete');
    expect(button.className).toContain('bg-destructive');
  });

  it('applies size classes', () => {
    render(<Button size="lg">Large</Button>);
    const button = screen.getByText('Large');
    expect(button.className).toContain('h-10');
  });

  it('handles click events', () => {
    const onClick = jest.fn();
    render(<Button onClick={onClick}>Click</Button>);
    fireEvent.click(screen.getByText('Click'));
    expect(onClick).toHaveBeenCalledTimes(1);
  });

  it('is disabled when disabled prop is true', () => {
    render(<Button disabled>Disabled</Button>);
    expect(screen.getByText('Disabled')).toBeDisabled();
  });
});

本章小结

Key Takeaways

  1. 组件库选型取决于项目需求:shadcn/ui(现代 / 可控)、MUI(Material 风格)、Ant Design(企业后台)
  2. shadcn/ui 是 Next.js App Router 的最佳搭档:RSC 兼容、零运行时、源码可控
  3. CVA 是管理组件变体的最佳工具:类型安全、声明式、支持复合变体
  4. RSC/Client 边界是组件设计的核心问题:Server Component 负责数据获取,Client Component 负责交互
  5. 业务组件应该封装领域逻辑:UserAvatar、DataTable、EmptyState 等
  6. Storybook 是组件文档化的标准工具:自动生成文档、交互式预览

下一步

下一章我们将深入 状态管理——对比 Zustand、Jotai、Redux Toolkit 在 Next.js App Router 中的适用场景,并构建一个完整的状态管理方案。


参考资料

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页