设计系统构建指南

组件库架构、Design Tokens、文档化、版本管理、Figma-to-Code 工作流与 Storybook 实践


一、设计系统的本质

设计系统不是组件库,也不是一套 UI 规范文档。它是一套活的、可执行的契约——连接设计师的意图和工程师的实现,确保产品在任何页面、任何设备、任何迭代中都保持一致。

设计系统的三个层次

┌─────────────────────────────────────────────────┐
│  L1: 原则层 (Principles)                         │
│  品牌价值观、设计哲学、交互范式                      │
├─────────────────────────────────────────────────┤
│  L2: Token 层 (Design Tokens)                    │
│  颜色、字体、间距、圆角、阴影、动画曲线               │
├─────────────────────────────────────────────────┤
│  L3: 组件层 (Components)                         │
│  Button、Input、Card、Modal、Table ...            │
└─────────────────────────────────────────────────┘

自建 vs 使用现成方案

方案 适用场景 工作量 灵活性
shadcn/ui 快速启动、可定制 高(代码在项目中)
Ant Design 企业后台、中文友好 中(主题定制有限)
MUI Material Design 风格
Radix + 自定义样式 需要完全品牌化
完全自建 超大型团队、独特需求 最高

中小团队的推荐路径:Radix Primitives(无样式组件)+ Tailwind CSS(样式系统)+ Design Tokens(语义映射)


二、Design Tokens 架构

2.1 三层 Token 体系

// tokens/primitives.ts - L1: Primitive tokens (raw values)
export const primitives = {
  color: {
    blue: {
      50: '#eff6ff',
      100: '#dbeafe',
      200: '#bfdbfe',
      300: '#93c5fd',
      400: '#60a5fa',
      500: '#3b82f6',
      600: '#2563eb',
      700: '#1d4ed8',
      800: '#1e40af',
      900: '#1e3a8a',
    },
    gray: {
      50: '#f8fafc',
      100: '#f1f5f9',
      200: '#e2e8f0',
      300: '#cbd5e1',
      400: '#94a3b8',
      500: '#64748b',
      600: '#475569',
      700: '#334155',
      800: '#1e293b',
      900: '#0f172a',
    },
    // ... green, red, yellow, etc.
  },
  spacing: {
    0: '0px',
    1: '4px',
    2: '8px',
    3: '12px',
    4: '16px',
    5: '20px',
    6: '24px',
    8: '32px',
    10: '40px',
    12: '48px',
    16: '64px',
  },
  radius: {
    none: '0px',
    sm: '4px',
    md: '8px',
    lg: '12px',
    xl: '16px',
    full: '9999px',
  },
  shadow: {
    sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
    md: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
    lg: '0 10px 15px -3px rgb(0 0 0 / 0.1)',
    xl: '0 20px 25px -5px rgb(0 0 0 / 0.1)',
  },
} as const;

// tokens/semantic.ts - L2: Semantic tokens (design intent)
export const semanticLight = {
  color: {
    bg: {
      primary: primitives.color.gray[50],
      secondary: '#ffffff',
      inverse: primitives.color.gray[900],
      brand: primitives.color.blue[500],
    },
    text: {
      primary: primitives.color.gray[800],
      secondary: primitives.color.gray[500],
      disabled: primitives.color.gray[300],
      inverse: '#ffffff',
      brand: primitives.color.blue[600],
    },
    border: {
      default: primitives.color.gray[200],
      focus: primitives.color.blue[400],
      error: '#ef4444',
    },
    feedback: {
      success: '#22c55e',
      warning: '#f59e0b',
      error: '#ef4444',
      info: primitives.color.blue[500],
    },
  },
  spacing: {
    page: primitives.spacing[6],
    section: primitives.spacing[8],
    element: primitives.spacing[4],
    inline: primitives.spacing[2],
  },
} as const;

// tokens/component.ts - L3: Component tokens (specific usage)
export const componentTokens = {
  button: {
    primary: {
      bg: semanticLight.color.bg.brand,
      text: semanticLight.color.text.inverse,
      borderRadius: primitives.radius.md,
      paddingX: primitives.spacing[4],
      paddingY: primitives.spacing[2],
      fontSize: '14px',
      fontWeight: '600',
    },
    secondary: {
      bg: 'transparent',
      text: semanticLight.color.text.brand,
      border: semanticLight.color.border.default,
      borderRadius: primitives.radius.md,
      paddingX: primitives.spacing[4],
      paddingY: primitives.spacing[2],
      fontSize: '14px',
      fontWeight: '500',
    },
  },
  input: {
    bg: '#ffffff',
    border: semanticLight.color.border.default,
    borderFocus: semanticLight.color.border.focus,
    borderError: semanticLight.color.border.error,
    borderRadius: primitives.radius.md,
    padding: primitives.spacing[3],
    fontSize: '14px',
    placeholderColor: semanticLight.color.text.disabled,
  },
} as const;

2.2 Token 转换为 CSS Variables

// tokens/export-css.ts

