设计系统与组件化
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__button、Button_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)库来定义组件的变体;样式合并使用 clsx 和 tailwind-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 自动化检测设计一致性。
RELATED
- 现代CSS特性 - CSS 新特性探索
- Tailwind-CSS深度指南 - Tailwind CSS 完整指南
- CSS动画与运动设计 - 动画与交互设计