设计系统与组件化

NOTE

本文档深入探讨现代前端开发中设计系统与组件化的核心概念,从 Design Token 的三层架构到样式隔离方案,从主题切换机制到最佳实践,全方位解析构建可维护、高复用 UI 的工程化路径。


设计系统的本质与价值

超越组件库的设计系统认知

在讨论设计系统之前,我们首先需要澄清一个常见的误解:设计系统不仅仅是组件库的代名词。虽然组件库是设计系统中最具象的部分,但设计系统的内涵远比这丰富得多。一个完整的设计系统是一套包含设计原则、视觉规范、交互模式、代码实现、文档说明和协作流程的完整体系。

想象一下,当你走进一个建筑工地时,你会看到砖块、水泥、钢筋这些基础材料,也会看到预制好的门窗、楼梯单元。这些材料和预制件就像是设计系统中的组件。但仅有材料是不够的,还需要建筑规范——比如每面墙的最小厚度、承重结构的要求、隔音标准等。这些规范类比到设计系统中,就是设计原则和视觉规范。

设计系统的价值体现在多个层面。首先是效率提升:设计师不需要每次都从零开始设计一个按钮,开发者也不需要每次都写一个按钮的代码。预先设计好的组件可以直接复用,大幅减少重复劳动。其次是一致性保障:想象一个产品有多个页面,如果每个页面都是不同设计师设计的,很可能颜色、大小、间距都不统一。设计系统确保了整个产品视觉语言的一致性,用户体验更加连贯。第三是协作效率:设计师和开发者有了共同的语言和工具,沟通成本大幅降低。第四是可维护性:当需要修改品牌色时,只需要在设计系统中修改一次,影响会自动传播到所有使用这个颜色的地方。

现代设计系统的典型架构通常包含以下几个核心组成部分。最底层是设计 Token,这是最基础的设计变量,比如具体的颜色值、字体大小、间距数值等。往上一层是原子组件,它们是最小功能单元,比如按钮、图标、输入框等。再往上是分子组件,由原子组件组合而成,比如搜索框由输入框和按钮组成。更上一层是有机组件或者模板,它们是完整的页面片段,比如导航栏、卡片、侧边栏等。最顶层是页面模板和具体页面,定义页面级别的布局结构。除了这些组件层级,还需要有完善的文档系统,为设计师和开发者提供清晰的使用指南和示例代码。

组件化的核心概念

组件化是现代前端开发的基础范式之一,它的核心理念是将用户界面拆分为独立、可复用、可组合的部件。这种思想与我们日常生活中使用乐高积木搭建建筑物非常相似:每块积木都是独立的,可以单独使用,也可以组合成复杂的结构。

组件化的核心优势在于几个方面。第一是复用性:同一个组件可以在多个地方使用,比如一个按钮组件可以在任何需要按钮的地方使用,不需要为每个按钮单独编写代码。第二是可维护性:当需要修改按钮的样式时,只需要修改按钮组件,所有使用这个组件的地方都会自动更新。第三是可测试性:组件是独立的单元,可以单独进行测试,确保质量。第四是团队协作:不同的开发者可以同时开发不同的组件,并行工作,提高效率。

在组件化开发中,理解组件的分层策略至关重要。原子设计方法论是 Brad Frost 提出的一种组件分层思想,它将组件分为五个层级。原子是最基础的 UI 元素,它们不能再被拆分,比如按钮、输入框、标签、图标等。分子是由多个原子组合而成的简单功能单元,比如一个搜索框由输入框、搜索按钮和搜索图标组成,虽然它由原子构成,但它已经是一个独立的功能单元。有机组件是更复杂的界面片段,可以独立完成特定功能,比如一个导航栏包含 Logo、导航链接和用户头像,它本身就是一个完整的功能区域。模板定义了页面的布局结构,是页面的骨架。页面是模板的具体实例,包含真实的内容和数据。

组件的接口设计决定了组件的可用性和灵活性。一个设计良好的组件接口应该足够通用,能够适应多种使用场景,同时又要足够具体,提供明确的使用指导。Props 作为组件的输入参数,是组件与外部世界交互的主要方式。每个 prop 应该有合理的默认值、清晰的类型定义和必要的文档说明。比如一个按钮组件的 variant prop 可以有 default、primary、secondary 等值,每个值的含义应该在文档中清晰说明。组件的事件接口定义了组件可以通知外部的行为,比如 onClick、onFocus 等。状态接口则表明组件可能处于的内部状态,比如 loading、disabled、error 等。

变体处理是组件设计中的一个重要课题。在实际开发中,我们经常需要同一个组件在不同场景下有不同的外观或行为。比如按钮有主要按钮、次要按钮、危险按钮等变体。处理变体的方式主要有几种:通过属性控制是最常见的方式,通过 variant、size 这样的 props 来切换不同的样式;通过组合实现功能扩展,比如在一个基础按钮上组合图标、加载状态等;通过继承实现主题定制,比如在基础按钮上扩展出不同主题的按钮。


Design Token 三层架构详解

全局 Token 的设计与应用

Design Token 是设计系统中存储设计决策的最小单位,它们以键值对的形式将视觉设计属性抽象为可复用的变量。这些变量可以被整个设计系统引用,确保一致性和可维护性。

三层架构的第一层是全局 Token,也叫 Primitives。全局 Token 是设计系统中最原始的值,它们直接对应设计工具中的具体数值。全局 Token 的命名应该清晰表达其视觉属性,让任何人都能从名称推断出这个值代表什么。

