本章目标:建立一套适合 Next.js App Router 的组件工程体系——从组件库选型、RSC/Client Component 边界处理,到 CVA 变体设计、业务组件封装、Storybook 文档化,最终形成可复用的组件体系。
14.1 组件库选型对比
三大主流组件库对比
| 维度 | shadcn/ui | MUI (Material UI) | Ant Design |
|---|---|---|---|
| 本质 | 组件源码集合 | 传统 npm 包 | 传统 npm 包 |
| 包体积 | 零(代码复制到项目) | 较大(~150KB gzip) | 较大(~200KB gzip) |
| CSS 方案 | Tailwind CSS | Emotion (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
理由:
- RSC 原生兼容:不需要在每个组件上添加
"use client" - 零运行时开销:基于 Tailwind,无 CSS-in-JS 运行时
- 源码可控:组件代码直接复制到项目中,可随意修改
- CVA 变体系统:类型安全的组件变体管理
- 与 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 业务组件封装
组件设计原则
- 单一职责:每个组件只做一件事
- Props 驱动:通过 props 控制行为与外观
- 组合优于配置:使用 children / slots 代替大量 boolean props
- 类型安全:所有 props 有完整的 TypeScript 类型
- 可访问性:使用语义化 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
- 组件库选型取决于项目需求:shadcn/ui(现代 / 可控)、MUI(Material 风格)、Ant Design(企业后台)
- shadcn/ui 是 Next.js App Router 的最佳搭档:RSC 兼容、零运行时、源码可控
- CVA 是管理组件变体的最佳工具:类型安全、声明式、支持复合变体
- RSC/Client 边界是组件设计的核心问题:Server Component 负责数据获取,Client Component 负责交互
- 业务组件应该封装领域逻辑:UserAvatar、DataTable、EmptyState 等
- Storybook 是组件文档化的标准工具:自动生成文档、交互式预览
下一步
下一章我们将深入 状态管理——对比 Zustand、Jotai、Redux Toolkit 在 Next.js App Router 中的适用场景,并构建一个完整的状态管理方案。
参考资料
- shadcn/ui 官方文档
- class-variance-authority
- Radix UI Primitives
- MUI + Next.js
- Ant Design + Next.js
- Storybook for Next.js
- React Testing Library
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。