设计系统实践
大约 8 分钟约 2443 字
设计系统实践
什么是设计系统?
设计系统(Design System)是一套完整的设计标准和组件库,包含设计令牌(Design Tokens)、基础组件、复合模式(Patterns)、主题系统和使用规范文档。它不仅仅是一套 UI 组件库,更是设计语言、交互规范和工程实践的集合体。
设计系统的核心目标是:
- 一致性 — 确保整个产品在视觉和交互上保持统一
- 效率 — 复用已验证的组件和模式,减少重复开发
- 协作 — 设计师和开发者共享同一套语言和规范
- 可扩展 — 随着产品成长,系统也能随之扩展
知名设计系统案例包括:Google Material Design、Apple Human Interface Guidelines、Ant Design、Arco Design、shadcn/ui 等。
Design Tokens — 设计系统的原子单位
Design Tokens 是设计系统中最基础的单元,定义了颜色、间距、字体、圆角、阴影等视觉变量的名称和值。它们是连接设计和代码的桥梁。
Token 分层架构
// tokens/primitives.ts — 原始 Token(设计师定义的值)
export const primitives = {
color: {
blue50: '#e6f7ff',
blue100: '#bae7ff',
blue500: '#1890ff',
blue700: '#096dd9',
blue900: '#003a8c',
red500: '#ff4d4f',
green500: '#52c41a',
yellow500: '#faad14',
gray50: '#fafafa',
gray100: '#f5f5f5',
gray200: '#e8e8e8',
gray500: '#d9d9d9',
gray700: '#8c8c8c',
gray900: '#1f1f1f',
},
spacing: {
0: '0px',
xs: '4px',
sm: '8px',
md: '16px',
lg: '24px',
xl: '32px',
'2xl': '48px',
'3xl': '64px',
},
fontSize: {
xs: '12px',
sm: '14px',
md: '16px',
lg: '18px',
xl: '20px',
'2xl': '24px',
'3xl': '30px',
'4xl': '36px',
},
borderRadius: {
sm: '2px',
md: '4px',
lg: '8px',
xl: '12px',
full: '9999px',
},
shadow: {
sm: '0 1px 2px 0 rgba(0, 0, 0, 0.06)',
md: '0 4px 12px 0 rgba(0, 0, 0, 0.1)',
lg: '0 8px 24px 0 rgba(0, 0, 0, 0.15)',
},
fontFamily: {
sans: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
mono: '"SF Mono", "Fira Code", monospace',
},
} as const;// tokens/semantic.ts — 语义 Token(业务含义)
export const semantic = {
color: {
// 品牌色
'brand-primary': primitives.color.blue500,
'brand-primary-hover': primitives.color.blue700,
'brand-primary-active': primitives.color.blue900,
'brand-primary-bg': primitives.color.blue50,
// 反馈色
'feedback-success': primitives.color.green500,
'feedback-warning': primitives.color.yellow500,
'feedback-error': primitives.color.red500,
'feedback-info': primitives.color.blue500,
// 文本色
'text-primary': primitives.color.gray900,
'text-secondary': primitives.color.gray700,
'text-disabled': primitives.color.gray500,
// 背景色
'bg-primary': '#ffffff',
'bg-secondary': primitives.color.gray50,
'bg-elevated': '#ffffff',
// 边框色
'border-primary': primitives.color.gray200,
'border-hover': primitives.color.gray500,
},
spacing: {
'input-padding-x': primitives.spacing.md,
'input-padding-y': primitives.spacing.sm,
'card-padding': primitives.spacing.lg,
},
} as const;// tokens/theme.ts — 主题映射(亮色/暗色)
export const lightTheme = {
color: {
primary: semantic.color['brand-primary'],
'primary-hover': semantic.color['brand-primary-hover'],
bg: semantic.color['bg-primary'],
'bg-secondary': semantic.color['bg-secondary'],
text: semantic.color['text-primary'],
'text-secondary': semantic.color['text-secondary'],
border: semantic.color['border-primary'],
},
} as const;
export const darkTheme = {
color: {
primary: primitives.color.blue100,
'primary-hover': primitives.color.blue50,
bg: '#1f1f1f',
'bg-secondary': '#141414',
text: '#e8e8e8',
'text-secondary': '#a0a0a0',
border: '#434343',
},
} as const;CSS 变量方式输出 Token
/* tokens.css — 通过 CSS 变量使用 */
:root {
/* 原始 Token */
--color-blue-500: #1890ff;
--color-gray-900: #1f1f1f;
--spacing-md: 16px;
--font-size-md: 16px;
--radius-md: 4px;
/* 语义 Token */
--color-primary: var(--color-blue-500);
--color-bg: #ffffff;
--color-text: var(--color-gray-900);
--color-border: #e8e8e8;
}
/* 暗色主题 */
[data-theme="dark"] {
--color-primary: #bae7ff;
--color-bg: #1f1f1f;
--color-text: #e8e8e8;
--color-border: #434343;
}
/* 组件使用 Token */
.button {
background-color: var(--color-primary);
color: var(--color-bg);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
}基础组件设计
Button 按钮
import React from 'react';
import { tokens } from '../tokens';
// 定义完整的 Props 类型
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
/** 按钮变体 */
variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'link';
/** 按钮尺寸 */
size?: 'sm' | 'md' | 'lg';
/** 是否加载中 */
loading?: boolean;
/** 图标 */
icon?: React.ReactNode;
/** 图标位置 */
iconPosition?: 'left' | 'right';
/** 是否撑满父容器 */
block?: boolean;
}
const sizeMap = {
sm: { padding: `${tokens.spacing.xs} ${tokens.spacing.sm}`, fontSize: tokens.fontSize.xs, height: '28px' },
md: { padding: `${tokens.spacing.sm} ${tokens.spacing.md}`, fontSize: tokens.fontSize.sm, height: '36px' },
lg: { padding: `${tokens.spacing.sm} ${tokens.spacing.lg}`, fontSize: tokens.fontSize.md, height: '44px' },
};
const variantMap = {
primary: {
background: 'var(--color-primary)',
color: 'var(--color-bg)',
border: 'none',
hover: { background: 'var(--color-primary-hover)' },
},
secondary: {
background: 'transparent',
color: 'var(--color-primary)',
border: '1px solid var(--color-primary)',
hover: { background: 'var(--color-brand-primary-bg)' },
},
danger: {
background: 'var(--color-feedback-error)',
color: '#fff',
border: 'none',
hover: { opacity: 0.85 },
},
ghost: {
background: 'transparent',
color: 'var(--color-text)',
border: '1px solid var(--color-border)',
hover: { background: 'var(--color-bg-secondary)' },
},
link: {
background: 'transparent',
color: 'var(--color-primary)',
border: 'none',
hover: { textDecoration: 'underline' },
},
};
// Spinner 加载动画
function Spinner({ size }: { size: 'sm' | 'md' | 'lg' }) {
const px = size === 'sm' ? 12 : size === 'md' ? 16 : 20;
return (
<svg width={px} height={px} viewBox="0 0 24 24" fill="none" className="animate-spin">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" opacity="0.25" />
<path d="M12 2a10 10 0 019.95 9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
);
}
function Button({
variant = 'primary',
size = 'md',
loading = false,
icon,
iconPosition = 'left',
block = false,
disabled,
children,
...rest
}: ButtonProps) {
const style = sizeMap[size];
const variantStyle = variantMap[variant];
return (
<button
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: tokens.spacing.xs,
...style,
...variantStyle,
width: block ? '100%' : 'auto',
cursor: disabled || loading ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
transition: 'all 0.2s ease',
borderRadius: tokens.borderRadius.md,
fontWeight: 500,
whiteSpace: 'nowrap',
}}
disabled={disabled || loading}
{...rest}
>
{loading && <Spinner size={size} />}
{!loading && icon && iconPosition === 'left' && <span>{icon}</span>}
{children}
{!loading && icon && iconPosition === 'right' && <span>{icon}</span>}
</button>
);
}Input 输入框
interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
/** 标签 */
label?: string;
/** 错误信息 */
error?: string;
/** 帮助文本 */
helpText?: string;
/** 前缀 */
prefix?: React.ReactNode;
/** 后缀 */
suffix?: React.ReactNode;
/** 尺寸 */
size?: 'sm' | 'md' | 'lg';
}
function Input({
label,
error,
helpText,
prefix,
suffix,
size = 'md',
id,
...rest
}: InputProps) {
const inputId = id || `input-${Math.random().toString(36).slice(2)}`;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: tokens.spacing.xs }}>
{label && (
<label
htmlFor={inputId}
style={{ fontSize: tokens.fontSize.sm, fontWeight: 500, color: 'var(--color-text)' }}
>
{label}
</label>
)}
<div
style={{
display: 'flex',
alignItems: 'center',
height: sizeMap[size].height,
padding: `0 ${tokens.spacing.sm}`,
border: `1px solid ${error ? 'var(--color-feedback-error)' : 'var(--color-border)'}`,
borderRadius: tokens.borderRadius.md,
background: 'var(--color-bg)',
transition: 'border-color 0.2s',
}}
>
{prefix && <span style={{ marginRight: tokens.spacing.sm }}>{prefix}</span>}
<input
id={inputId}
style={{
flex: 1,
border: 'none',
outline: 'none',
background: 'transparent',
color: 'var(--color-text)',
fontSize: sizeMap[size].fontSize,
}}
aria-invalid={!!error}
aria-describedby={error ? `${inputId}-error` : undefined}
{...rest}
/>
{suffix && <span style={{ marginLeft: tokens.spacing.sm }}>{suffix}</span>}
</div>
{error && (
<span id={`${inputId}-error`} style={{ fontSize: tokens.fontSize.xs, color: 'var(--color-feedback-error)' }}>
{error}
</span>
)}
{helpText && !error && (
<span style={{ fontSize: tokens.fontSize.xs, color: 'var(--color-text-secondary)' }}>
{helpText}
</span>
)}
</div>
);
}Card 卡片
interface CardProps {
/** 标题 */
title?: string;
/** 额外操作区域 */
extra?: React.ReactNode;
/** 是否可悬浮 */
hoverable?: boolean;
/** 内边距 */
padding?: string;
/** 加载状态 */
loading?: boolean;
children: React.ReactNode;
}
function Card({ title, extra, hoverable, padding, loading, children }: CardProps) {
return (
<div
style={{
background: 'var(--color-bg-elevated)',
border: '1px solid var(--color-border)',
borderRadius: tokens.borderRadius.lg,
overflow: 'hidden',
transition: hoverable ? 'box-shadow 0.2s, transform 0.2s' : 'none',
}}
onMouseEnter={(e) => {
if (hoverable) {
e.currentTarget.style.boxShadow = tokens.shadow.md;
e.currentTarget.style.transform = 'translateY(-2px)';
}
}}
onMouseLeave={(e) => {
if (hoverable) {
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.transform = 'none';
}
}}
>
{(title || extra) && (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: `${tokens.spacing.md} ${tokens.spacing.lg}`,
borderBottom: '1px solid var(--color-border)',
}}
>
<h3 style={{ margin: 0, fontSize: tokens.fontSize.lg, fontWeight: 600 }}>{title}</h3>
{extra}
</div>
)}
<div style={{ padding: padding || tokens.spacing.lg }}>
{loading ? (
<div style={{ textAlign: 'center', padding: tokens.spacing['2xl'] }}>
<Spinner size="md" />
</div>
) : (
children
)}
</div>
</div>
);
}复合模式(Patterns)
复合模式是基于基础组件构建的、面向特定业务场景的更高层抽象。
搜索表单模式
interface SearchFormProps {
fields: Array<{
name: string;
label: string;
type: 'text' | 'select' | 'date';
options?: Array<{ label: string; value: string }>;
}>;
onSearch: (values: Record<string, string>) => void;
onReset: () => void;
loading?: boolean;
}
function SearchForm({ fields, onSearch, onReset, loading }: SearchFormProps) {
const [values, setValues] = useState<Record<string, string>>({});
const handleSearch = () => onSearch(values);
const handleReset = () => {
setValues({});
onReset();
};
return (
<Card>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: tokens.spacing.md }}>
{fields.map((field) => (
<div key={field.name} style={{ minWidth: '200px', flex: 1 }}>
{field.type === 'select' ? (
<select
value={values[field.name] || ''}
onChange={(e) => setValues({ ...values, [field.name]: e.target.value })}
aria-label={field.label}
>
<option value="">请选择{field.label}</option>
{field.options?.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
) : (
<Input
label={field.label}
value={values[field.name] || ''}
onChange={(e) => setValues({ ...values, [field.name]: e.target.value })}
/>
)}
</div>
))}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: tokens.spacing.sm }}>
<Button variant="primary" onClick={handleSearch} loading={loading}>搜索</Button>
<Button variant="ghost" onClick={handleReset}>重置</Button>
</div>
</div>
</Card>
);
}
// 使用
function UserListPage() {
const handleSearch = (values) => fetchUsers(values);
return (
<SearchForm
fields={[
{ name: 'keyword', label: '用户名', type: 'text' },
{ name: 'role', label: '角色', type: 'select', options: [
{ label: '管理员', value: 'admin' },
{ label: '普通用户', value: 'user' },
]},
{ name: 'date', label: '注册日期', type: 'date' },
]}
onSearch={handleSearch}
onReset={() => fetchUsers({})}
/>
);
}主题系统
主题切换实现
// ThemeProvider.tsx
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
type Theme = 'light' | 'dark' | 'brand';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType>(null!);
const themeCSSVars: Record<Theme, Record<string, string>> = {
light: {
'--color-bg': '#ffffff',
'--color-bg-secondary': '#fafafa',
'--color-text': '#1f1f1f',
'--color-text-secondary': '#8c8c8c',
'--color-border': '#e8e8e8',
'--color-primary': '#1890ff',
},
dark: {
'--color-bg': '#1f1f1f',
'--color-bg-secondary': '#141414',
'--color-text': '#e8e8e8',
'--color-text-secondary': '#a0a0a0',
'--color-border': '#434343',
'--color-primary': '#bae7ff',
},
brand: {
'--color-bg': '#f0f5ff',
'--color-bg-secondary': '#e6f7ff',
'--color-text': '#003a8c',
'--color-text-secondary': '#096dd9',
'--color-border': '#91caff',
'--color-primary': '#096dd9',
},
};
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => {
if (typeof window !== 'undefined') {
return (localStorage.getItem('theme') as Theme) || 'light';
}
return 'light';
});
const setTheme = useCallback((newTheme: Theme) => {
setThemeState(newTheme);
localStorage.setItem('theme', newTheme);
}, []);
const toggleTheme = useCallback(() => {
setTheme(theme === 'light' ? 'dark' : 'light');
}, [theme, setTheme]);
// 将 CSS 变量注入到 document
useMemo(() => {
const root = document.documentElement;
const vars = themeCSSVars[theme];
Object.entries(vars).forEach(([key, value]) => {
root.style.setProperty(key, value);
});
root.setAttribute('data-theme', theme);
}, [theme]);
const value = useMemo(() => ({ theme, setTheme, toggleTheme }), [theme, setTheme, toggleTheme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}项目结构与发布
/design-system
/src
/tokens — 设计令牌
primitives.ts — 原始 Token
semantic.ts — 语义 Token
theme.ts — 主题映射
/components — 基础组件
Button.tsx
Input.tsx
Card.tsx
Modal.tsx
Table.tsx
index.ts — 统一导出
/patterns — 复合模式
SearchForm.tsx
DataTable.tsx
FormWizard.tsx
/hooks — 通用 Hook
useTheme.ts
useMediaQuery.ts
/styles — 全局样式
reset.css
tokens.css
/docs — Storybook 文档
/package.json — npm 发布配置
/tsconfig.json
/vite.config.tspackage.json 配置
{
"name": "@myorg/design-system",
"version": "1.2.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"./tokens": {
"import": "./dist/tokens.js",
"types": "./dist/tokens.d.ts"
},
"./styles": "./dist/styles/tokens.css"
},
"files": ["dist"],
"sideEffects": ["**/*.css"],
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
},
"devDependencies": {
"react": "^18.2.0",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vite-plugin-dts": "^3.7.0",
"@storybook/react": "^8.0.0"
},
"scripts": {
"build": "vite build",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"test": "vitest",
"release": "changeset publish"
}
}无障碍设计(A11y)
设计系统的组件必须考虑无障碍访问:
// 无障碍实践示例
function AccessibleModal({ isOpen, onClose, title, children }) {
const modalRef = useRef<HTMLDivElement>(null);
// 焦点陷阱
useEffect(() => {
if (!isOpen) return;
const previousFocus = document.activeElement as HTMLElement;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'Tab') {
// 焦点在模态框内循环
const focusable = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusable && focusable.length > 0) {
const first = focusable[0] as HTMLElement;
const last = focusable[focusable.length - 1] as HTMLElement;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
modalRef.current?.focus();
return () => {
document.removeEventListener('keydown', handleKeyDown);
previousFocus?.focus(); // 恢复焦点
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label={title}
ref={modalRef}
tabIndex={-1}
style={{ /* 样式 */ }}
>
<h2>{title}</h2>
{children}
<button onClick={onClose} aria-label="关闭">X</button>
</div>
);
}常见误区
- 试图一次性覆盖所有组件 — 导致项目长期无法交付,应从高频组件开始
- 设计系统与实际项目脱节 — 组件在 Storybook 中好看,但在真实业务中不好用
- 忽略暗色模式和无障碍 — 后期补充成本远高于提前规划
- 组件 API 设计不合理 — 过度封装导致灵活性不足,或缺乏约束导致使用混乱
最佳实践总结
- 从高频组件开始 — Button、Input、Card、Modal、Table
- 建立 Token 体系 — 原始 Token → 语义 Token → 主题 Token
- 使用 Storybook — 为每个组件编写文档和交互示例
- 版本化管理 — 使用 Changesets 管理 semver 版本和变更日志
- 支持暗色模式 — 通过 CSS 变量实现主题切换
- 无障碍优先 — 为组件添加 ARIA 属性和键盘导航
- 向后兼容 — API 变更需要遵循 semver,提供迁移指南