/* 全局颜色 Token */
:root {
  /* 蓝色系 */
  --color-blue-50: #eff6ff;
  --color-blue-100: #dbeafe;
  --color-blue-200: #bfdbfe;
  --color-blue-300: #93c5fd;
  --color-blue-400: #60a5fa;
  --color-blue-500: #3b82f6;
  --color-blue-600: #2563eb;
  --color-blue-700: #1d4ed8;
  --color-blue-800: #1e40af;
  --color-blue-900: #1e3a8a;
  --color-blue-950: #172554;
 
  /* 灰色系 */
  --color-gray-50: #f9fafb;
  --color-gray-100: #f3f4f6;
  --color-gray-200: #e5e7eb;
  --color-gray-300: #d1d5db;
  --color-gray-400: #9ca3af;
  --color-gray-500: #6b7280;
  --color-gray-600: #4b5563;
  --color-gray-700: #374151;
  --color-gray-800: #1f2937;
  --color-gray-900: #111827;
  --color-gray-950: #030712;
}
 
/* 全局间距 Token */
:root {
  --spacing-0: 0;
  --spacing-1: 0.25rem;   /* 4px */
  --spacing-2: 0.5rem;    /* 8px */
  --spacing-3: 0.75rem;    /* 12px */
  --spacing-4: 1rem;       /* 16px */
  --spacing-5: 1.25rem;   /* 20px */
  --spacing-6: 1.5rem;     /* 24px */
  --spacing-8: 2rem;       /* 32px */
  --spacing-10: 2.5rem;    /* 40px */
  --spacing-12: 3rem;      /* 48px */
  --spacing-16: 4rem;      /* 64px */
  --spacing-20: 5rem;      /* 80px */
  --spacing-24: 6rem;     /* 96px */
}
 
/* 全局圆角 Token */
:root {
  --radius-none: 0;
  --radius-sm: 0.25rem;   /* 4px */
  --radius-md: 0.375rem;   /* 6px */
  --radius-lg: 0.5rem;     /* 8px */
  --radius-xl: 0.75rem;    /* 12px */
  --radius-2xl: 1rem;     /* 16px */
  --radius-3xl: 1.5rem;   /* 24px */
  --radius-full: 9999px;   /* 完全圆形 */
}
 
/* 全局字体大小 Token */
:root {
  --font-size-xs: 0.75rem;     /* 12px */
  --font-size-sm: 0.875rem;    /* 14px */
  --font-size-base: 1rem;       /* 16px */
  --font-size-lg: 1.125rem;    /* 18px */
  --font-size-xl: 1.25rem;     /* 20px */
  --font-size-2xl: 1.5rem;     /* 24px */
  --font-size-3xl: 1.875rem;   /* 30px */
  --font-size-4xl: 2.25rem;    /* 36px */
  --font-size-5xl: 3rem;       /* 48px */
}
 
/* 全局阴影 Token */
:root {
  --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05);
  --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
  --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
  --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
  --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
  --shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
  --shadow-inner: inset 0 2px 4px 0 rgb(0 0 0 / 0.05);
}

语义 Token 的设计哲学

第二层是语义 Token,也叫 Semantic Tokens。语义 Token 是对全局 Token 的抽象,它们表达含义而非具体值。通过语义 Token,同一视觉属性可以在不同上下文中复用不同值,这是实现主题切换的基础。

语义 Token 的命名应该表达其用途,而不是具体的颜色值。比如 --color-text-primary 表示主要文本颜色,--color-background-base 表示基础背景颜色。这些名称本身不透露具体是什么颜色,但清楚地表达了它们的用途。

/* 语义颜色 Token */
:root {
  /* 品牌色 */
  --color-brand-primary: var(--color-blue-600);
  --color-brand-secondary: var(--color-violet-600);
  --color-brand-accent: var(--color-pink-500);
 
  /* 背景色 */
  --color-background-base: #ffffff;
  --color-background-raised: #f8fafc;
  --color-background-sunken: #f1f5f9;
  --color-background-overlay: rgba(0, 0, 0, 0.5);
  --color-background-muted: #f9fafb;
 
  /* 文本色 */
  --color-text-primary: #0f172a;
  --color-text-secondary: #475569;
  --color-text-tertiary: #94a3b8;
  --color-text-inverse: #ffffff;
  --color-text-link: var(--color-blue-600);
  --color-text-link-hover: var(--color-blue-700);
 
  /* 边框色 */
  --color-border-default: #e2e8f0;
  --color-border-subtle: #f1f5f9;
  --color-border-strong: #cbd5e1;
  --color-border-focus: var(--color-blue-500);
  --color-border-error: var(--color-red-500);
 
  /* 功能色 */
  --color-success: #10b981;
  --color-success-bg: #dcfce7;
  --color-success-text: #166534;
 
  --color-warning: #f59e0b;
  --color-warning-bg: #fef3c7;
  --color-warning-text: #92400e;
 
  --color-error: #ef4444;
  --color-error-bg: #fee2e2;
  --color-error-text: #991b1b;
 
  --color-info: #3b82f6;
  --color-info-bg: #dbeafe;
  --color-info-text: #1e40af;
}
 
