从零到一:如何搭建一套主题驱动的 UI 组件库-——-Awesome-Design UI 开发全记录

在这篇博客中,我想和大家分享一下我近期造的一个新轮子——Awesome Design UI。这不仅是一篇复盘笔记,也是一份写给想要自己从零搭建 React 组件库的同学的实战教程。

📚 文档与组件预览: Awesome Design UI Storybook

源码已托管至🫱 GitHub,期待你的 Star🌟。

💡 前言:为什么又要造一个轮子?

市面上的 React 组件库已经多如牛毛,为什么我还要再写一个?

因为我始终想要一个真正意义上**“动态主题驱动 (Theme-Driven)”**的现代化组件库。我希望底层的组件样式能够完全解耦,并深入绑定到一套强大的 ThemeConfig 体系上。这意味着开发者可以零成本接入极具设计感(例如 Framer 设计哲学)的主题,并且能在运行时无缝切换组件的视觉表现。

同时,为了追求极致性能,我放弃了传统的 CSS-in-JS 方案(这种方案往往会带来运行时性能损耗),而是选择底层通过注入全局 CSS Variables (--ad-*) 的方式来实现样式驱动。


🛠 技术栈选型

在正式敲下第一行代码之前,为这个项目确定了非常现代化的技术基建:

  • 核心框架:使用 React 18/19 以及 TypeScript 配合开发,以提供完善的类型推导体验。

  • 包管理工具:采用了性能极佳的 pnpm (版本 10.33.0)。

  • 构建与打包:选用了 Vite 作为底层打包器,能够快速构建出 es 和 umd 产物。

  • 文档预览:引入 Storybook 10,搭配 @storybook/addon-docs 和 @storybook/addon-a11y 等插件来构建组件的展示文档。

  • 测试驱动:配置了 Vitest 并在底层使用 Playwright 运行浏览器环境测试。


🏗 核心架构:解密动态主题引擎

Awesome Design UI 的灵魂在于其主题引擎。这个引擎主要由两部分构成:“严谨的数据类型规范”与“零损耗的变量注入器”。

1. 建立完备的 Design Token 体系

在 src/theme/types.ts 中,我定义了一套完整的 ThemeConfig 接口模型。它并没有停留在简单的色号定义上,而是深入到了设计系统的骨髓:

  • Colors (色彩):除了基础的 base,还针对现代 UI 设计提取了 surfaceGlass (毛玻璃背景)、inverse (反转色) 以及专为输入框定义的 input 背景色等语义化 Token。

  • Typography (排版):为了完美兼容类似 Framer 那样复杂的设计体系,排版接口分为基础字体族 (family) 和复杂的组合角色样式 (styles)。不仅包含 displayHerobodyReadable,甚至支持应对复杂字体的 OpenType 特性 (fontFeatureSettings) 和微小的大写文本修饰 (microUppercase)。

  • Shadows & Radii (阴影与圆角):阴影系统结合了绝对高度层级 (Elevation Scale) 和语义化映射 (Semantic Aliases)。圆角也划分了从 1px 的精细边缘 (micro) 到 100px 的全圆角 (pillFull) 标尺。

  • Spacing & Effects (间距与特效):为了还原 UI 留白中的“呼吸感哲学”,定义了大跨度的垂直间距 (section)。此外,还新增了包括玻璃态模糊度 (glassmorphism) 和环境径向光晕 (radialAura) 的特效层。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
// src/theme/types.ts

export interface ColorTokens {
background: {
base: string;
surface: string;
surfaceGlass: string;
surfaceGlassHover: string;
inverse: string; // 【根据 4. Buttons 提取】反转背景色,用于 Solid White Pill
input: string; // 【根据 4. Inputs 提取】暗色输入框背景
nav: string; // 【根据 4. Navigation 提取】深色悬浮导航背景
};
text: {
primary: string;
secondary: string;
placeholder: string; // 【根据 4. Inputs 提取】占位符文本颜色
inverse: string; // 【根据 4. Buttons 提取】反转文本色,用于按钮内的黑字
muted: string; // 【根据 4. Trust 提取】客户 Logo 和引言的静音灰色
};
action: {
primary: string;
link: string;
ghostHover: string; // 【根据 4. Buttons 提取】Ghost 按钮 hover 时的背景
};
border: {
subtle: string;
input: string; // 【根据 4. Inputs 提取】输入框基础边框
focus: string; // 【根据 4. Inputs 提取】输入框 focus 状态边框
};
}

export interface TextStyle {
fontFamily: string;
fontSize: string;
fontWeight: number | string;
lineHeight: number | string;
letterSpacing: string;
fontFeatureSettings?: string; // 应对复杂字体的 OpenType 特性 (如 cv01, ss02)
textTransform?: "none" | "uppercase" | "lowercase" | "capitalize"; // 应对 Micro Uppercase
}

