在这篇博客中,我想和大家分享一下我近期造的一个新轮子——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)。不仅包含 displayHero, bodyReadable,甚至支持应对复杂字体的 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
|
export interface ColorTokens { background: { base: string; surface: string; surfaceGlass: string; surfaceGlassHover: string; inverse: string; input: string; nav: string; }; text: { primary: string; secondary: string; placeholder: string; inverse: string; muted: string; }; action: { primary: string; link: string; ghostHover: string; }; border: { subtle: string; input: string; focus: string; }; }
export interface TextStyle { fontFamily: string; fontSize: string; fontWeight: number | string; lineHeight: number | string; letterSpacing: string; fontFeatureSettings?: string; textTransform?: "none" | "uppercase" | "lowercase" | "capitalize"; }
export interface TypographyTokens { family: { display: string; body: string; accent: string; mono: string; rounded?: string; };
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; small: string; standard: string; card: string; container: string; pill: string; pillFull: string; }
export interface ShadowTokens { elevation: { level0: string; level1: string; level2: string; level3: string; };
semantic: { cardRingHover: string; inputFocus: string; imageDepth: string; }; }
export interface AnimationTokens { transform: { activeScale: string; }; transition: { base: string; opacity: string; }; }
export interface SpacingTokens { base: string;
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: { section: string; cardPadding: string; componentGap: string; buttonPadding: string; }; }
export interface LayoutTokens { container: { maxWidth: string; }; grid: { asymmetricTemplate: string; }; }
export interface EffectTokens { blur: { glassmorphism: string; }; 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
| 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伪类中。 这种设计的收益非常巨大:无论是html、body还是外挂在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
| import React, { createContext, useContext, useMemo } from 'react'; import { ThemeConfig } from '../theme/types';
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; }
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) => { 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 开启了原生的库打包支持:
通过 build.lib 定义入口为 src/index.ts,并自动生成不同格式(umd、es)的最终产物。
在 rollupOptions.external 中,我将 react、react-dom 甚至 react/jsx-runtime 剔除出打包文件。这个设置非常关键,它避免了将 React 代码二次打包进我们的组件库,防止了开发者使用时遇到 “多个 React 实例” 冲突导致的崩溃。
通过引入并配置 vite-plugin-dts,以原汁原味的方式输出类型声明文件 (.d.ts),保障在业务端使用时依旧拥有极其出色的 TypeScript 自动补全体验。
📝 总结与感想
开发 Awesome Design UI 是一场将“UI逻辑”与“视觉表现”彻底解耦的极佳实践。
看到它如今内置支持了 Apple、Framer 和 Airtable 这样多变的主题系列,并且组件间的颜色、圆角、排版与阴影都能根据外层注入的 Theme 瞬间完成响应变化,我感到成就感满满。这说明了基于 CSS Variables 进行设计变量下发的思路是非常稳健的。
更有趣的是,当开发者在使用这个库编写自己的业务代码时,甚至不需要写基础样式,可以直接复用我们暴露出的那些 var(--ad-*) CSS 变量,让业务代码跟组件库永远保持 100% 的视觉统一性。
如果你在构建自己的应用时也受够了臃肿的样式系统,或者想要探寻动态主题的最佳实践,这套源码或许会是一个不错的参考案例。欢迎大家来一起体验并探讨改进!