/* 暗色主题语义 Token */
[data-theme="dark"] {
  --color-background-base: #0f172a;
  --color-background-raised: #1e293b;
  --color-background-sunken: #020617;
  --color-background-overlay: rgba(0, 0, 0, 0.7);
 
  --color-text-primary: #f8fafc;
  --color-text-secondary: #cbd5e1;
  --color-text-tertiary: #64748b;
  --color-text-inverse: #0f172a;
 
  --color-border-default: #334155;
  --color-border-subtle: #1e293b;
  --color-border-strong: #475569;
 
  --color-success-bg: #052e16;
  --color-success-text: #86efac;
 
  --color-warning-bg: #451a03;
  --color-warning-text: #fcd34d;
 
  --color-error-bg: #450a0a;
  --color-error-text: #fca5a5;
 
  --color-info-bg: #172554;
  --color-info-text: #93c5fd;
}

组件 Token 的封装策略

第三层是组件 Token,也叫 Component Tokens。组件 Token 是针对特定组件的 Token,它们继承或覆盖语义 Token,为组件提供精确的样式控制。

组件 Token 的命名应该包含组件名称,比如 --button-padding-x 表示按钮的水平内边距,--button-font-weight 表示按钮的字体粗细。这种命名方式确保了 Token 的作用域是组件级别的,避免与其他组件的 Token 冲突。

/* Button 组件 Token */
:root {
  /* 按钮尺寸 */
  --button-height-xs: 1.75rem;   /* 28px */
  --button-height-sm: 2rem;       /* 32px */
  --button-height-md: 2.5rem;     /* 40px */
  --button-height-lg: 3rem;       /* 48px */
  --button-height-xl: 3.5rem;     /* 56px */
 
  /* 按钮内边距 */
  --button-padding-x-xs: 0.5rem;   /* 8px */
  --button-padding-x-sm: 0.75rem;  /* 12px */
  --button-padding-x-md: 1rem;     /* 16px */
  --button-padding-x-lg: 1.5rem;   /* 24px */
 
  /* 按钮圆角 */
  --button-border-radius: var(--radius-md);
  --button-border-radius-sm: var(--radius-sm);
  --button-border-radius-lg: var(--radius-lg);
 
  /* 按钮字体 */
  --button-font-family: var(--font-sans);
  --button-font-weight: 500;
  --button-font-size-xs: var(--font-size-xs);
  --button-font-size-sm: var(--font-size-sm);
  --button-font-size-md: var(--font-size-base);
  --button-font-size-lg: var(--font-size-lg);
 
  /* 按钮过渡 */
  --button-transition-duration: 150ms;
  --button-transition-timing: ease-out;
 
  /* 主要按钮样式 */
  --button-primary-bg: var(--color-brand-primary);
  --button-primary-color: var(--color-text-inverse);
  --button-primary-hover-bg: var(--color-blue-700);
  --button-primary-active-bg: var(--color-blue-800);
 
  /* 次要按钮样式 */
  --button-secondary-bg: transparent;
  --button-secondary-color: var(--color-text-primary);
  --button-secondary-border: var(--color-border-default);
  --button-secondary-hover-bg: var(--color-gray-100);
 
  /* 幽灵按钮样式 */
  --button-ghost-color: var(--color-text-primary);
  --button-ghost-hover-bg: var(--color-gray-100);
 
  /* 危险按钮样式 */
  --button-danger-bg: var(--color-error);
  --button-danger-color: var(--color-text-inverse);
  --button-danger-hover-bg: var(--color-red-600);
}
 
/* Input 组件 Token */
:root {
  --input-height: 2.5rem;         /* 40px */
  --input-height-sm: 2rem;        /* 32px */
  --input-height-lg: 3rem;         /* 48px */
 
  --input-padding-x: 0.75rem;       /* 12px */
  --input-padding-y: 0.5rem;      /* 8px */
 
  --input-border-color: var(--color-border-default);
  --input-border-color-hover: var(--color-border-strong);
  --input-border-color-focus: var(--color-border-focus);
  --input-border-radius: var(--radius-md);
 
  --input-font-size: var(--font-size-sm);
  --input-font-family: var(--font-sans);
  --input-text-color: var(--color-text-primary);
  --input-placeholder-color: var(--color-text-tertiary);
 
  --input-bg: var(--color-background-base);
  --input-bg-disabled: var(--color-background-sunken);
}

Token 的平台适配与工具链

Design Token 需要转换为各平台可用的格式,这需要一个完整的工具链来支持。Style Dictionary 是最流行的 Token 管理工具,它可以从单一来源生成多种平台的输出。

// style-dictionary.config.js
module.exports = {
  source: ['tokens/**/*.json'],
  platforms: {
    // 输出 CSS 变量
    css: {
      transformGroup: 'css',
      prefix: 'app',
      buildPath: 'dist/css/',
      files: [{
        destination: 'tokens.css',
        format: 'css/variables',
        options: {
          outputReferences: true,
          selector: ':root',
        }
      }]
    },
 
    // 输出 SCSS 变量
    scss: {
      transformGroup: 'scss',
      buildPath: 'dist/scss/',
      files: [{
        destination: '_tokens.scss',
        format: 'scss/variables'
      }]
    },
 
    // 输出 JavaScript/TypeScript
    js: {
      transformGroup: 'js',
      buildPath: 'src/',
      files: [{
        destination: 'tokens.js',
        format: 'javascript/es6',
        options: {
          outputReferences: true,
         命名空间: false,
        }
      }]
    },
 
    // 输出 iOS Swift
    ios: {
      transformGroup: 'ios-swift',
      buildPath: 'ios/Tokens/',
      files: [{
        destination: 'Tokens.swift',
        format: 'ios-swift/class.swift',
        filter: token => token.attributes.category !== 'asset'
      }]
    },
 
    // 输出 Android XML
    android: {
      transformGroup: 'android',
      buildPath: 'android/src/main/res/values/',
      files: [{
        destination: 'tokens.xml',
        format: 'android/resources'
      }]
    }
  }
};