export interface TypographyTokens {
// 基础字体族 (Primitive)
family: {
display: string;
body: string;
accent: string;
mono: string;
rounded?: string; // 设为可选,应对某些主题没有圆体的情况
};

// 核心!层级与角色 (Composite - 组合样式)
// 这里列出的角色名必须满足通用业务场景,Framer 的定义非常完整,直接作为我们的基准 Schema
styles: {
displayHero: TextStyle;
sectionDisplay: TextStyle;
sectionHeading: TextStyle;
featureHeading: TextStyle;
accentDisplay: TextStyle;

cardTitle: TextStyle;
featureTitle: TextStyle;
subHeading: TextStyle;

bodyLarge: TextStyle;
body: TextStyle;
navUI: TextStyle;
bodyReadable: TextStyle;

caption: TextStyle;
label: TextStyle;
smallCaption: TextStyle;
microCode: TextStyle;
badge: TextStyle;
microUppercase: TextStyle;
};
}

export interface RadiusTokens {
micro: string; // 【新增】1px: 微小元素、精密边缘
small: string; // 【新增】5px–7px: 小 UI 元素、图片缩略图
standard: string; // 【新增】8px: 标准组件圆角 (代码块、交互元素)
card: string; // 10px–12px: 卡片、产品截图 (替换并融合了之前的 image)
container: string; // 【新增】15px–20px: 大型容器、特性展示卡片
pill: string; // 30px–40px: 导航药丸、分页器
pillFull: string; // 100px: 纯药丸形状 (主 CTA、标签)
}

export interface ShadowTokens {
// 1. 绝对高度层级 (Elevation Scale)
// 任何现代 Design System (如 Material, Apple) 都有基础的高度标尺
elevation: {
level0: string; // 贴地 (Flat)
level1: string; // 基础浮层/边界环 (Ring)
level2: string; // 收缩/内敛容器 (Contained)
level3: string; // 悬浮 (Floating)
};

// 2. 语义化映射 (Semantic Aliases)
// 保留第 4 节中提取的特殊交互状态阴影
semantic: {
cardRingHover: string;
inputFocus: string;
imageDepth: string;
};
}

export interface AnimationTokens {
transform: {
activeScale: string; // 【根据 4. Buttons 提取】点击按压时的矩阵缩放比例
};
transition: {
base: string; // 基础缓动
opacity: string; // 【根据 4. Buttons/Nav 提取】用于揭示效果和导航 Hover
};
}

export interface SpacingTokens {
base: string; // 基础单元 (通常是 8px)

// 绝对比例尺 (Scale): 精确还原文档中的步进表
scale: {
1: string;
2: string;
3: string;
4: string;
5: string;
6: string;
8: string;
10: string;
12: string;
15: string;
20: string;
30: string;
35: string;
};

// 语义化间距 (Semantic): 还原 Whitespace Philosophy (呼吸感哲学)
semantic: {
section: string; // 大跨度垂直间距 (80px–120px),体现 Breathe through darkness
cardPadding: string; // 卡片内部留白 (15px–30px)
componentGap: string; // 关联组件间隙 (8px–20px),体现 Dense within
buttonPadding: string; // 继承自第 4 节
};
}

export interface LayoutTokens {
container: {
maxWidth: string; // 页面内容最大宽度
};
grid: {
asymmetricTemplate: string; // 不对称网格的比例划分 (如 40% vs 60%)
};
}

export interface EffectTokens {
blur: {
glassmorphism: string; // 毛玻璃背景模糊度 (backdrop-filter)
};
glow: {
radialAura: string; // 环境径向光晕 (用于截图背后或特殊视觉中心)
};
}

export interface ThemeConfig {
name: string;
colors: ColorTokens;
typography: TypographyTokens;
radii: RadiusTokens;
shadows: ShadowTokens; // 已重构
animations: AnimationTokens;
spacing: SpacingTokens;
layout: LayoutTokens;
effects: EffectTokens; // 【新增】特效配置
}

2. 注入魔法:ThemeProvider 设计

有了 JS 对象形式的 Token,下一步是如何让 CSS 读取到它们?我在 src/provider/ThemeProvider.tsx 里编写了一个深度遍历器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 深度遍历对象,生成带有 --ad 前缀的 CSS 变量
function objectToCssVariables(obj: Record<string, any>, prefix = '--ad'): React.CSSProperties {
const variables: Record<string, string> = {};
const flatten = (currentObj: Record<string, any>, currentPrefix: string) => {
for (const key in currentObj) {
const value = currentObj[key];
const newKey = `${currentPrefix}-${key}`;
if (typeof value === 'object' && value !== null) {
flatten(value, newKey);
} else {
variables[newKey] = String(value);
}
}
};
flatten(obj, prefix);
return variables as React.CSSProperties;
}

通过这段逻辑,ThemeConfig 中的所有配置都会被拍平。比如 colors.text.primary 会瞬间变为 CSS 变量 --ad-colors-text-primary

