设计系统与组件库
What — 什么是设计系统
设计系统(Design System)是一套完整的设计标准和组件集合,包含设计原则、设计令牌、组件库、模式库和文档。它确保产品在不同页面、不同团队、不同平台间保持视觉和交互一致性。
设计系统的层次
设计系统
├── 设计原则(Design Principles) — 为什么这样设计
├── 设计令牌(Design Tokens) — 最小的设计决策单元
├── 组件库(Component Library) — 可复用的 UI 组件
├── 模式库(Pattern Library) — 组件的组合模式
└── 文档(Documentation) — 使用指南和最佳实践
设计令牌(Design Tokens)
设计令牌是设计系统的原子单位——一个颜色值、一个间距值、一个字号,都是一个 Token。
// design-tokens.json
{
"color": {
"primary": { "value": "#3b82f6" },
"secondary": { "value": "#6b7280" },
"success": { "value": "#10b981" },
"danger": { "value": "#ef4444" },
"warning": { "value": "#f59e0b" },
"text": {
"primary": { "value": "#1f2937" },
"secondary": { "value": "#6b7280" },
"disabled": { "value": "#9ca3af" }
},
"background": {
"primary": { "value": "#ffffff" },
"secondary": { "value": "#f9fafb" },
"elevated": { "value": "#ffffff" }
}
},
"spacing": {
"xs": { "value": "4px" },
"sm": { "value": "8px" },
"md": { "value": "16px" },
"lg": { "value": "24px" },
"xl": { "value": "32px" }
},
"typography": {
"fontSize": {
"xs": { "value": "12px" },
"sm": { "value": "14px" },
"base": { "value": "16px" },
"lg": { "value": "18px" },
"xl": { "value": "20px" },
"2xl": { "value": "24px" }
},
"fontWeight": {
"normal": { "value": "400" },
"medium": { "value": "500" },
"semibold": { "value": "600" },
"bold": { "value": "700" }
},
"lineHeight": {
"tight": { "value": "1.25" },
"normal": { "value": "1.5" },
"relaxed": { "value": "1.75" }
}
},
"borderRadius": {
"sm": { "value": "4px" },
"md": { "value": "8px" },
"lg": { "value": "12px" },
"full": { "value": "9999px" }
},
"shadow": {
"sm": { "value": "0 1px 2px rgba(0,0,0,0.05)" },
"md": { "value": "0 4px 6px rgba(0,0,0,0.1)" },
"lg": { "value": "0 10px 15px rgba(0,0,0,0.1)" }
}
}
Token 的三层架构
| 层级 | 说明 | 示例 |
|---|---|---|
| Global Tokens | 全局原始值 | blue-500: #3b82f6 |
| Alias Tokens | 语义化别名 | color-primary: {blue-500} |
| Component Tokens | 组件级变量 | button-bg-primary: {color-primary} |
/* Global → Alias → Component */
:root {
/* Global */
--blue-500: #3b82f6;
--gray-100: #f3f4f6;
--spacing-md: 16px;
/* Alias */
--color-primary: var(--blue-500);
--color-surface: var(--gray-100);
--spacing-comfortable: var(--spacing-md);
/* Component */
--button-bg: var(--color-primary);
--button-padding: var(--spacing-comfortable);
--card-surface: var(--color-surface);
}
Why — 为什么需要设计系统
1. 一致性
没有设计系统时,不同开发者可能用不同的蓝色(#3b82f6 vs #2563eb vs #4f86f7)、不同的间距(12px vs 14px vs 16px),产品视觉碎片化。
2. 效率
设计系统提供预制组件,开发者无需从零构建每个按钮、表单、弹窗。统计显示,使用设计系统可以减少 30-50% 的前端开发时间。
3. 协作
设计系统和开发共享同一套 Token,设计师在 Figma 中用的颜色值和开发在代码中用的完全一致,消除”设计稿与实现不一致”的问题。
4. 可扩展性
新增主题(暗色模式、品牌定制)只需修改 Token 值,无需修改组件代码。
对比有无设计系统
| 维度 | 无设计系统 | 有设计系统 |
|---|---|---|
| 视觉一致性 | 低 | 高 |
| 开发速度 | 慢(重复造轮子) | 快(组件复用) |
| 设计-开发协作 | 反复对齐 | 共享 Token |
| 主题扩展 | 逐个修改 | 改 Token 全局生效 |
| 新人上手 | 慢 | 快(文档 + 组件) |
| 维护成本 | 高(分散修改) | 低(集中管理) |
How — 怎么构建
1. 从 Token 开始
npm install style-dictionary
// style-dictionary.config.js
module.exports = {
source: ['tokens/**/*.json'],
platforms: {
css: {
transformGroup: 'css',
buildPath: 'dist/',
files: [{
destination: 'tokens.css',
format: 'css/variables',
options: { outputReferences: true }
}]
},
scss: {
transformGroup: 'scss',
buildPath: 'dist/',
files: [{
destination: 'tokens.scss',
format: 'scss/variables',
options: { outputReferences: true }
}]
},
js: {
transformGroup: 'js',
buildPath: 'dist/',
files: [{
destination: 'tokens.js',
format: 'javascript/es6'
}]
}
}
}
npx style-dictionary build
产物:
/* dist/tokens.css */
:root {
--color-primary: #3b82f6;
--color-secondary: #6b7280;
--spacing-md: 16px;
--font-size-base: 16px;
--border-radius-md: 8px;
--shadow-md: 0 4px 6px rgba(0,0,0,0.1);
}
2. 组件库架构
my-ui/
├── packages/
│ ├── tokens/ — 设计令牌
│ ├── core/ — 基础工具(合并类名、主题上下文)
│ ├── components/ — 组件源码
│ │ ├── Button/
│ │ │ ├── Button.tsx
│ │ │ ├── Button.test.tsx
│ │ │ ├── Button.stories.tsx
│ │ │ └── index.ts
│ │ ├── Input/
│ │ ├── Modal/
│ │ └── index.ts — 统一导出
│ └── docs/ — 文档站点
├── scripts/
├── package.json
└── tsconfig.json
3. 组件设计原则
API 设计——变体(Variants):
// 使用 CVA (Class Variance Authority) 管理变体
import { cva, type VariantProps } from 'class-variance-authority'
const buttonVariants = cva(
// 基础样式
'inline-flex items-center justify-center font-medium transition-colors focus:outline-none focus:ring-2',
{
variants: {
variant: {
primary: 'bg-blue-500 text-white hover:bg-blue-600 focus:ring-blue-300',
secondary: 'bg-gray-100 text-gray-700 hover:bg-gray-200 focus:ring-gray-300',
outline: 'border border-gray-300 text-gray-700 hover:bg-gray-50 focus:ring-gray-300',
danger: 'bg-red-500 text-white hover:bg-red-600 focus:ring-red-300',
ghost: 'text-gray-600 hover:bg-gray-100 focus:ring-gray-300',
},
size: {
sm: 'h-8 px-3 text-sm rounded',
md: 'h-10 px-4 text-sm rounded-md',
lg: 'h-12 px-6 text-base rounded-lg',
},
fullWidth: {
true: 'w-full',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
)
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
loading?: boolean
}
function Button({ variant, size, fullWidth, loading, className, children, ...props }: ButtonProps) {
return (
<button
className={buttonVariants({ variant, size, fullWidth, className })}
disabled={loading || props.disabled}
{...props}
>
{loading && <Spinner className="mr-2 h-4 w-4 animate-spin" />}
{children}
</button>
)
}
Composition 模式——组件组合:
// ❌ 过度配置化:所有功能塞进一个组件
<Modal
title="Confirm"
body="Are you sure?"
showCloseButton
closeOnOverlay
footer={<Button>OK</Button>}
/>
// ✅ 组合模式:各部分独立
<Modal>
<Modal.Header>
<Modal.Title>Confirm</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>Are you sure?</Modal.Body>
<Modal.Footer>
<Button variant="secondary">Cancel</Button>
<Button variant="primary">Confirm</Button>
</Modal.Footer>
</Modal>
4. 主题系统
// ThemeProvider
const defaultTheme = {
colors: {
primary: 'var(--color-primary)',
secondary: 'var(--color-secondary)',
success: 'var(--color-success)',
danger: 'var(--color-danger)',
text: 'var(--color-text-primary)',
background: 'var(--color-background-primary)',
},
spacing: {
xs: 'var(--spacing-xs)',
sm: 'var(--spacing-sm)',
md: 'var(--spacing-md)',
lg: 'var(--spacing-lg)',
xl: 'var(--spacing-xl)',
},
borderRadius: {
sm: 'var(--border-radius-sm)',
md: 'var(--border-radius-md)',
lg: 'var(--border-radius-lg)',
},
}
const ThemeContext = createContext(defaultTheme)
function ThemeProvider({ theme, children }) {
return (
<ThemeContext.Provider value={{ ...defaultTheme, ...theme }}>
{children}
</ThemeContext.Provider>
)
}
function useTheme() {
return useContext(ThemeContext)
}
暗色模式:
/* tokens.css */
:root {
--color-primary: #3b82f6;
--color-text-primary: #1f2937;
--color-background-primary: #ffffff;
}
[data-theme="dark"] {
--color-primary: #60a5fa;
--color-text-primary: #f3f4f6;
--color-background-primary: #1f2937;
}
function ThemeSwitcher() {
const [theme, setTheme] = useState('light')
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
}, [theme])
return (
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
{theme === 'light' ? 'Dark' : 'Light'}
</button>
)
}
5. 无障碍(Accessibility)
function Dialog({ open, onClose, title, children }) {
const dialogRef = useRef<HTMLDialogElement>(null)
useEffect(() => {
if (open) {
dialogRef.current?.showModal()
} else {
dialogRef.current?.close()
}
}, [open])
return (
<dialog
ref={dialogRef}
onClose={onClose}
aria-labelledby="dialog-title"
aria-modal="true"
className="rounded-lg p-0 shadow-lg"
>
<div className="p-6">
<h2 id="dialog-title" className="text-lg font-semibold">{title}</h2>
<div className="mt-4">{children}</div>
</div>
</dialog>
)
}
组件库的 A11y 清单:
| 检查项 | 说明 |
|---|---|
| 键盘导航 | 所有交互可用 Tab/Enter/Space/Escape 操作 |
| ARIA 属性 | 正确使用 role、aria-label、aria-describedby |
| 焦点管理 | 弹窗打开时聚焦到弹窗,关闭后回到触发元素 |
| 颜色对比度 | 文字与背景对比度 ≥ 4.5:1(WCAG AA) |
| 屏幕阅读器 | 使用 VoiceOver/NVDA 测试 |
| 减少动画 | prefers-reduced-motion 时禁用动画 |
6. 文档与 Storybook
npx storybook@latest init
// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
import { Button } from './Button'
const meta: Meta<typeof Button> = {
title: 'Components/Button',
component: Button,
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'outline', 'danger', 'ghost'],
},
size: {
control: 'select',
options: ['sm', 'md', 'lg'],
},
},
}
export default meta
type Story = StoryObj<typeof Button>
export const Primary: Story = {
args: { variant: 'primary', children: 'Primary' },
}
export const AllVariants: Story = {
render: () => (
<div className="flex gap-4">
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="danger">Danger</Button>
<Button variant="ghost">Ghost</Button>
</div>
),
}
7. 主流组件库对比
| 维度 | Ant Design | Element Plus | Radix UI | Headless UI | shadcn/ui |
|---|---|---|---|---|---|
| 框架 | React | Vue | React | React/Vue | React |
| 样式 | CSS-in-JS | SCSS | 无样式 | 无样式 | Tailwind |
| 定制性 | 中 | 中 | 极高 | 高 | 极高 |
| 开箱即用 | 是 | 是 | 否 | 否 | 是 |
| 体积 | 大(~600KB) | 中(~300KB) | 小(按需) | 小(按需) | 小(按需) |
| A11y | 好 | 一般 | 优秀 | 优秀 | 优秀 |
| 设计风格 | 企业级 | 简约 | 无 | 无 | 简约现代 |
shadcn/ui 的独特模式:
shadcn/ui 不是 npm 包,而是 CLI 工具,直接将组件源码复制到你的项目中,你完全拥有代码,可以随意修改。
npx shadcn@latest init
npx shadcn@latest add button dialog table
常见问题与踩坑
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 设计与开发不一致 | 设计师和开发用不同的色值 | 共享 Design Token,Figma 插件同步 |
| 组件定制困难 | 组件内部样式不可覆盖 | 使用 CSS 变量 + ::part() 暴露钩子 |
| 体积过大 | 引入整个组件库 | 按需导入(Tree Shaking) |
| 版本升级破坏 | 组件 API 变更 | 语义化版本 + 迁移指南 |
| 多框架重复开发 | 同一设计不同框架各实现一套 | 用 Web Components 做底层,或用 Headless 方案 |
最佳实践
- Token 先行:先定义设计令牌,再构建组件,确保一致性从底层开始。
- Headless 优先:用 Radix UI / Headless UI 处理无障碍和交互逻辑,只写样式。
- 组合优于配置:组件用
<Modal.Header>而非<Modal headerTitle="...">。 - CSS 变量做主题:组件内部用 CSS 变量,外部通过覆盖变量定制主题。
- 文档即代码:Storybook / Docusaurus 与组件源码同仓库,文档与代码永远同步。
面试题
1. Design Token 是什么?为什么需要三层架构(Global → Alias → Component)?
答:Design Token 是设计系统的最小决策单元,代表一个设计值(颜色、间距、字号等)。三层架构的好处:(1) Global Token(如 blue-500: #3b82f6)是原始值,方便维护色板;(2) Alias Token(如 color-primary: {blue-500})赋予语义,修改品牌色只需改别名指向;(3) Component Token(如 button-bg: {color-primary})将组件与语义绑定,主题切换时只需改 Alias 层。没有三层架构的话,改品牌色需要搜索所有 #3b82f6 逐个替换;有了三层架构,只需将 color-primary 从 blue-500 改为 indigo-500,所有使用该别名的组件自动更新。
2. shadcn/ui 和传统组件库(Ant Design、Element Plus)的核心区别是什么?
答:核心区别是分发模式。传统组件库通过 npm 安装,组件代码在 node_modules 中,你只能通过库提供的 API(props、CSS 变量、插槽)定制。shadcn/ui 通过 CLI 将组件源码直接复制到你的项目中,你完全拥有代码,可以随意修改任何细节。优势:定制无上限,不依赖库的 API 设计。劣势:无法通过 npm update 升级,需要手动合并更新。shadcn/ui 本质上不是组件库,而是”组件模板”——它把最佳实践的组件代码给你,剩下的你自己掌控。
3. Headless UI 组件是什么?为什么越来越多组件库采用这个模式?
答:Headless UI 组件只提供行为和可访问性逻辑(键盘导航、ARIA 属性、焦点管理),不提供任何样式。开发者负责视觉表现。代表:Radix UI、Headless UI、Ark UI。采用这个模式的原因:(1) 定制性无限——不受库的视觉设计约束,可以做任何风格;(2) 体积更小——只有逻辑代码,无 CSS 运行时;(3) A11y 专业——Headless 库专注处理键盘、屏幕阅读器、焦点等复杂逻辑,开发者无需成为 A11y 专家;(4) 设计系统友好——Headless 做行为,Tailwind/CSS-in-JS 做视觉,分层清晰。
4. 如何实现组件库的按需加载?
答:三种方式:(1) Tree Shaking——使用 ES Module 的 named export,构建工具自动摇树优化,前提是 package.json 中配置 "sideEffects": false;(2) 手动按需导入——import Button from 'my-ui/es/button' 直接引入组件目录,而非 import { Button } from 'my-ui';(3) babel-plugin-import——自动将 import { Button } from 'my-ui' 转换为 import Button from 'my-ui/es/button'。现代组件库(支持 ESM + Tree Shaking)通常只需方式(1),无需额外配置。
5. 组件库如何支持多主题(暗色模式、品牌定制)?
答:核心是 CSS 变量 + 语义化 Token。组件内部不硬编码颜色值,而是引用 CSS 变量(如 var(--button-bg))。主题切换只需改变量值:(1) 暗色模式——[data-theme="dark"] { --color-primary: #60a5fa; } 覆盖变量;(2) 品牌定制——不同客户加载不同的 Token 文件(brand-a.css vs brand-b.css);(3) 运行时切换——JS 修改 document.documentElement.style.setProperty('--color-primary', newValue)。关键原则:组件只消费 Alias/Component Token,不消费 Global Token,这样改主题不需要改组件代码。
6. 组件库如何保证无障碍性(Accessibility)?
答:四个层面保证:(1) 语义化 HTML——用原生 <button> 而非 <div onClick>,用 <dialog> 而非自定义弹窗;(2) ARIA 属性——aria-label、aria-expanded、aria-selected 等状态属性自动管理;(3) 键盘导航——Tab 键聚焦、Enter/Space 激活、Escape 关闭弹窗、方向键在列表中移动;(4) 自动化测试——使用 @testing-library/jest-dom 的 toBeVisible()、toHaveAttribute() 等 A11y 断言,结合 axe-core 自动检测 WCAG 违规。Headless UI 库(Radix、Headless UI)已经内置了这些逻辑,直接使用即可。
7. 如何评估一个组件库是否适合你的项目?
答:六个维度评估:(1) 框架兼容——是否支持你使用的前端框架;(2) 定制能力——是否可以覆盖样式(CSS 变量、::part()、className 合并);(3) 体积影响——打包后增量多少(用 bundlephobia.com 检查);(4) 无障碍——是否有 A11y 测试、ARIA 支持;(5) 社区与维护——GitHub star、issue 响应速度、发布频率;(6) 设计风格匹配——视觉风格是否与产品定位一致。一个常见误区是选了功能最全的库,但 80% 的组件用不到,体积反而成为负担。推荐策略:功能简单选 Headless + Tailwind,企业级后台选 Ant Design / Element Plus。
8. 设计系统如何与 Figma 协同工作?
答:Figma 与设计系统通过 Design Token 桥接:(1) Figma Variables——Figma 原生支持设计变量,可以直接映射到代码中的 CSS 变量;(2) Token 同步工具——Tokens Studio(原 Figma Tokens)插件可以在 Figma 中编辑 Token,同步到 JSON 文件,再通过 Style Dictionary 编译为 CSS/JS;(3) Figma API——自动从 Figma 文件提取组件属性和样式,生成代码骨架;(4) Storybook + Figma——Storybook 的 Figma 插件可以直接在文档中嵌入 Figma 设计稿,设计和开发对照查看。工作流:设计师在 Figma 中用 Token 变量设计 → 导出 Token JSON → 开发用 Style Dictionary 编译 → 组件使用编译后的变量。