组件化样式隔离方案对比

CSS Modules 深度实践

CSS Modules 通过文件级别的唯一类名生成实现样式隔离,是 React 生态中最广泛使用的方案之一。它的核心原理是:在编译时,CSS Modules 会为每个类名生成一个唯一的哈希后缀,确保即使在同一个页面上使用了相同名称的类,它们也不会冲突。

CSS Modules 的使用非常简单直观。你只需要将 CSS 文件命名为 .module.css,然后在组件中导入它:

/* Button.module.css */
.button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: var(--button-padding-x-md) var(--button-padding-x-lg);
  font-size: var(--button-font-size-md);
  font-weight: var(--button-font-weight);
  border-radius: var(--button-border-radius);
  border: none;
  cursor: pointer;
  transition: all var(--button-transition-duration) var(--button-transition-timing);
}
 
.button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
 
.primary {
  background-color: var(--button-primary-bg);
  color: var(--button-primary-color);
}
 
.primary:hover:not(:disabled) {
  background-color: var(--button-primary-hover-bg);
}
 
.secondary {
  background-color: var(--button-secondary-bg);
  color: var(--button-secondary-color);
  border: 1px solid var(--button-secondary-border);
}
 
.secondary:hover:not(:disabled) {
  background-color: var(--button-secondary-hover-bg);
}
 
.small {
  padding: var(--button-padding-x-sm) var(--button-padding-x-md);
  font-size: var(--button-font-size-sm);
}
 
.large {
  padding: var(--button-padding-x-lg) var(--button-padding-x-xl);
  font-size: var(--button-font-size-lg);
}
 
.icon {
  width: 1.25em;
  height: 1.25em;
}
 
.iconLeft {
  margin-right: 0.5em;
}
 
.iconRight {
  margin-left: 0.5em;
}
// Button.tsx
import React from 'react';
import styles from './Button.module.css';
 
interface ButtonProps {
  variant?: 'primary' | 'secondary';
  size?: 'small' | 'medium' | 'large';
  disabled?: boolean;
  loading?: boolean;
  icon?: React.ReactNode;
  iconPosition?: 'left' | 'right';
  children: React.ReactNode;
  onClick?: () => void;
  type?: 'button' | 'submit' | 'reset';
  className?: string;
}
 
export function Button({
  variant = 'primary',
  size = 'medium',
  disabled = false,
  loading = false,
  icon,
  iconPosition = 'left',
  children,
  onClick,
  type = 'button',
  className = '',
}: ButtonProps) {
  const classNames = [
    styles.button,
    styles[variant],
    size !== 'medium' && styles[size],
    loading && styles.loading,
    className,
  ]
    .filter(Boolean)
    .join(' ');
 
  const iconClassNames = [
    styles.icon,
    iconPosition === 'left' ? styles.iconLeft : styles.iconRight,
  ].join(' ');
 
  return (
    <button
      type={type}
      className={classNames}
      disabled={disabled || loading}
      onClick={onClick}
    >
      {loading && <span className={styles.spinner} />}
      {icon && iconPosition === 'left' && (
        <span className={iconClassNames}>{icon}</span>
      )}
      {children}
      {icon && iconPosition === 'right' && (
        <span className={iconClassNames}>{icon}</span>
      )}
    </button>
  );
}

编译后,类名会变成类似 Button_button__3x7kP__buttonButton_primary__3x7kP__primary 这样的形式,确保全局唯一性。

Styled Components 的深度实践

Styled Components 是 CSS-in-JS 方案的典型代表,它通过标签模板字符串和 JavaScript 运行时实现样式定义。这种方案的最大特点是样式和组件代码共存于同一个文件中,开发者可以在定义样式时直接使用 JavaScript 变量和逻辑。

import styled, { css, createGlobalStyle } from 'styled-components';
import { theme } from './theme';
 
// 主题类型定义
interface Theme {
  colors: {
    primary: string;
    secondary: string;
    background: string;
    surface: string;
    text: string;
    textSecondary: string;
    border: string;
    success: string;
    warning: string;
    error: string;
  };
  spacing: {
    xs: string;
    sm: string;
    md: string;
    lg: string;
    xl: string;
  };
  radius: {
    sm: string;
    md: string;
    lg: string;
    full: string;
  };
  shadows: {
    sm: string;
    md: string;
    lg: string;
  };
}
 
// 全局样式
export const GlobalStyles = createGlobalStyle<{ theme: Theme }>`
  * {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
  }
 
  body {
    font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
    background-color: ${({ theme }) => theme.colors.background};
    color: ${({ theme }) => theme.colors.text};
    line-height: 1.5;
  }
`;
 