最关键的一步是,我通过 <style dangerouslySetInnerHTML={{ __html: \`:root {\n${globalCssString}\n}` }} />将这些变量强行注入到了全局的:root伪类中。 这种设计的收益非常巨大:无论是htmlbody还是外挂在body 上的 React Portal 弹窗,全都能无死角地读取到主题变量!。并且由于我们在 React 渲染外直接依托了原生 CSS 进行样式的重新计算,主题切换时的性能堪称完美。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
// src/provider/ThemeProvider.tsx
import React, { createContext, useContext, useMemo } from 'react';
import { ThemeConfig } from '../theme/types';

// 工具函数:深度遍历对象,生成 CSS 变量
function objectToCssVariables(obj: Record<string, any>, prefix = '--ad'): React.CSSProperties {
const variables: Record<string, string> = {};

const flatten = (currentObj: Record<string, any>, currentPrefix: string) => {
for (const key in currentObj) {
const value = currentObj[key];
// 保持驼峰命名,直接用中划线连接
const newKey = `${currentPrefix}-${key}`;

if (typeof value === 'object' && value !== null) {
flatten(value, newKey);
} else {
variables[newKey] = String(value);
}
}
};

flatten(obj, prefix);
return variables as React.CSSProperties;
}

interface ThemeContextProps {
theme: ThemeConfig;
}

// 初始化时不给默认值,强制要求外面包裹 Provider
const ThemeContext = createContext<ThemeContextProps | null>(null);

export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme 必须在 ThemeProvider 内部使用');
}
return context;
};

export interface ThemeProviderProps {
theme: ThemeConfig;
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}

export const ThemeProvider = ({ theme, children, className = '', style }: ThemeProviderProps) => {
// 缓存计算结果,避免不必要的 re-render
const cssVariables = useMemo(() => {
const { name, ...tokens } = theme;
return objectToCssVariables(tokens);
}, [theme]);

const globalCssString = useMemo(() => {
return Object.entries(cssVariables)
.map(([key, value]) => `${key}: ${value};`)
.join('\n');
}, [cssVariables]);

return (
<ThemeContext.Provider value={{ theme }}>

{/* 将所有的 --ad-xxx 变量强行注入到全局的 :root 伪类中。
从此以后,html、body、甚至外挂到 body 上的 React Portal 弹窗,
全部都能无死角地读取到你的主题变量!
*/}
<style dangerouslySetInnerHTML={{ __html: `:root {\n${globalCssString}\n}` }} />
{/* 这里将所有的 --ad-xxx 变量挂载到 style 上。
内部的 Button、Text 组件将直接消费这些变量!
*/}
<div
className={`ad-theme-${theme.name} ${className}`}
style={style}
>
{children}
</div>
</ThemeContext.Provider>
);
};

📦 工程化:Vite 的库模式打包

除了好用的组件,一个现代化的开源库必须要有顺畅的发布构建流。 在 vite.config.ts 中,我为 Awesome Design UI 开启了原生的库打包支持:

  1. 通过 build.lib 定义入口为 src/index.ts,并自动生成不同格式(umdes)的最终产物。

  2. 在 rollupOptions.external 中,我将 reactreact-dom 甚至 react/jsx-runtime 剔除出打包文件。这个设置非常关键,它避免了将 React 代码二次打包进我们的组件库,防止了开发者使用时遇到 “多个 React 实例” 冲突导致的崩溃。

  3. 通过引入并配置 vite-plugin-dts,以原汁原味的方式输出类型声明文件 (.d.ts),保障在业务端使用时依旧拥有极其出色的 TypeScript 自动补全体验。


📝 总结与感想

开发 Awesome Design UI 是一场将“UI逻辑”与“视觉表现”彻底解耦的极佳实践。

看到它如今内置支持了 Apple、Framer 和 Airtable 这样多变的主题系列,并且组件间的颜色、圆角、排版与阴影都能根据外层注入的 Theme 瞬间完成响应变化,我感到成就感满满。这说明了基于 CSS Variables 进行设计变量下发的思路是非常稳健的。

更有趣的是,当开发者在使用这个库编写自己的业务代码时,甚至不需要写基础样式,可以直接复用我们暴露出的那些 var(--ad-*) CSS 变量,让业务代码跟组件库永远保持 100% 的视觉统一性。

如果你在构建自己的应用时也受够了臃肿的样式系统,或者想要探寻动态主题的最佳实践,这套源码或许会是一个不错的参考案例。欢迎大家来一起体验并探讨改进!


从零到一:如何搭建一套主题驱动的 UI 组件库-——-Awesome-Design UI 开发全记录
http://example.com/2026/04/16/从零到一:如何搭建一套主题驱动的-UI-组件库-——-Awesome-Design-UI-开发全记录/
作者
Lingkai Shi
发布于
2026年4月16日
许可协议