function flattenTokens(
  obj: Record<string, unknown>,
  prefix: string = '',
): Record<string, string> {
  const result: Record<string, string> = {};

  for (const [key, value] of Object.entries(obj)) {
    const varName = prefix ? `${prefix}-${key}` : key;

    if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
      Object.assign(result, flattenTokens(value as Record<string, unknown>, varName));
    } else {
      result[`--${varName}`] = String(value);
    }
  }

  return result;
}

function generateCSS(
  lightTokens: Record<string, unknown>,
  darkTokens: Record<string, unknown>,
): string {
  const lightVars = flattenTokens(lightTokens);
  const darkVars = flattenTokens(darkTokens);

  const lightCSS = Object.entries(lightVars)
    .map(([k, v]) => `  ${k}: ${v};`)
    .join('\n');

  const darkCSS = Object.entries(darkVars)
    .map(([k, v]) => `  ${k}: ${v};`)
    .join('\n');

  return `
:root {
${lightCSS}
}

@media (prefers-color-scheme: dark) {
  :root {
${darkCSS}
  }
}

[data-theme="dark"] {
${darkCSS}
}
  `.trim();
}

三、组件库架构

3.1 组件分层

┌────────────────────────────────────────────┐
│  Patterns (业务模式)                        │
│  LoginForm, DataTable, FileUpload           │
├────────────────────────────────────────────┤
│  Components (通用组件)                       │
│  Button, Input, Select, Dialog, Card        │
├────────────────────────────────────────────┤
│  Primitives (基础原语)                       │
│  Box, Flex, Text, Separator, VisuallyHidden │
└────────────────────────────────────────────┘

3.2 组件规范模板

每个组件必须包含以下结构:

components/
  button/
    Button.tsx            # 组件实现
    Button.stories.tsx    # Storybook stories
    Button.test.tsx       # 单元测试
    Button.module.css     # 样式(或用 Tailwind)
    index.ts              # 导出入口
    README.md             # 组件文档(可选)

3.3 组件 API 设计原则

// components/button/Button.tsx
import { forwardRef } from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';

/**
 * Button component API design principles:
 * 1. Variant-driven: visual styles via `variant` prop
 * 2. Size tokens: `sm`, `md`, `lg` (not arbitrary px)
 * 3. Composition: `asChild` for rendering as different elements
 * 4. Accessible: proper ARIA attributes
 * 5. Polymorphic: works as button, link, or custom element
 */

const buttonVariants = cva(
  // Base styles (always applied)
  'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      variant: {
        primary:
          'bg-[var(--color-bg-brand)] text-[var(--color-text-inverse)] hover:opacity-90',
        secondary:
          'border border-[var(--color-border-default)] bg-transparent hover:bg-[var(--color-bg-primary)]',
        ghost:
          'hover:bg-[var(--color-bg-primary)]',
        destructive:
          'bg-[var(--color-feedback-error)] text-white hover:opacity-90',
        link:
          'text-[var(--color-text-brand)] underline-offset-4 hover:underline',
      },
      size: {
        sm: 'h-8 px-3 text-xs',
        md: 'h-10 px-4 text-sm',
        lg: 'h-12 px-6 text-base',
        icon: 'h-10 w-10',
      },
    },
    defaultVariants: {
      variant: 'primary',
      size: 'md',
    },
  }
);

interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
  loading?: boolean;
}

const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild, loading, children, ...props }, ref) => {
    const Comp = asChild ? Slot : 'button';
    return (
      <Comp
        ref={ref}
        className={cn(buttonVariants({ variant, size }), className)}
        disabled={loading || props.disabled}
        {...props}
      >
        {loading ? <Spinner className="mr-2 h-4 w-4" /> : null}
        {children}
      </Comp>
    );
  }
);

Button.displayName = 'Button';

export { Button, buttonVariants };
export type { ButtonProps };

四、Storybook 集成

4.1 配置

// .storybook/preview.ts
import type { Preview } from '@storybook/react';
import '../src/styles/globals.css'; // Import design tokens

const preview: Preview = {
  parameters: {
    controls: { matchers: { color: /(background|color)$/i, date: /Date$/i } },
    backgrounds: {
      default: 'light',
      values: [
        { name: 'light', value: '#ffffff' },
        { name: 'dark', value: '#0f172a' },
        { name: 'gray', value: '#f8fafc' },
      ],
    },
  },
  decorators: [
    (Story) => (
      <div className="p-8">
        <Story />
      </div>
    ),
  ],
};

export default preview;

4.2 组件 Story 示例

// components/button/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', 'ghost', 'destructive', 'link'],
    },
    size: {
      control: 'select',
      options: ['sm', 'md', 'lg', 'icon'],
    },
    loading: { control: 'boolean' },
    disabled: { control: 'boolean' },
  },
};

export default meta;
type Story = StoryObj<typeof Button>;

export const Primary: Story = {
  args: { children: 'Primary Button', variant: 'primary' },
};

export const Secondary: Story = {
  args: { children: 'Secondary Button', variant: 'secondary' },
};