// 按钮变体样式片段
const buttonVariants = {
  primary: css`
    background-color: ${({ theme }) => theme.colors.primary};
    color: white;
    border: none;
 
    &:hover:not(:disabled) {
      filter: brightness(1.1);
      transform: translateY(-1px);
    }
 
    &:active:not(:disabled) {
      filter: brightness(0.95);
      transform: translateY(0);
    }
  `,
  secondary: css`
    background-color: transparent;
    color: ${({ theme }) => theme.colors.text};
    border: 1px solid ${({ theme }) => theme.colors.border};
 
    &:hover:not(:disabled) {
      background-color: ${({ theme }) => theme.colors.surface};
    }
 
    &:active:not(:disabled) {
      background-color: ${({ theme }) => theme.colors.border};
    }
  `,
  ghost: css`
    background-color: transparent;
    color: ${({ theme }) => theme.colors.text};
    border: none;
 
    &:hover:not(:disabled) {
      background-color: ${({ theme }) => theme.colors.surface};
    }
 
    &:active:not(:disabled) {
      background-color: ${({ theme }) => theme.colors.border};
    }
  `,
  danger: css`
    background-color: ${({ theme }) => theme.colors.error};
    color: white;
    border: none;
 
    &:hover:not(:disabled) {
      filter: brightness(1.1);
    }
  `,
};
 
// 尺寸样式片段
const buttonSizes = {
  sm: css`
    padding: 0.375rem 0.75rem;
    font-size: 0.875rem;
  `,
  md: css`
    padding: 0.5rem 1rem;
    font-size: 1rem;
  `,
  lg: css`
    padding: 0.75rem 1.5rem;
    font-size: 1.125rem;
  `,
};
 
// 按钮组件
interface StyledButtonProps {
  $variant?: keyof typeof buttonVariants;
  $size?: keyof typeof buttonSizes;
}
 
export const StyledButton = styled.button<StyledButtonProps>`
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  font-weight: 500;
  border-radius: ${({ theme }) => theme.radius.md};
  cursor: pointer;
  transition: all 150ms ease-out;
  outline: none;
 
  ${({ $size = 'md' }) => buttonSizes[$size]}
  ${({ $variant = 'primary' }) => buttonVariants[$variant]}
 
  &:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }
 
  &:focus-visible {
    box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
  }
`;
 
// 卡片组件
export const Card = styled.div<{ $elevated?: boolean }>`
  background-color: ${({ theme }) => theme.colors.surface};
  border-radius: ${({ theme }) => theme.radius.lg};
  padding: ${({ theme }) => theme.spacing.lg};
  box-shadow: ${({ theme, $elevated }) =>
    $elevated ? theme.shadows.lg : theme.shadows.sm};
  transition: box-shadow 200ms ease;
 
  &:hover {
    box-shadow: ${({ theme }) => theme.shadows.md};
  }
`;
 
export const CardTitle = styled.h3`
  font-size: 1.25rem;
  font-weight: 600;
  margin-bottom: ${({ theme }) => theme.spacing.sm};
  color: ${({ theme }) => theme.colors.text};
`;
 
export const CardContent = styled.p`
  font-size: 0.875rem;
  color: ${({ theme }) => theme.colors.textSecondary};
  line-height: 1.6;
`;

Tailwind CSS 的组件化实践

Tailwind CSS 采用原子化 CSS 的理念,将样式抽象为单一职责的工具类,通过组合实现复杂 UI。这种方式与组件化的结合需要一些额外的策略。

// components/ui/button.tsx
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
 
// CVA 变体配置
const buttonVariants = cva(
  '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-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground hover:bg-primary/90',
        destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
        outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
        secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
        ghost: 'hover:bg-accent hover:text-accent-foreground',
        link: 'text-primary underline-offset-4 hover:underline',
      },
      size: {
        default: 'h-10 px-4 py-2',
        sm: 'h-9 rounded-md px-3',
        lg: 'h-11 rounded-md px-8',
        icon: 'h-10 w-10',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  }
);
 
// 组件类型定义
export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
}
 
// 组件实现
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : 'button';
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    );
  }
);
Button.displayName = 'Button';
 
export { Button, buttonVariants };
// lib/utils.ts
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
 
/**
 * 合并 Tailwind 类名,处理冲突
 * clsx 处理条件类名和数组
 * tailwind-merge 处理 Tailwind 类的冲突合并
 */
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

三种方案的综合对比

选择哪种样式隔离方案需要根据项目的具体需求来决定。以下是一个全面的对比:

从样式隔离能力来看,CSS Modules 通过文件级唯一类名实现隔离,Styled Components 通过运行时生成唯一哈希类名实现隔离,Tailwind CSS 则通过原子类组合实现隔离(需要额外的配置来处理冲突)。从运行时开销来看,CSS Modules 没有运行时开销,因为所有处理都在编译时完成;Styled Components 有中等运行时开销,因为需要 JavaScript 在浏览器中处理样式;Tailwind CSS 在生产环境也没有运行时开销,但开发时需要编译。从 CSS 体积来看,CSS Modules 的体积与使用量成正比,Styled Components 同样与使用量成正比,而 Tailwind CSS 通过 JIT 可以实现极小的体积。对于动态主题需求,CSS Modules 需要依赖 CSS 变量或重新编译,Styled Components 原生支持主题变量,Tailwind CSS 需要配置。从学习成本来看,CSS Modules 最低因为语法就是普通 CSS,Styled Components 需要学习模板字符串和 styled-components API,Tailwind CSS 学习曲线最陡峭需要记忆大量类名。


主题切换实现方案

暗色模式的多种实现方式

暗色模式已经成为现代 Web 应用的标准配置。实现暗色模式有多种方式,各有优劣。

基于媒体查询的静态方案利用 prefers-color-scheme 媒体查询来检测系统偏好:

/* 默认浅色主题 */
:root {
  --color-bg: #ffffff;
  --color-text: #0f172a;
  --color-border: #e2e8f0;
  --color-primary: #3b82f6;
}
 
