每天一个高级前端知识 - Day 12
今日主题:设计系统实战 - 从0到1构建企业级组件库
核心概念:设计系统 ≠ UI组件库
设计系统是产品开发的底层基础设施,包含设计令牌、组件库、文档、工具链和治理流程。
🔬 设计系统核心架构
┌─────────────────────────────────────────────┐
│ 设计令牌 (Design Tokens) │
│ 颜色 / 字体 / 间距 / 阴影 / 动画 │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ 无头组件 (Headless UI) │
│ 逻辑与样式分离 + 可访问性内置 │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ 主题系统 (Theming) │
│ 亮色/暗色 / 品牌定制 / 高对比度 │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ 框架适配层 (React/Vue/Angular) │
│ 组件封装 + 类型定义 │
└─────────────────────────────────────────────┘
🎨 第一阶段:设计令牌系统
// tokens/design-tokens.js
export const designTokens = {
// 颜色系统 - 语义化命名
color: {
// 基础色板
primitive: {
gray: {
50: '#f9fafb',
100: '#f3f4f6',
200: '#e5e7eb',
300: '#d1d5db',
400: '#9ca3af',
500: '#6b7280',
600: '#4b5563',
700: '#374151',
800: '#1f2937',
900: '#111827'
},
blue: {
50: '#eff6ff',
500: '#3b82f6',
700: '#1d4ed8'
},
red: { /* ... */ },
green: { /* ... */ }
},
// 语义令牌(引用基础色)
semantic: {
background: {
primary: 'var(--gray-50)',
secondary: 'var(--gray-100)',
inverse: 'var(--gray-900)'
},
text: {
primary: 'var(--gray-900)',
secondary: 'var(--gray-600)',
disabled: 'var(--gray-400)',
inverse: 'var(--gray-50)'
},
border: {
light: 'var(--gray-200)',
medium: 'var(--gray-300)',
heavy: 'var(--gray-400)'
},
status: {
success: 'var(--green-500)',
warning: 'var(--yellow-500)',
error: 'var(--red-500)',
info: 'var(--blue-500)'
}
}
},
// 字体系统
typography: {
fontFamily: {
sans: 'Inter, system-ui, -apple-system, sans-serif',
mono: 'JetBrains Mono, SF Mono, monospace'
},
fontSize: {
xs: '0.75rem', // 12px
sm: '0.875rem', // 14px
base: '1rem', // 16px
lg: '1.125rem', // 18px
xl: '1.25rem', // 20px
'2xl': '1.5rem', // 24px
'3xl': '1.875rem' // 30px
},
fontWeight: {
normal: '400',
medium: '500',
semibold: '600',
bold: '700'
},
lineHeight: {
tight: '1.25',
normal: '1.5',
relaxed: '1.75'
}
},
// 间距系统(基于8px网格)
spacing: {
0: '0',
1: '0.25rem', // 4px
2: '0.5rem', // 8px
3: '0.75rem', // 12px
4: '1rem', // 16px
5: '1.25rem', // 20px
6: '1.5rem', // 24px
8: '2rem', // 32px
10: '2.5rem', // 40px
12: '3rem' // 48px
},
// 圆角
borderRadius: {
none: '0',
sm: '0.125rem', // 2px
base: '0.25rem',// 4px
md: '0.375rem', // 6px
lg: '0.5rem', // 8px
xl: '0.75rem', // 12px
full: '9999px'
},
// 阴影
shadow: {
sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
base: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
md: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
lg: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
xl: '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)'
},
// 动画
animation: {
duration: {
fastest: '50ms',
fast: '100ms',
base: '150ms',
slow: '200ms',
slowest: '300ms'
},
easing: {
linear: 'linear',
in: 'cubic-bezier(0.4, 0, 1, 1)',
out: 'cubic-bezier(0, 0, 0.2, 1)',
inOut: 'cubic-bezier(0.4, 0, 0.2, 1)'
}
}
};
// 生成CSS变量
function generateCSSVariables(tokens) {
let css = ':root {\n';
function walk(obj, prefix = '') {
for (const [key, value] of Object.entries(obj)) {
const variableName = `${prefix}${key}`.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`);
if (typeof value === 'object' && !Array.isArray(value)) {
walk(value, `${variableName}-`);
} else {
css += ` --${variableName}: ${value};\n`;
}
}
}
walk(tokens);
css += '}';
return css;
}
🧩 第二阶段:无头组件(Headless UI)
// hooks/useSelect.ts - 无头选择器逻辑
import { useState, useRef, useEffect, useCallback } from 'react';
interface UseSelectProps<T> {
options: T[];
value?: T;
onChange?: (value: T) => void;
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
}
export function useSelect<T>({
options,
value: controlledValue,
onChange,
isOpen: controlledIsOpen,
onOpenChange
}: UseSelectProps<T>) {
// 状态管理
const [internalValue, setInternalValue] = useState<T | undefined>(controlledValue);
const [internalIsOpen, setInternalIsOpen] = useState(false);
const value = controlledValue ?? internalValue;
const isOpen = controlledIsOpen ?? internalIsOpen;
const triggerRef = useRef<HTMLElement>(null);
const menuRef = useRef<HTMLUListElement>(null);
const selectedIndexRef = useRef(-1);
// 打开/关闭控制
const open = useCallback(() => {
if (!controlledIsOpen) setInternalIsOpen(true);
onOpenChange?.(true);
}, [controlledIsOpen, onOpenChange]);
const close = useCallback(() => {
if (!controlledIsOpen) setInternalIsOpen(false);
onOpenChange?.(false);
}, [controlledIsOpen, onOpenChange]);
const toggle = useCallback(() => {
isOpen ? close() : open();
}, [isOpen, open, close]);
// 选择值
const select = useCallback((selectedValue: T) => {
if (controlledValue === undefined) {
setInternalValue(selectedValue);
}
onChange?.(selectedValue);
close();
// 触发change事件用于表单集成
triggerRef.current?.dispatchEvent(new Event('change', { bubbles: true }));
}, [controlledValue, onChange, close]);
// 键盘导航
const handleKeyDown = useCallback((event: KeyboardEvent) => {
if (!isOpen) {
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
open();
}
return;
}
switch (event.key) {
case 'Escape':
close();
triggerRef.current?.focus();
break;
case 'ArrowDown':
event.preventDefault();
selectedIndexRef.current = Math.min(
selectedIndexRef.current + 1,
options.length - 1
);
// 滚动到选中项
const selectedItem = menuRef.current?.children[selectedIndexRef.current];
selectedItem?.scrollIntoView({ block: 'nearest' });
break;
case 'ArrowUp':
event.preventDefault();
selectedIndexRef.current = Math.max(selectedIndexRef.current - 1, 0);
const prevItem = menuRef.current?.children[selectedIndexRef.current];
prevItem?.scrollIntoView({ block: 'nearest' });
break;
case 'Enter':
if (selectedIndexRef.current >= 0) {
select(options[selectedIndexRef.current]);
}
break;
case 'Tab':
close();
break;
}
}, [isOpen, options, open, close, select]);
// 点击外部关闭
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (event: MouseEvent) => {
if (
triggerRef.current && !triggerRef.current.contains(event.target as Node) &&
menuRef.current && !menuRef.current.contains(event.target as Node)
) {
close();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen, close]);
// 监听键盘事件
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
// 获取选中项的显示文本
const getSelectedLabel = useCallback(() => {
if (!value) return '请选择';
// 假设options是{ label, value }结构
const selected = options.find(opt => (opt as any).value === value);
return (selected as any)?.label || String(value);
}, [value, options]);
return {
isOpen,
value,
selectedLabel: getSelectedLabel(),
triggerProps: {
ref: triggerRef,
onClick: toggle,
onKeyDown: (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
open();
}
},
'aria-haspopup': 'listbox',
'aria-expanded': isOpen,
'aria-label': '选择框'
},
menuProps: {
ref: menuRef,
role: 'listbox',
'aria-hidden': !isOpen,
style: { display: isOpen ? 'block' : 'none' }
},
getOptionProps: (option: T, index: number) => ({
role: 'option',
'aria-selected': value === option,
onClick: () => select(option),
onMouseEnter: () => { selectedIndexRef.current = index; },
style: {
cursor: 'pointer',
backgroundColor: value === option ? 'var(--color-primary-50)' : 'transparent'
}
}),
open,
close,
toggle,
select
};
}
// components/Select.tsx - 样式化组件(React)
import React from 'react';
import { useSelect } from '../hooks/useSelect';
import './Select.css';
export interface SelectOption {
label: string;
value: string | number;
}
export interface SelectProps {
options: SelectOption[];
value?: SelectOption['value'];
onChange?: (value: SelectOption['value']) => void;
placeholder?: string;
disabled?: boolean;
size?: 'sm' | 'md' | 'lg';
error?: string;
}
export const Select: React.FC<SelectProps> = ({
options,
value: controlledValue,
onChange,
placeholder = '请选择',
disabled = false,
size = 'md',
error
}) => {
const {
isOpen,
value,
selectedLabel,
triggerProps,
menuProps,
getOptionProps
} = useSelect({
options,
value: controlledValue,
onChange
});
return (
<div className={`select-container select-${size} ${disabled ? 'select-disabled' : ''} ${error ? 'select-error' : ''}`}>
<button
{...triggerProps}
className="select-trigger"
disabled={disabled}
type="button"
>
<span className="select-value">
{selectedLabel || placeholder}
</span>
<span className={`select-arrow ${isOpen ? 'select-arrow-up' : ''}`}>
▼
</span>
</button>
{error && <div className="select-error-message">{error}</div>}
<ul {...menuProps} className="select-menu">
{options.map((option, index) => (
<li
key={option.value}
{...getOptionProps(option, index)}
className={`select-option ${value === option.value ? 'select-option-selected' : ''}`}
>
{option.label}
</li>
))}
</ul>
</div>
);
};
🎨 第三阶段:主题系统
// theme/ThemeProvider.tsx
import React, { createContext, useContext, useState, useEffect } from 'react';
type ThemeMode = 'light' | 'dark' | 'high-contrast';
interface ThemeContext {
mode: ThemeMode;
setMode: (mode: ThemeMode) => void;
toggleMode: () => void;
}
const ThemeContext = createContext<ThemeContext | undefined>(undefined);
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [mode, setMode] = useState<ThemeMode>(() => {
// 从localStorage读取
const saved = localStorage.getItem('theme-mode') as ThemeMode;
if (saved) return saved;
// 检测系统偏好
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
return 'light';
});
useEffect(() => {
// 更新DOM
document.documentElement.setAttribute('data-theme', mode);
localStorage.setItem('theme-mode', mode);
// 动态加载对应主题CSS变量
import(`./themes/${mode}.css`);
}, [mode]);
const toggleMode = () => {
setMode(prev => {
if (prev === 'light') return 'dark';
if (prev === 'dark') return 'high-contrast';
return 'light';
});
};
return (
<ThemeContext.Provider value={{ mode, setMode, toggleMode }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) throw new Error('useTheme must be used within ThemeProvider');
return context;
};
/* themes/light.css */
[data-theme="light"] {
--color-bg-primary: #ffffff;
--color-bg-secondary: #f9fafb;
--color-text-primary: #111827;
--color-text-secondary: #6b7280;
--color-border: #e5e7eb;
--color-primary: #3b82f6;
--color-primary-hover: #2563eb;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
/* themes/dark.css */
[data-theme="dark"] {
--color-bg-primary: #1f2937;
--color-bg-secondary: #111827;
--color-text-primary: #f9fafb;
--color-text-secondary: #9ca3af;
--color-border: #374151;
--color-primary: #60a5fa;
--color-primary-hover: #3b82f6;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
}
/* themes/high-contrast.css */
[data-theme="high-contrast"] {
--color-bg-primary: #000000;
--color-bg-secondary: #000000;
--color-text-primary: #ffffff;
--color-text-secondary: #ffff00;
--color-border: #ffffff;
--color-primary: #ffff00;
--color-primary-hover: #ffffff;
--shadow-sm: none;
}
🚀 第四阶段:构建与发布
// rollup.config.js
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import typescript from '@rollup/plugin-typescript';
import postcss from 'rollup-plugin-postcss';
import { terser } from 'rollup-plugin-terser';
import dts from 'rollup-plugin-dts';
export default [
// ESM和CJS构建
{
input: 'src/index.ts',
output: [
{
file: 'dist/index.esm.js',
format: 'esm',
sourcemap: true
},
{
file: 'dist/index.cjs.js',
format: 'cjs',
sourcemap: true
}
],
plugins: [
resolve(),
commonjs(),
typescript({ tsconfig: './tsconfig.json' }),
postcss({
extract: true,
modules: true,
minimize: true
}),
terser()
],
external: ['react', 'react-dom']
},
// 类型定义文件
{
input: 'src/index.ts',
output: [{ file: 'dist/index.d.ts', format: 'es' }],
plugins: [dts()],
}
];
// package.json配置
{
"name": "@company/design-system",
"version": "1.0.0",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts",
"style": "dist/index.css",
"exports": {
".": {
"import": "./dist/index.esm.js",
"require": "./dist/index.cjs.js",
"types": "./dist/index.d.ts"
},
"./tokens": {
"import": "./dist/tokens.esm.js",
"require": "./dist/tokens.cjs.js"
},
"./themes/*.css": "./dist/themes/*.css"
},
"peerDependencies": {
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
},
"files": [
"dist",
"README.md"
]
}
📚 设计系统文档工具
// docs/ComponentPlayground.tsx
import React, { useState } from 'react';
import { LiveProvider, LiveEditor, LivePreview, LiveError } from 'react-live';
import * as DesignSystem from '@company/design-system';
// 组件展示文档
export const ButtonDocs = () => {
const [variant, setVariant] = useState('primary');
const [disabled, setDisabled] = useState(false);
const code = `<Button
variant="${variant}"
disabled={${disabled}}
onClick={() => alert('clicked')}
>
点击按钮
</Button>`;
return (
<div className="docs">
<h1>Button 按钮</h1>
<p>按钮用于触发操作。</p>
<div className="playground">
<div className="controls">
<select value={variant} onChange={e => setVariant(e.target.value)}>
<option value="primary">主要按钮</option>
<option value="secondary">次要按钮</option>
<option value="danger">危险按钮</option>
</select>
<label>
<input type="checkbox" checked={disabled} onChange={e => setDisabled(e.target.checked)} />
禁用状态
</label>
</div>
<LiveProvider code={code} scope={DesignSystem}>
<LivePreview />
<LiveEditor />
<LiveError />
</LiveProvider>
</div>
<div className="props-table">
<h2>API</h2>
<table>
<thead>
<tr><th>属性</th><th>说明</th><th>类型</th><th>默认值</th></tr>
</thead>
<tbody>
<tr><td>variant</td><td>按钮类型</td><td>'primary' \| 'secondary' \| 'danger'</td><td>'primary'</td></tr>
<tr><td>disabled</td><td>禁用状态</td><td>boolean</td><td>false</td></tr>
<tr><td>loading</td><td>加载中</td><td>boolean</td><td>false</td></tr>
</tbody>
</table>
</div>
</div>
);
};
🎯 今日挑战
实现一个完整的Button组件,要求:
- 支持设计令牌(颜色、间距、圆角)
- 支持多种变体(primary、secondary、outline、ghost)
- 支持多种尺寸(sm、md、lg)
- 支持加载状态、禁用状态
- 支持图标(左侧、右侧)
- 支持涟漪效果
- 完整的TypeScript类型定义
- 支持暗色主题自动适配
// 使用示例
<Button
variant="primary"
size="lg"
loading={isLoading}
leftIcon={<MailIcon />}
rightIcon={<ArrowIcon />}
onClick={handleClick}
>
发送邮件
</Button>
明日预告:WebRTC 深度实战 - 实现1对1视频通话和白板协作(含信令服务器、STUN/TURN、数据通道)
💡 设计系统铁律:“约束中创新”——好的设计系统不是限制创造力,而是让创新更高效!