export const AllVariants: Story = {
  render: () => (
    <div className="flex gap-4 flex-wrap">
      <Button variant="primary">Primary</Button>
      <Button variant="secondary">Secondary</Button>
      <Button variant="ghost">Ghost</Button>
      <Button variant="destructive">Destructive</Button>
      <Button variant="link">Link</Button>
    </div>
  ),
};

export const AllSizes: Story = {
  render: () => (
    <div className="flex items-center gap-4">
      <Button size="sm">Small</Button>
      <Button size="md">Medium</Button>
      <Button size="lg">Large</Button>
    </div>
  ),
};

export const LoadingState: Story = {
  args: { children: 'Loading...', loading: true },
};

五、Figma-to-Code 工作流

5.1 工作流全景

设计师 (Figma)
  |
  v
[Figma Tokens Studio] -> 导出 tokens.json
  |
  v
[Style Dictionary / Token Transform] -> 多平台格式
  |
  v
[Git Commit] -> 触发 CI
  |
  v
[组件库更新] -> Storybook 自动构建
  |
  v
[发布 npm 包] -> 项目消费

5.2 Token 同步自动化

// scripts/sync-tokens.ts
/**
 * Sync design tokens from Figma Tokens Studio output
 * to codebase format.
 *
 * Run: npx ts-node scripts/sync-tokens.ts
 */
import { readFileSync, writeFileSync } from 'fs';
import { resolve } from 'path';

interface FigmaToken {
  value: string;
  type: string;
  description?: string;
}

function syncTokens(inputPath: string, outputDir: string): void {
  const raw = JSON.parse(readFileSync(inputPath, 'utf-8'));

  // Transform Figma Tokens format to our internal format
  const primitives = transformPrimitives(raw.global);
  const semanticLight = transformSemantic(raw.light, primitives);
  const semanticDark = transformSemantic(raw.dark, primitives);

  // Write TypeScript files
  writeFileSync(
    resolve(outputDir, 'primitives.ts'),
    `export const primitives = ${JSON.stringify(primitives, null, 2)} as const;\n`
  );

  writeFileSync(
    resolve(outputDir, 'semantic-light.ts'),
    `export const semanticLight = ${JSON.stringify(semanticLight, null, 2)} as const;\n`
  );

  writeFileSync(
    resolve(outputDir, 'semantic-dark.ts'),
    `export const semanticDark = ${JSON.stringify(semanticDark, null, 2)} as const;\n`
  );

  // Generate CSS variables
  const css = generateCSS(semanticLight, semanticDark);
  writeFileSync(resolve(outputDir, 'tokens.css'), css);

  console.log(`Synced tokens to ${outputDir}`);
}

六、版本管理与发布

6.1 语义化版本规则

变更类型 版本变化 示例
新增组件 Minor 1.2.0 -> 1.3.0
修复组件 Bug Patch 1.2.0 -> 1.2.1
Token 值调整 Patch 颜色微调
破坏性 API 变更 Major 删除 prop、改变行为
新增 Token Minor 增加语义色
删除/重命名 Token Major 所有消费者需更新

6.2 Changelog 自动化

{
  "scripts": {
    "release": "changeset publish",
    "version": "changeset version",
    "storybook:build": "storybook build -o storybook-static",
    "storybook:deploy": "gh-pages -d storybook-static",
    "tokens:sync": "ts-node scripts/sync-tokens.ts",
    "test": "vitest run",
    "lint": "eslint . && stylelint '**/*.css'"
  }
}

七、设计系统的度量与治理

治理指标

指标 目标 度量方式
组件覆盖率 > 80% UI 使用设计系统组件 ESLint 规则检测
Token 使用率 > 90% 颜色使用 Token Stylelint 检测硬编码值
文档完整度 100% 组件有 Storybook CI 检查 story 文件存在
无障碍合规 WCAG AA axe-core 自动扫描
Bundle 影响 单组件 < 10KB gzip Bundle analyzer

设计系统的生命周期

阶段 1: 基础建设 (1-2 月)
  -> Token 体系 + 10-15 核心组件 + Storybook

阶段 2: 推广采用 (2-3 月)
  -> 文档完善 + 迁移指南 + 团队培训

阶段 3: 稳定运营 (持续)
  -> 需求驱动新增 + Token 同步 + 版本管理

阶段 4: 进化 (按需)
  -> 多品牌支持 + 多平台扩展 + AI 辅助

八、经验总结

成功的设计系统具备三个特征

  1. 易用:开发者用设计系统比不用更快
  2. 一致:同一组件在任何地方看起来和行为一样
  3. 活着:持续更新、有人维护、有文档

常见失败原因

  • 过度设计:一开始就想覆盖所有场景,结果什么都做不好
  • 与业务脱节:设计系统团队闭门造车,产出的组件业务不需要
  • 缺少治理:没有强制使用的机制,逐渐被绕过
  • Token 漂移:设计稿和代码中的 Token 不同步

设计系统的核心价值不是"统一",而是"减速"——减少每次 UI 决策的认知负荷,让团队把精力放在真正的产品问题上。


Maurice | maurice_wen@proton.me