/* 暗色主题(跟随系统设置)*/
@media (prefers-color-scheme: dark) {
  :root {
    --color-bg: #0f172a;
    --color-text: #f8fafc;
    --color-border: #334155;
    --color-primary: #60a5fa;
  }
}

这种方式最大的优点是零 JavaScript 代码,用户切换系统设置后主题会自动跟随。但缺点是用户无法手动选择偏好,且可能存在闪烁问题。

基于 CSS 变量的运行时切换方案通过 JavaScript 控制主题:

// ThemeProvider.tsx
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
 
type Theme = 'light' | 'dark';
 
interface ThemeContextValue {
  theme: Theme;
  toggleTheme: () => void;
  setTheme: (theme: Theme) => void;
  isSystem: boolean;
  setSystemPreference: (useSystem: boolean) => void;
}
 
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
 
export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setThemeState] = useState<Theme>(() => {
    // 初始化时检查 localStorage
    if (typeof window !== 'undefined') {
      const stored = localStorage.getItem('theme') as Theme | null;
      if (stored === 'light' || stored === 'dark') {
        return stored;
      }
    }
 
    // 检查系统偏好
    if (typeof window !== 'undefined' &&
        window.matchMedia('(prefers-color-scheme: dark)').matches) {
      return 'dark';
    }
    return 'light';
  });
 
  const [isSystem, setIsSystem] = useState(() => {
    if (typeof window !== 'undefined') {
      return localStorage.getItem('theme') === null;
    }
    return true;
  });
 
  // 更新文档主题属性
  useEffect(() => {
    const root = document.documentElement;
 
    // 移除旧主题类
    root.classList.remove('light', 'dark');
 
    // 添加新主题类
    root.classList.add(theme);
 
    // 保存偏好
    if (isSystem) {
      localStorage.removeItem('theme');
    } else {
      localStorage.setItem('theme', theme);
    }
  }, [theme, isSystem]);
 
  // 监听系统主题变化
  useEffect(() => {
    if (!isSystem) return;
 
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
 
    const handleChange = (e: MediaQueryListEvent) => {
      setThemeState(e.matches ? 'dark' : 'light');
    };
 
    mediaQuery.addEventListener('change', handleChange);
    return () => mediaQuery.removeEventListener('change', handleChange);
  }, [isSystem]);
 
  const toggleTheme = useCallback(() => {
    setThemeState(prev => prev === 'light' ? 'dark' : 'light');
    setIsSystem(false);
  }, []);
 
  const setTheme = useCallback((newTheme: Theme) => {
    setThemeState(newTheme);
    setIsSystem(false);
  }, []);
 
  const setSystemPreference = useCallback((useSystem: boolean) => {
    setIsSystem(useSystem);
    if (useSystem) {
      localStorage.removeItem('theme');
      if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
        setThemeState('dark');
      } else {
        setThemeState('light');
      }
    }
  }, []);
 
  return (
    <ThemeContext.Provider
      value={{
        theme,
        toggleTheme,
        setTheme,
        isSystem,
        setSystemPreference
      }}
    >
      {children}
    </ThemeContext.Provider>
  );
}
 
export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
}

主题色系统的实现

除了明暗主题切换,很多应用还需要支持主题色定制,即用户可以选择自己的品牌色。这需要使用 HSL 或 OKLCH 颜色空间来进行动态调整。

// theme-color.ts
/**
 * 基于 HSL 的主题色生成系统
 * 通过调整 HSL 的 L 值生成同色系的浅色变体
 */
export interface ThemeColors {
  primary: string;
  primaryLight: string;
  primaryDark: string;
  primaryLighter: string;
  primaryDarker: string;
  accent: string;
  accentLight: string;
}
 
export function generateThemeColors(baseColor: string): ThemeColors {
  const hsl = hexToHSL(baseColor);
 
  return {
    primary: `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)`,
    primaryLight: `hsl(${hsl.h}, ${hsl.s}%, ${Math.min(hsl.l + 15, 90)}%)`,
    primaryLighter: `hsl(${hsl.h}, ${hsl.s}%, ${Math.min(hsl.l + 25, 95)}%)`,
    primaryDark: `hsl(${hsl.h}, ${hsl.s}%, ${Math.max(hsl.l - 10, 10)}%)`,
    primaryDarker: `hsl(${hsl.h}, ${hsl.s}%, ${Math.max(hsl.l - 20, 5)}%)`,
    accent: `hsl(${(hsl.h + 30) % 360}, ${hsl.s}%, ${hsl.l}%)`,
    accentLight: `hsl(${(hsl.h + 30) % 360}, ${hsl.s}%, ${Math.min(hsl.l + 15, 90)}%)`,
  };
}
 
export function hexToHSL(hex: string): { h: number; s: number; l: number } {
  // 移除 # 号
  hex = hex.replace('#', '');
 
  // 解析 RGB
  const r = parseInt(hex.substring(0, 2), 16) / 255;
  const g = parseInt(hex.substring(2, 4), 16) / 255;
  const b = parseInt(hex.substring(4, 6), 16) / 255;
 
  const max = Math.max(r, g, b);
  const min = Math.min(r, g, b);
  let h = 0;
  let s = 0;
  const l = (max + min) / 2;
 
  if (max !== min) {
    const d = max - min;
    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
 
    switch (max) {
      case r:
        h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
        break;
      case g:
        h = ((b - r) / d + 2) / 6;
        break;
      case b:
        h = ((r - g) / d + 4) / 6;
        break;
    }
  }
 
  return {
    h: Math.round(h * 360),
    s: Math.round(s * 100),
    l: Math.round(l * 100),
  };
}
 
