每天一个高级前端知识 - Day 12

5 阅读6分钟

每天一个高级前端知识 - 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组件,要求:

  1. 支持设计令牌(颜色、间距、圆角)
  2. 支持多种变体(primary、secondary、outline、ghost)
  3. 支持多种尺寸(sm、md、lg)
  4. 支持加载状态、禁用状态
  5. 支持图标(左侧、右侧)
  6. 支持涟漪效果
  7. 完整的TypeScript类型定义
  8. 支持暗色主题自动适配
// 使用示例
<Button 
  variant="primary"
  size="lg"
  loading={isLoading}
  leftIcon={<MailIcon />}
  rightIcon={<ArrowIcon />}
  onClick={handleClick}
>
  发送邮件
</Button>

明日预告:WebRTC 深度实战 - 实现1对1视频通话和白板协作(含信令服务器、STUN/TURN、数据通道)

💡 设计系统铁律:“约束中创新”——好的设计系统不是限制创造力,而是让创新更高效!