// 应用主题色到 CSS 变量
export function applyThemeColor(color: string) {
  const colors = generateThemeColors(color);
  const root = document.documentElement;
 
  Object.entries(colors).forEach(([key, value]) => {
    root.style.setProperty(`--theme-${key}`, value);
    root.style.setProperty(`--theme-${key}-rgb`, hexToRgbString(color));
  });
}
 
// 辅助函数:将十六进制转换为 RGB 字符串
function hexToRgbString(hex: string): string {
  const { r, g, b } = hexToRGB(hex);
  return `${r}, ${g}, ${b}`;
}
 
function hexToRGB(hex: string): { r: number; g: number; b: number } {
  hex = hex.replace('#', '');
  return {
    r: parseInt(hex.substring(0, 2), 16),
    g: parseInt(hex.substring(2, 4), 16),
    b: parseInt(hex.substring(4, 6), 16),
  };
}

样式架构最佳实践

目录结构规范

大型项目的样式架构需要清晰的目录划分。以下是一个推荐的目录结构:

src/
├── styles/
│   ├── tokens/                 # Design Token
│   │   ├── _colors.css         # 颜色系统
│   │   ├── _typography.css     # 字体系统
│   │   ├── _spacing.css        # 间距系统
│   │   ├── _radius.css         # 圆角系统
│   │   ├── _shadow.css         # 阴影系统
│   │   └── _index.css          # Token 入口
│   ├── base/                   # 基础样式
│   │   ├── _reset.css          # CSS Reset/Normalize
│   │   ├── _typography.css     # 排版基础
│   │   ├── _link.css           # 链接样式
│   │   ├── _image.css          # 图片样式
│   │   └── _index.css
│   ├── components/             # 组件样式
│   │   ├── Button/
│   │   │   ├── Button.css
│   │   │   └── Button.variables.css
│   │   ├── Card/
│   │   ├── Modal/
│   │   ├── Input/
│   │   └── _index.css
│   ├── layouts/                # 布局样式
│   │   ├── _container.css
│   │   ├── _grid.css
│   │   ├── _sidebar.css
│   │   └── _index.css
│   ├── utilities/              # 工具类
│   │   ├── _display.css
│   │   ├── _spacing.css
│   │   ├── _text.css
│   │   └── _index.css
│   ├── themes/                 # 主题配置
│   │   ├── _light.css
│   │   ├── _dark.css
│   │   ├── _high-contrast.css
│   │   └── _index.css
│   └── main.css                # 入口文件

BEM 命名规范

BEM(Block Element Modifier)是业界广泛采用的 CSS 命名规范,特别适合不使用 CSS Modules 等工具时的命名管理:

/* Block: 独立的实体,代表最外层容器 */
.card { }
 
/* Element: 属于 Block 的一部分,用 __ 分隔 */
.card__header { }
.card__body { }
.card__footer { }
.card__image { }
.card__title { }
.card__description { }
.card__button { }
.card__icon { }
.card__badge { }
 
/* Modifier: Block 或 Element 的变体,用 -- 分隔 */
.card--featured { }
.card--disabled { }
.card--loading { }
 
.card__title--large { }
.card__title--centered { }
.card__title--primary-color { }
 
.card__button--primary { }
.card__button--secondary { }
.card__button--small { }
.card__button--large { }
.card__button--disabled { }
 
/* 推荐使用扁平结构,避免过度嵌套 */
.card__item { }
/* 而不是 */
.card__body__item { }

shadcn/ui 组件库深度解析

shadcn/ui 的核心理念

shadcn/ui 不是传统意义上的组件库,而是一组可复制粘贴的组件源码集合。它代表了组件化开发的新范式,与传统组件库有着本质的区别。

传统组件库如 Material UI、Ant Design 是作为 npm 包分发的,你需要安装整个包,然后通过 props 和主题配置来定制组件。这种方式的优点是开箱即用,缺点是你受限于组件库的 API 设计,深度定制可能需要使用 CSS-in-JS 覆盖或 !important。

shadcn/ui 的做法完全不同。它提供了组件的源代码,你可以将这些源代码复制到自己的项目中,然后完全掌控这些代码。你可以随意修改组件的任何部分,不需要通过 props API 来间接控制。这种方式给了开发者最大的自由度,同时也要求开发者承担更多的维护责任。

组件结构解析

shadcn/ui 的组件通常包含以下几个部分:变体配置使用 class-variance-authority(CVA)库来定义组件的变体;样式合并使用 clsxtailwind-merge 来处理类名合并;无障碍支持依赖 Radix UI 提供基础行为;组件本身是简单的函数组件。

// components/ui/dialog.tsx
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
 
// Dialog 基础组件
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
 
// 遮罩层
const DialogOverlay = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Overlay>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
  <DialogPrimitive.Overlay
    ref={ref}
    className={cn(
      'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out',
      'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
      className
    )}
    {...props}
  />
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
 
// 内容层
const DialogContent = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
  <DialogPortal>
    <DialogOverlay />
    <DialogPrimitive.Content
      ref={ref}
      className={cn(
        'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%]',
        'gap-4 border bg-background p-6 shadow-lg duration-200',
        'data-[state=open]:animate-in data-[state=closed]:animate-out',
        'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
        'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
        'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
        'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
        'sm:rounded-lg',
        className
      )}
      {...props}
    >
      {children}
      <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
        <X className="h-4 w-4" />
        <span className="sr-only">关闭</span>
      </DialogPrimitive.Close>
    </DialogPrimitive.Content>
  </DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
 
// 标题
const DialogHeader = ({
  className,
  ...props
}: React.HTMLAttributes<HTMLDivElement>) => (
  <div
    className={cn(
      'flex flex-col space-y-1.5 text-center sm:text-left',
      className
    )}
    {...props}
  />
);
DialogHeader.displayName = 'DialogHeader';
 
// 底部
const DialogFooter = ({
  className,
  ...props
}: React.HTMLAttributes<HTMLDivElement>) => (
  <div
    className={cn(
      'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
      className
    )}
    {...props}
  />
);
DialogFooter.displayName = 'DialogFooter';
 
// 标题
const DialogTitle = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Title>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
  <DialogPrimitive.Title
    ref={ref}
    className={cn(
      'text-lg font-semibold leading-none tracking-tight',
      className
    )}
    {...props}
  />
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
 
// 描述
const DialogDescription = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Description>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
  <DialogPrimitive.Description
    ref={ref}
    className={cn('text-sm text-muted-foreground', className)}
    {...props}
  />
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
 
// 导出所有组件
export {
  Dialog,
  DialogPortal,
  DialogOverlay,
  DialogTrigger,
  DialogClose,
  DialogContent,
  DialogHeader,
  DialogFooter,
  DialogTitle,
  DialogDescription,
};

组件设计原则与实践

单一职责原则

每个组件应该只负责一个功能或关注点。这使得组件更容易理解、测试和复用。当一个组件承担太多职责时,它会变得复杂且难以维护。

// 好的实践:职责分离
// 按钮组件只负责按钮的交互
const Button = ({ onClick, children, variant }) => (
  <button className={`btn btn-${variant}`} onClick={onClick}>
    {children}
  </button>
);
 
// 图标组件只负责图标显示
const Icon = ({ name, size, color }) => (
  <svg className={`icon icon-${name}`} width={size} height={size} fill={color}>
    <use href={`#${name}`} />
  </svg>
);
 
// 组合使用
const DeleteButton = ({ onDelete }) => (
  <Button onClick={onDelete} variant="danger">
    <Icon name="trash" size={16} />
    删除
  </Button>
);
 
// 差的实践:职责混合
const BadButton = ({
  onDelete,
  icon,
  text,
  onHover,
  onClick,
  showBadge,
  badgeCount,
}) => (
  <button
    onClick={onClick}
    onMouseEnter={onHover}
    className="btn"
  >
    {showBadge && <span className="badge">{badgeCount}</span>}
    <img src={icon} />
    {text}
    {onDelete && <span onClick={onDelete}>x</span>}
  </button>
);

开闭原则

组件应该对扩展开放,对修改关闭。通过适当的抽象和组合,组件可以在不修改源代码的情况下适应新的需求。

// 使用组合模式实现开闭原则
const Card = ({ children, className = '' }) => (
  <div className={`card ${className}`}>
    {children}
  </div>
);
 
const CardHeader = ({ title, subtitle, actions }) => (
  <div className="card-header">
    <div className="card-header-content">
      <h3 className="card-title">{title}</h3>
      {subtitle && <p className="card-subtitle">{subtitle}</p>}
    </div>
    {actions && <div className="card-header-actions">{actions}</div>}
  </div>
);
 
const CardBody = ({ children }) => (
  <div className="card-body">{children}</div>
);
 
const CardFooter = ({ children, align = 'end' }) => (
  <div className={`card-footer card-footer-${align}`}>
    {children}
  </div>
);
 
// 组合使用 - 可以适应各种场景
const UserCard = ({ user }) => (
  <Card>
    <CardHeader
      title={user.name}
      subtitle={user.email}
      actions={<SettingsButton />}
    />
    <CardBody>
      <UserAvatar src={user.avatar} />
      <UserStats stats={user.stats} />
    </CardBody>
    <CardFooter>
      <Button variant="primary">关注</Button>
      <Button variant="secondary">私信</Button>
    </CardFooter>
  </Card>
);

依赖倒置原则

高层次的模块不应该依赖低层次的模块,两者都应该依赖抽象。这在组件库设计中尤为重要:我们应该让业务组件依赖抽象的组件接口,而不是具体的实现。

// 定义抽象接口
interface DataService {
  fetch<T>(url: string): Promise<T>;
  post<T>(url: string, data: unknown): Promise<T>;
}
 
// 低层次实现
class HttpDataService implements DataService {
  async fetch<T>(url: string): Promise<T> {
    const response = await fetch(url);
    return response.json();
  }
 
  async post<T>(url: string, data: unknown): Promise<T> {
    const response = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });
    return response.json();
  }
}
 
// 高层次组件依赖抽象而非具体实现
const UserList = ({ dataService }: { dataService: DataService }) => {
  const [users, setUsers] = useState([]);
 
  useEffect(() => {
    dataService.fetch<User[]>('/api/users').then(setUsers);
  }, [dataService]);
 
  return (
    <ul>
      {users.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
};
 
// 使用时注入具体实现
const App = () => (
  <UserList dataService={new HttpDataService()} />
);

TIP

设计系统建设要点:从 Design Token 出发建立统一的设计语言,通过原子设计方法论组织组件层级,选择适合项目的样式隔离方案,建立完善的文档和 Storybook,使用 CI/CD 自动化检测设计一致性。