第五章. 主题核心:Design Tokens 与 Vanilla Extract

第五章. 主题核心:Design Tokens 与 Vanilla Extract

在第四章,我们成功搭建了 Ant Design + Tailwind CSS 的混合样式基础。然而,一个真正健壮, 可维护的主题系统,还需要一个中心化的、类型安全的“设计语言”来驱动它们。本章,我们将引入 Vanilla Extract,一个革命性的 CSS-in-TS 解决方案。它将成为我们定义 Design Tokens(设计规范)和生成原生 CSS 变量 的核心引擎。我们将在这里奠定 Prorise-Admin 独特视觉风格和高度可定制性的基石。

5.1. 引入 Vanilla Extract:类型安全的样式基石

我们已经有了 Ant Design (组件库) 和 Tailwind CSS (原子化工具)。为什么还需要引入第三种样式技术 Vanilla Extract?

5.1.1. 决策:为何 Vanilla Extract 是主题系统的“必需品”?

架构师的思考
M

Antd 提供了主题定制,Tailwind 也能通过 @theme 定义变量。

M

我们的工具箱是不是已经满了?为什么还需要 Vanilla Extract?

E
expert

这是一个非常好的问题。

E
expert

答案在于,Antd 和 Tailwind 的主题配置,本质上还是“配置式”的。

E
expert

它们缺乏我们构建一个真正工业级主题系统所必需的两个核心特性:编译时类型安全零运行时

M

可以具体解释一下“类型安全”吗?

E
expert

当然。在 tailwind.config.ts 里,你可以把 colors.primary 设为一个无效的颜色值,或者把 fontSize.lg 设成一个颜色字符串。

E
expert

这些错误只能在浏览器里看到效果时才被发现。

E
expert

而 Vanilla Extract 允许我们完全使用 TypeScript 来定义我们的设计规范。

E
expert

如果你给一个期望是 pxrem 的变量赋了一个颜色值,TypeScript 会在 你保存文件的那一刻 就报错。

E
expert

这种编译时的保障,对于大型项目和团队协作来说至关重要。

M

那么“零运行时”呢?

E
expert

这是 Vanilla Extract 的核心优势。

E
expert

像 Styled Components 或 Emotion 这类传统的 CSS-in-JS 库,需要在浏览器运行时执行 JavaScript 来解析样式、生成类名。这会带来额外的性能开销。

E
expert

Vanilla Extract 则是在 构建时 (build time),就将你的 .css.ts 文件直接编译成了 静态的 .css 文件

E
expert

最终交付给浏览器的,只有最优化的原生 CSS,没有任何额外的 JS 运行时负担。

M

我明白了。所以,VE 不是用来替代 Antd 或 Tailwind 写具体样式的,而是用来构建整个主题系统的“地基”和“规范”?

E
expert

完全正确!在 Prrorise-Admin 的架构中,它们三者的分工非常清晰:

E
expert

Vanilla Extract: 作为“设计语言的编译器”。

E
expert

它负责读取用 TypeScript 写的 Design Tokens,并将它们编译成全局可用的、类型安全的 CSS 变量。它是我们“唯一事实来源”的实现者。

E
expert

Ant Design: 作为“主题的消费者”。

E
expert

它通过一个适配器,读取这些 CSS 变量或 Tokens,实现自身组件的主题化。

E
expert

Tailwind CSS: 同样作为“主题的消费者”。

E
expert

它通过配置文件,读取这些 CSS 变量或 Tokens,生成与我们设计系统完全一致的原子类。

E
expert

它们三者协同工作,构成了一个强大、类型安全且高性能的主题生态系统。

5.1.2. 安装 Vanilla Extract 相关依赖

现在,我们来安装 Vanilla Extract 的核心库和 Vite 插件。由于这些包只在开发和构建阶段使用,我们将它们安装为开发依赖 (-D)。

打开终端,执行以下命令:

1
pnpm add -D @vanilla-extract/css @vanilla-extract/vite-plugin

依赖解析:

  • @vanilla-extract/css: 这是 Vanilla Extract 的核心包,提供了所有用于在 TypeScript 中定义样式、主题和 CSS 变量的 API(例如 style, createTheme, createThemeContract, globalStyle 等)。
  • @vanilla-extract/vite-plugin: 这是官方提供的 Vite 插件。它的职责是在 Vite 的构建流程中,自动查找、解析并编译所有 .css.ts 文件,将其中定义的样式提取为最终的静态 CSS 文件。

5.1.3. 配置 Vite 插件

安装插件后,我们必须在 vite.config.ts 文件中“注册”它,Vite 才能知道如何处理这些特殊的 .css.ts 文件。

文件路径: vite.config.ts (修改)

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
import tailwindcss from "@tailwindcss/vite";
import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin";
import react from "@vitejs/plugin-react";
import AutoImport from "unplugin-auto-import/vite";
import antdResolver from "unplugin-auto-import-antd";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
tsconfigPaths(),
tailwindcss(),
// 这一行参数很重要,他会让Vanilla Extract 不要添加哈希后缀(如 __pl7sh5)
vanillaExtractPlugin({
identifiers: ({ debugId }) => `${debugId}`,
}),
AutoImport({
// 目标文件类型
include: [
/\.[tj]sx?$/, // .ts, .tsx, .js, .jsx
],
// 自动导入的预设包
imports: ["react"],
// 使用 antdResolver(注意是小写)
resolvers: [antdResolver()],
// 自动生成 'auto-imports.d.ts' 类型声明文件
dts: "./auto-imports.d.ts",
// 解决 ESLint/BiomeJS 报错问题
eslintrc: {
enabled: true,
},
}),
],
});

关键配置点:

  • 插件顺序:在大多数情况下,vanillaExtractPlugin 应该放在框架插件(如 react())的后面。这是因为 Vanilla Extract 的 .css.ts 文件本身是 TypeScript 代码,如果其中包含 JSX (例如使用了 sprinkles 等高级 API),可能需要先由 react() 插件处理。
  • 无需额外配置:对于我们的标准设置,vanillaExtractPlugin() 不需要任何额外的参数。它会自动查找并处理项目中的所有 .css.ts 文件。

5.1.4. “Hello World”:创建并验证第一个 .css.ts 文件

完成配置后,我们不能只停留在概念上。让我们创建一个最简单的 theme.css.ts 文件来亲眼见证 Vanilla Extract 的工作流程。

第一步:创建 theme.css.ts

我们在 src/theme/ 目录下创建这个文件。它将是我们主题系统的核心入口。
文件路径: src/theme/theme.css.ts

1
2
3
4
5
6
7
import { globalStyle } from '@vanilla-extract/css';

// 使用 globalStyle API 来定义一个全局样式
// 这是一个简单的测试,证明 VE 正在工作
globalStyle('body', {
backgroundColor: 'lightcoral',
});

第二步:在应用入口导入

为了让 Vite 能够发现并编译这个文件,我们需要在应用的入口处导入它。
文件路径: src/main.tsx

1
2
3
4
5
6
7
8
import React from 'react';
import ReactDOM from 'react-dom/client';
import MyApp from '@/MyApp';
import '@/index.css';
import { ConfigProvider, App as AntdApp } from 'antd';
import '@/theme/theme.css'; // <-- 导入我们的 VE 样式文件

// ... antd 配置 ...

第三步:验证效果

重启你的开发服务器(pnpm dev)。现在,打开浏览器,你会发现整个页面的背景色变成了刺眼的 lightcoral (浅珊瑚色)。

这证明了:

  1. Vite 成功加载了 @vanilla-extract/vite-plugin
  2. 插件成功找到了 src/theme/theme.css.ts 文件。
  3. 插件成功将 globalStyle API 调用编译成了真实的 CSS body { background-color: lightcoral; }
  4. Vite 将编译后的 CSS 注入到了我们的应用中。

验证成功后,你可以 删除或注释掉 theme.css.ts 中的测试代码,为下一节的正式内容做准备。

阶段性成果:我们成功将 Vanilla Extract 集成到了 Prorise-Admin 的构建流程中。我们深刻理解了它在主题架构中扮演的“类型安全基石”和“零运行时 CSS 变量引擎”的关键角色,并通过一个简单的“Hello World”实例,亲眼见证了其工作流程。我们已为下一节深入定义 Design Tokens 做好了万全准备。


5.2. 定义 Design Tokens

5.1 节中,我们成功集成了 Vanilla Extract。现在,我们将进入主题系统的核心:在 src/theme/tokens/ 目录下,定义我们项目的设计规范,即 Design Tokens

5.2.1. 理念:什么是 Design Tokens?

Design Tokens 是构成我们产品界面视觉风格的所有 原子化、可命名的设计决策。它们是设计系统中的“最小单元”,例如:

  • 一个特定的蓝色:#2065D1 (命名为 colorPrimary)
  • 一个标准的内边距:16px (命名为 spacing4)
  • 一种常用的字体:'Inter', sans-serif (命名为 fontFamilySans)

核心价值:将这些原子化的设计决策 中心化管理,并赋予它们 语义化的名称,可以带来巨大的好处:

  1. 一致性:确保整个应用(甚至跨平台)的视觉风格高度统一。修改一个 Token,所有使用它的地方都会自动更新。
  2. 可维护性:设计师和开发者使用同一套“设计语言”进行沟通和协作,极大降低了维护成本。
  3. 可扩展性:基于 Tokens,可以轻松构建多主题(如亮/暗模式、多品牌色)或支持白标 (Whitelabeling)。
  4. 自动化:Tokens 可以被工具(如 Vanilla Extract)读取,自动生成 CSS 变量、样式代码,甚至设计文档。

src/theme/tokens/ 目录将是我们 Prorise-Admin 所有 Design Tokens 的“唯一事实来源 (Single Source of Truth)”。

5.2.2. 核心枚举:定义主题状态

在定义具体的 Token 值之前,我们必须先定义一组 常量枚举 (Enums) 来描述我们的主题状态。这对于类型安全和代码可读性至关重要。

第一步:创建 types 目录和 enum.ts 文件

1
2
3
# 在 src/theme 目录下
mkdir types
touch types/enum.ts

第二步:定义核心枚举
文件路径: src/theme/types/enum.ts

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
/**
* 定义主题模式的枚举
* 'light' 和 'dark' 将作为 data-theme-mode 属性的值
*/
export enum ThemeMode {
Light = "light",
Dark = "dark",
}

/**
* 定义预设主题色的枚举
* 'default', 'cyan' 等将作为 data-color-palette 属性的值
*/
export enum ThemeColorPresets {
Default = "default",
Cyan = "cyan",
Purple = "purple",
Blue = "blue",
Orange = "orange",
Red = "red",
}

/**
* 定义将要附加到 HTML 根元素上的 data 属性名称的常量
* 这样做可以避免在代码中硬编码字符串,减少拼写错误
*/
export enum HtmlDataAttribute {
ColorPalette = "data-color-palette",
ThemeMode = "data-theme-mode",
}

5.2.3. 工具先行:颜色处理函数

在定义颜色之前,我们先来创建几个非常重要的工具函数,它们将极大地增强我们颜色系统的灵活性。

第一步:安装 color

我们需要一个强大的库来解析和操作颜色。

1
pnpm add color @types/color

第二步:创建 src/theme/utils/themeUtils.ts

1
2
3
# 在 src/theme 目录下
mkdir utils
touch utils/themeUtils.ts

第三步:创建 rgbAlpha 函数
文件路径: src/theme/utils/themeUtils.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import color from "color";

/**
* 为颜色值添加 alpha 透明度
* @param colorVal 颜色值 (支持 #hex)
* @param alpha 透明度 (0-1)
* @returns rgba 格式的颜色字符串
*/
export function rgbAlpha(colorVal: string, alpha: number): string {
try {
return color(colorVal).alpha(alpha).rgb().string();
} catch (error) {
console.error(`Invalid color value: ${colorVal}`, error);
return colorVal; // 出错时返回原值
}
}

这个 rgbAlpha 函数让我们可以在 TypeScript 定义阶段 就为一个颜色值(如 #919EAB)附加透明度。这对于定义交互状态的颜色(如 hover, disabled)非常有用。

5.2.4. 构建调色盘:color.ts

颜色是主题系统中最重要的部分。现在我们来创建 color.ts 文件。

重要:在此文件中,我们只定义 扁平的、易于阅读 的颜色值(例如 white: "#FFFFFF")。在下一节中,我们将编写一个工具函数,自动将这些扁平值转换为 4.5.2 中定义的 { value: "...", channel: "..." } 契约形状。

第一步:创建 color.ts 并定义基础色
文件路径: src/theme/tokens/color.ts

1
2
3
4
5
6
7
8
9
10
11
// 定义最基础的黑白色
export const commonColors = {
white: "#FFFFFF",
black: "#09090B", // 使用略微柔和的黑色,而非纯 #000000
};

// 定义一套中性的灰色梯度,用于背景、边框、文本等
export const grayColors = {
"100": "#F9FAFB", "200": "#F4F6F8", "300": "#DFE3E8", "400": "#C4CDD5",
"500": "#919EAB", "600": "#637381", "700": "#454F5B", "800": "#1C252E", "900": "#141A21",
};

第二步:定义预设主题色

这是实现动态主题色切换的关键。我们必须 paletteColors 之前 定义好所有可用的颜色预设。

文件路径: src/theme/tokens/color.ts (追加)

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
import { ThemeColorPresets } from "@/theme/types/enum"; // 导入我们刚定义的枚举

// 1. 定义所有预设主题色的梯度色板
export const presetsColors = {
[ThemeColorPresets.Default]: { // 默认(绿色)
lighter: "#C8FAD6",
light: "#5BE49B",
default: "#00A76F",
dark: "#007867",
darker: "#004B50",
},
[ThemeColorPresets.Cyan]: {
lighter: "#CCF4FE",
light: "#68CDF9",
default: "#078DEE",
dark: "#0351AB",
darker: "#012972",
},
[ThemeColorPresets.Purple]: {
lighter: "#E8DAFF",
light: "#B18AFF",
default: "#7635dc",
dark: "#49199c",
darker: "#290966",
},
[ThemeColorPresets.Blue]: {
lighter: "#D1E9FC",
light: "#76B0F1",
default: "#2065D1",
dark: "#103996",
darker: "#061B64",
},
[ThemeColorPresets.Orange]: {
lighter: "#FEF4D4",
light: "#FED680",
default: "#fda92d",
dark: "#b66800",
darker: "#793900",
},
[ThemeColorPresets.Red]: {
lighter: "#FFE4DE",
light: "#FF8676",
default: "#FF5630",
dark: "#B71D18",
darker: "#7A0916",
},
};

第三步:定义语义化调色板
文件路径: src/theme/tokens/color.ts (追加)

1
2
3
4
5
6
// 2. 定义语义调色板
export const paletteColors = {
// primary 引用我们刚刚定义的预设,默认使用 Default (绿色)
primary: presetsColors[ThemeColorPresets.Default],
// 其他语义色保持不变
};

第四步:定义交互状态颜色
这里,我们使用之前创建的 rgbAlpha 工具函数。
文件路径: src/theme/tokens/color.ts (追加)

1
2
3
4
5
6
7
8
9
10
11
import { rgbAlpha } from "@/theme/utils/themeUtils";
// ... (其他颜色定义)

// 定义组件在不同交互状态下的颜色 (灰色的不同透明度)
export const actionColors = {
hover: rgbAlpha(grayColors[500], 0.08), // 悬停状态背景
selected: rgbAlpha(grayColors[500], 0.16), // 选中状态背景
focus: rgbAlpha(grayColors[500], 0.24), // 聚焦状态光晕
disabled: rgbAlpha(grayColors[500], 0.24), // 禁用状态文本/图标颜色
active: rgbAlpha(grayColors[500], 0.24), // 激活状态
};

第五步:组合亮/暗模式的扁平颜色令牌
文件路径: src/theme/tokens/color.ts (追加)

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
// 定义亮色模式下的最终颜色 Token 集合 (扁平结构)
export const lightColorTokens = {
palette: paletteColors,
common: commonColors,
action: actionColors,
text: {
primary: grayColors[800],
secondary: grayColors[600],
disabled: grayColors[400],
},
background: {
default: grayColors[100],
paper: commonColors.white,
neutral: grayColors[200],
},
};

// 定义暗色模式下的最终颜色 Token 集合 (扁平结构)
export const darkColorTokens = {
palette: paletteColors,
common: commonColors,
action: actionColors,
text: {
primary: commonColors.white,
secondary: grayColors[500],
disabled: grayColors[600],
},
background: {
default: grayColors[900],
paper: grayColors[800],
neutral: rgbAlpha(grayColors[500], 0.12),
},
};

5.2.5. 核心转换器:构建类型安全的 addColorChannels

我们现在面临一个贯穿“定义”与“实现”的关键任务:

  1. 我们的“定义”color.ts 中的 lightColorTokens):是 扁平的、易于阅读的(例如 { text: { primary: "#FFFFFF" } })。
  2. 我们的“契约”type.ts 中的 themeTokens):是 结构化的、为 CSS 变量准备的(例如 { text: { primary: { value: null, channel: null } } })。

我们需要一个转换器函数,将“扁平定义”转换为“结构化契约”的形状。

初步尝试:一个“天真”的实现

一个初步的、看似简单快捷的想法是:我们可以编写一个递归函数,当它找到一个字符串时,就将其替换为 { value, channel } 对象。

我们可能会写出下面这样的代码:

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
// 这是一个“天真”的、存在严重类型缺陷的实现
// !!请勿在项目中使用 !!

import color from "color";

// 天真的实现
function addColorChannels_NAIVE<T extends object>(obj: T): T { // 缺陷 1: 返回 T
const result: Record<string, unknown> = {};

for (const [key, value] of Object.entries(obj)) {
if (typeof value === "string") {
const colorValue = color(value);
// 替换了形状
result[key] = {
value: value,
channel: colorValue.rgb().array().join(" "),
};
} else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
result[key] = addColorChannels_NAIVE(value as object);
} else {
result[key] = value;
}
}

// 缺陷 2: 强制类型断言 (as T)
return result as T;
}

深入分析:为什么这个“天真”的想法是危险的

这个 addColorChannels_NAIVE 函数 在功能上或许能运行,但在 TypeScript 架构 上,它是一个 灾难

  1. 根本的类型不匹配:函数的 输入类型 T(例如 { primary: string })与它的 实际输出形状{ primary: { value: string, channel: string } })是 根本不同 的。
  2. “类型欺骗” (as T)return result as T 这一行是在 对 TypeScript 编译器撒谎。我们告诉编译器:“别担心,我返回的这个新对象 result,它的类型和传入的 obj(类型为 T)是一模一样的。”
  3. 灾难的下游传递:编译器 相信了这个谎言
    • 当你调用 addColorChannels_NAIVE(lightColorTokens) 时,TypeScript 认为返回的还是 lightColorTokens原始扁平类型
    • 在下一章 5.3 中,当你把这个(被谎报了类型的)结果传递给 Vanilla Extract 的 createGlobalTheme 时,灾难就发生了。
    • createGlobalTheme 会比较这个 实际为“结构化”(但被谎报为“扁平”)的对象和我们的结构化”契约(themeVars,发现类型完全不匹配,从而抛出一个 极其隐晦难懂的 TypeScript 编译错误。
    • 最糟糕的是,错误出现在了 5.3 节,而真正的 Bug 却隐藏在 5.2.5 节的这个 as T 类型断言中。

我们必须做得更好。我们不能欺骗编译器,而是必须 精确地告诉 TypeScript 转换后的输出类型到底是什么

专业的解决方案:精确计算输出类型

要解决这个问题,我们需要一个 递归的 TypeScript 泛型类型,它能“计算”出转换后的新形状。

第一步:定义类型工具

我们将在 themeUtils.ts 中定义两个新的类型工具:ColorChannel(描述叶子节点的新形状)和 AddChannelToLeaf<T>(递归计算器)。

文件路径: src/theme/utils/themeUtils.ts (在 rgbAlpha 下方追加)

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
import color from "color";
// ... (rgbAlpha function)

// --- 核心转换器的类型定义 ---

/**
* 描述我们颜色契约的叶子节点形状
*/
type ColorChannel = { value: string; channel: string };

/**
* [类型工具] 递归地“计算”出转换后的类型
* 它的工作原理与我们的函数逻辑完全一致:
* 1. 遍历 T
* 2. 当遇到 'string' 时, 将其类型替换为 'ColorChannel'
* 3. 当遇到 'object' 时, 递归进入
* 4. 遇到其他类型则保持不变
*/
export type AddChannelToLeaf<T> = T extends string
? ColorChannel // 基础情况: 字符串 → { value, channel }
: T extends (infer U)[] // 处理数组 (虽然我们颜色对象里没有, 但这更健壮)
? AddChannelToLeaf<U>[]
: T extends object // 递归情况: 深入处理对象
? { [K in keyof T]: AddChannelToLeaf<T[K]> }
: T; // 其他情况 (null, number...) 保持不变

这个 AddChannelToLeaf<T> 工具是本节的类型安全基石。它让 TypeScript 能够 静态地理解 我们的 addColorChannels 函数到底要返回什么。

第二步:实现健壮的 addColorChannels 函数

现在,我们可以编写这个函数的实现,并为其赋予 正确、安全 的返回类型:AddChannelToLeaf<T>

文件路径: src/theme/utils/themeUtils.ts (在类型工具下方追加)

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
/**
* [核心转换器]
* 递归地将一个扁平的、包含颜色字符串的对象
* (e.g., { primary: '#FFF', nested: { secondary: '#000' } })
* 转换为符合我们 { value, channel } 契约的形状
* (e.g., { primary: { value: '#FFF', channel: '255 255 255' }, ... })
*
* @param obj 扁平的颜色令牌对象 (e.g., lightColorTokens)
* @returns 符合契约的嵌套对象
*/
export function addColorChannels<T extends object>(obj: T): AddChannelToLeaf<T> {
// 创建一个新的结果对象, 以避免修改原始的 tokens
const result: Record<string, unknown> = {};

// 遍历传入对象的所有键
for (const [key, value] of Object.entries(obj)) {
if (typeof value === "string") {
// 叶子节点: 这应该是一个颜色字符串
// 使用 color 库解析
const colorValue = color(value);
// 转换为契约形状
result[key] = {
value: value,
channel: colorValue.rgb().array().join(" "),
};
} else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
// 嵌套对象: 递归调用自身
result[key] = addColorChannels(value as object);
} else {
// 其他类型 (null, array, number...), 按原样保留
// 在我们的颜色 token 中不应出现, 但这使函数更安全
result[key] = value;
}
}

return result as AddChannelToLeaf<T>;
}

5.2.6. 填充其他 Tokens

我们已经完成了最复杂的 color.ts。现在,我们按照同样的模式,创建并填充其他非颜色的 Tokens 文件。这些文件相对简单,它们的值将 直接 用于填充 4.5.2 中定义的 null 契约。

1. 基础 Tokens: base.ts

文件路径: src/theme/tokens/base.ts

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
// 基础主题 Token - 定义间距、圆角、透明度、层级等

// 间距系统 - 用于 margin、padding 等
// 我们采用 4px 网格系统作为间距基础 (spacing: { 1: "4px" }),
// 这是业界标准的做法,能确保 UI 元素之间的节奏一致性。
const spacing = {
0: "0px", 1: "4px", 2: "8px", 3: "12px", 4: "16px",
5: "20px", 6: "24px", 7: "28px", 8: "32px", 10: "40px",
12: "48px", 16: "64px", 20: "80px", 24: "96px", 32: "128px",
};

// 圆角系统 - 用于 border-radius
// 使用语义化名称 (sm, md, lg) 而非纯数字,增加可读性
const borderRadius = {
none: "0px", sm: "2px", default: "4px", md: "6px",
lg: "8px", xl: "12px", full: "9999px",
};

// 透明度系统 - 用于 opacity 和颜色透明度
// 定义了从 0% 到 100% 的梯度,以及常用的语义化透明度
const opacity = {
0: "0%", 5: "5%", 10: "10%", 20: "20%", 25: "25%",
30: "30%", 35: "35%", 40: "40%", 45: "45%", 50: "50%",
55: "55%", 60: "60%", 65: "65%", 70: "70%", 75: "75%",
80: "80%", 85: "85%", 90: "90%", 95: "95%", 100: "100%",
border: "20%", hover: "8%", selected: "16%", focus: "24%",
disabled: "80%", disabledBackground: "24%",
};

// 层级系统 - 定义各组件的 z-index
// 集中管理 z-index 是避免“层级战争”的最佳实践
const zIndex = {
appBar: "10", nav: "20", drawer: "50", modal: "50",
snackbar: "50", tooltip: "50", scrollbar: "100",
};

export const baseThemeTokens = {
spacing,
borderRadius,
opacity,
zIndex,
};

2. 断点 Tokens: breakpoints.ts

文件路径: src/theme/tokens/breakpoints.ts

1
2
3
4
5
6
7
8
9
10
11
// 定义响应式断点,键名遵循 Tailwind 规范
// 我们采用移动端优先 (Mobile-First) 的策略,
// 'xs' (375px) 是我们的基础视口。
export const breakpointsTokens = {
xs: "375px", // 移动端
sm: "576px", // 小型设备
md: "768px", // 平板
lg: "1024px", // 笔记本
xl: "1280px", // 常规桌面
"2xl": "1536px", // 大型桌面
};

更新 base.ts 以包含断点
文件路径: src/theme/tokens/base.ts (修改 export 部分)

1
2
3
4
5
6
7
8
9
10
import { breakpointsTokens } from "./breakpoints";
// ... (spacing, borderRadius, opacity, zIndex 定义)

export const baseThemeTokens = {
spacing,
borderRadius,
screens: breakpointsTokens, // <--- 添加断点
opacity,
zIndex,
};

3. 排版 Tokens: typography.ts

文件路径: src/theme/tokens/typography.ts

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
// 预设字体族,方便切换
export const FontFamilyPreset = {
openSans: "'Open Sans Variable', sans-serif", // 引入 Google Fonts 或本地字体
inter: "'Inter Variable', sans-serif",
};

// 定义排版相关的规范
export const typographyTokens = {
fontFamily: {
openSans: FontFamilyPreset.openSans,
inter: FontFamilyPreset.inter,
},

// 注意:fontSize 值不带单位 (e.g., "16")
// 这对于 Ant Design 的 theme token 兼容性至关重要
// Vanilla Extract 会在编译时为它们添加 'px' (或由我们决定单位)
fontSize: { xs: "12", sm: "14", default: "16", lg: "18", xl: "20" },

fontWeight: {
light: "300", normal: "400", medium: "500",
semibold: "600", bold: "700",
},

// lineHeight 也不带单位,它们是相对于 fontSize 的乘数
lineHeight: { none: "1", tight: "1.25", normal: "1.375", relaxed: "1.5" },
};

4. 阴影 Tokens: shadow.ts

阴影是颜色相关的,所以我们需要 color 库和 color.ts 中的令牌。

文件路径: src/theme/tokens/shadow.ts

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
import Color from "color";
import { commonColors, paletteColors } from "./color";

// --- 亮色主题阴影 ---
export const lightShadowTokens = {
none: "none",
// 基础阴影梯度 (从小到大)
sm: `0 1px 2px 0 ${Color(paletteColors.gray[500]).alpha(0.16)}`,
default: `0 4px 8px 0 ${Color(paletteColors.gray[500]).alpha(0.16)}`,
md: `0 8px 16px 0 ${Color(paletteColors.gray[500]).alpha(0.16)}`,
lg: `0 12px 24px 0 ${Color(paletteColors.gray[500]).alpha(0.16)}`,
xl: `0 16px 32px 0 ${Color(paletteColors.gray[500]).alpha(0.16)}`,
"2xl": `0 20px 40px 0 ${Color(paletteColors.gray[500]).alpha(0.16)}`,
"3xl": `0 24px 48px 0 ${Color(paletteColors.gray[500]).alpha(0.16)}`,
inner: `inset 0 2px 4px 0 ${Color(paletteColors.gray[500]).alpha(0.16)}`,

// 特定组件阴影
dialog: `-40px 40px 80px -8px ${Color(commonColors.black).alpha(0.24)}`,
card: `0 0 2px 0 ${Color(paletteColors.gray[500]).alpha(0.2)}, 0 12px 24px -4px ${Color(paletteColors.gray[500]).alpha(0.12)}`,
dropdown: `0 0 2px 0 ${Color(paletteColors.gray[500]).alpha(0.24)}, -20px 20px 40px -4px ${Color(paletteColors.gray[500]).alpha(0.24)}`,

// 语义化阴影 (带品牌色调)
primary: `0 8px 16px 0 ${Color(paletteColors.primary.default).alpha(0.24)}`,
info: `0 8px 16px 0 ${Color(paletteColors.info.default).alpha(0.24)}`,
success: `0 8px 16px 0 ${Color(paletteColors.success.default).alpha(0.24)}`,
warning: `0 8px 16px 0 ${Color(paletteColors.warning.default).alpha(0.24)}`,
error: `0 8px 16px 0 ${Color(paletteColors.error.default).alpha(0.24)}`,
};

// --- 暗色主题阴影 ---
// 暗色模式下,阴影通常使用更深的黑色基础,以在深色背景上“凸显”
export const darkShadowTokens = {
none: "none",
sm: `0 1px 2px 0 ${Color(commonColors.black).alpha(0.16)}`,
default: `0 4px 8px 0 ${Color(commonColors.black).alpha(0.16)}`,
md: `0 8px 16px 0 ${Color(commonColors.black).alpha(0.16)}`,
lg: `0 12px 24px 0 ${Color(commonColors.black).alpha(0.16)}`,
xl: `0 16px 32px 0 ${Color(commonColors.black).alpha(0.16)}`,
"2xl": `0 20px 40px 0 ${Color(commonColors.black).alpha(0.16)}`,
"3xl": `0 24px 48px 0 ${Color(commonColors.black).alpha(0.16)}`,
inner: `inset 0 2px 4px 0 ${Color(commonColors.black).alpha(0.16)}`,

dialog: `-40px 40px 80px -8px ${Color(commonColors.black).alpha(0.24)}`,
card: `0 0 2px 0 ${Color(commonColors.black).alpha(0.2)}, 0 12px 24px -4px ${Color(commonColors.black).alpha(0.12)}`,
dropdown: `0 0 2px 0 ${Color(commonColors.black).alpha(0.24)}, -20px 20px 40px -4px ${Color(commonColors.black).alpha(0.24)}`,

// 语义化阴影在暗色模式下通常保持不变,因为它们是品牌色的体现
primary: `0 8px 16px 0 ${Color(paletteColors.primary.default).alpha(0.24)}`,
info: `0 8px 16px 0 ${Color(paletteColors.info.default).alpha(0.24)}`,
success: `0 8px 16px 0 ${Color(paletteColors.success.default).alpha(0.24)}`,
warning: `0 8px 16px 0 ${Color(paletteColors.warning.default).alpha(0.24)}`,
error: `0 8px 16px 0 ${Color(paletteColors.error.default).alpha(0.24)}`,
};

5.3. 生成 CSS 变量

我们已经在 5.2 节精心定义了 Prorise-Admin 的 Design Tokens (color.ts, base.ts 等),它们是项目视觉风格的“唯一事实来源”。但这些 TypeScript 对象本身并不能被浏览器直接理解。

本节的核心任务,就是利用 Vanilla Extract 的强大 API,将这些 静态的 TS 定义 转化为 动态的、可在浏览器中使用的 CSS 自自定义属性(CSS 变量)

我们将聚焦于 src/theme/theme.css.ts 文件,一步步揭示其工作原理。

5.3.1. 定义“主题契约” (createThemeContract)

在直接赋予 CSS 变量具体值之前,Vanilla Extract 推荐我们先定义一个 “主题契约 (Theme Contract)”

“契约”就像一个 TypeScript 的 interface,它只定义了变量的 “形状”或“名称结构”,而不关心它们的具体值。在我们的架构中,我们 已经4.5.2 节的 src/theme/type.ts 文件中,以 themeTokens 变量的形式定义了这个契约(即那个包含 null{ value: null, channel: null } 的对象)。

现在,我们只需要将这个 TypeScript 对象“喂”给 Vanilla Extract 即可。

API 语法深度解析: createThemeContract

1
createThemeContract(contract: Object): ThemeContract;
  • contract (参数):
    • 类型: Object
    • 作用: 这是一个描述你的主题“形状”的 JavaScript 对象。对象的 key 将被用作生成 CSS 变量名称的一部分,value 通常是 null 或一个包含 null 的嵌套对象。
    • 我们的实现: 我们将传入在 src/theme/type.ts 中定义的 themeTokens 对象。
  • 返回值:
    • 类型: ThemeContract (一个与输入对象结构完全相同,但所有叶子节点的值都变成了 CSS 变量引用字符串的对象,例如:var(--colors-palette-primary-default-value__1k2j3h)),但是我们在5.1章节禁用了哈希后缀名,所以他不会带有后缀内容,,这是一个核心关键点!在后续的Tailwindcss的适配中我们不能让他有哈希后缀名
    • 作用: 这个返回的对象是类型安全的,你可以在代码中直接引用它的属性(如 themeVars.colors.palette.primary.default.value),Vanilla Extract 会确保你引用的是一个有效的、唯一的 CSS 变量。

代码实现

文件路径: src/theme/theme.css.ts (开始编写)

1
2
3
4
5
6
7
8
9
10
11
12
import { createThemeContract } from "@vanilla-extract/css";
// 1. 导入我们在 4.5.2 中定义的、具有完美形状的“主题契约”
import { themeTokens } from "./type";

// 2. 使用 createThemeContract 创建主题契约
//
// 关键点:
// 因为 themeTokens 已经具备了我们设计的
// { value: null, channel: null } 结构。
// Vanilla Extract 会自动遍历这个结构并为所有 'null' 占位符创建 CSS 变量。
//
export const themeVars = createThemeContract(themeTokens);

就是这么简单。我们的 themeVars 变量现在是一个类型安全的对象,它包含了项目中所有 CSS 变量的引用。

5.3.2. 实现亮/暗模式 (createGlobalTheme)

我们已经有了契约 (themeVars)。现在,我们需要为契约中的每个变量赋予 具体的值,并且要能根据亮色和暗色模式提供不同的值。

API 语法深度解析: createGlobalTheme

1
createGlobalTheme(selector: string, contract: ThemeContract, tokens: Object): void;
  • selector (参数 1):
    • 类型: string
    • 作用: 一个标准的 CSS 选择器。Vanilla Extract 将在这个选择器下生成所有的 CSS 变量。例如,':root[data-theme-mode="light"]'
  • contract (参数 2):
    • 类型: ThemeContract
    • 作用: 我们在上一步通过 createThemeContract 创建的 主题契约对象 (themeVars)。
  • tokens (参数 3):
    • 类型: Object
    • 作用: 一个包含了 具体设计令牌值 的对象。这个对象的结构 必须contract 参数的结构完全匹配。

代码实现

第一步:准备数据获取函数

createGlobalTheme 的第三个参数 tokens 要求数据结构必须与 contract 完全匹配。

  • 我们的 contract (themeVars) 的颜色部分是 结构化 的:{ primary: { value: "...", channel: "..." } }
  • 但我们在 5.2.4 中定义的 lightColorTokens扁平的{ primary: "..." }

这正是我们在 5.2.5 中重构的 addColorChannels 转换器大显身手的地方。我们需要一个辅助函数,来组合所有令牌,并 使用 addColorChannels 转换颜色

文件路径: src/theme/theme.css.ts (追加)

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
import { createThemeContract } from "@vanilla-extract/css";
// 导入【主题契约】
import { themeTokens } from "./type";

// 导入【核心转换器】
import { addColorChannels } from '@/theme/utils/themeUtils';

// 导入【扁平的、具体的值】
import { baseThemeTokens } from './tokens/base';
import { darkColorTokens, lightColorTokens, presetsColors } from './tokens/color';
import { darkShadowTokens, lightShadowTokens } from './tokens/shadow';
import { typographyTokens } from './tokens/typography';

// 导入【枚举】
import { ThemeMode, ThemeColorPresets, HtmlDataAttribute } from './types/enum';

/**
* @description 根据亮/暗模式,获取一个包含了所有具体设计令牌值的完整对象
* @param mode - 主题模式 ('light' 或 'dark')
* @returns 一个与 themeVars 契约结构完全匹配,但填充了具体值的对象
*/
const getThemeTokens = (mode: ThemeMode) => {
// 步骤 1: 根据传入的 mode,选择对应的【扁平】颜色和阴影令牌集
const colorModeTokens = mode === ThemeMode.Light ? lightColorTokens : darkColorTokens;
const shadowModeTokens = mode === ThemeMode.Light ? lightShadowTokens : darkShadowTokens;

// 步骤 2: 组合所有令牌到一个对象中
// 这个对象的结构必须与我们之前定义的 themeVars 契约完全一致
return {
// 步骤 2a:
// [关键点]:对【扁平】的颜色令牌集调用 addColorChannels!
// 这个函数会将其递归转换为 { value: "...", channel: "..." } 的
// 【契约形状】,这正是 createGlobalTheme 所需要的。
colors: addColorChannels(colorModeTokens),

// 步骤 2b: 组合所有非颜色令牌
// 这些令牌的结构 (e.g., typography.fontSize.xs)
// 已经与契约 (themeVars) 匹配
typography: typographyTokens,
shadows: shadowModeTokens,
...baseThemeTokens, // 使用展开运算符合并 spacing, borderRadius, screens 等
};
};

第二步:使用 createGlobalTheme 生成亮/暗模式变量
文件路径: src/theme/theme.css.ts (追加)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 循环遍历 ThemeMode 枚举 (light 和 dark)
for (const themeMode of Object.values(ThemeMode)) {
// 为每种模式创建一个全局主题
createGlobalTheme(
// CSS 选择器, e.g., : root [data-theme-mode = "light"]
`:root[${HtmlDataAttribute.ThemeMode}=${themeMode}]`,

// 我们在 5.3.1 中创建的契约
themeVars,

// 我们刚创建的辅助函数,用于获取该模式下的具体值
getThemeTokens(themeMode),
);
}

类型安全: 得益于我们在 4.5.2 的严格契约设计和 5.2.5addColorChannels 转换器,getThemeTokens 返回的类型被 TypeScript 正确推断,并与 themeVars 契约完美匹配。我们不再需要任何 as any 类型断言,实现了端到端的类型安全。

5.3.3. 实现动态主题色 (globalStyle + assignVars)

我们的主题系统现在可以切换亮/暗模式了。最后一步,是实现允许用户切换应用的主要“品牌色”(例如,从默认的绿色切换到蓝色、紫色等)。

我们希望高效地实现这一点。当用户选择“蓝色”时,我们不希望重新定义全部 200 多个 CSS 变量,而只想精准地覆盖掉少数几个与 primary 颜色相关的变量。

API 语法深度解析: globalStyleassignVars

为了实现精准的变量 覆盖,我们需要组合使用两个新的 API。

API 语法: globalStyle

1
globalStyle(selector: string, styleRule: StyleRule): void;
  • selector (参数 1): 一个标准的 CSS 选择器。globalStyle 会为这个选择器创建一个 CSS 规则块。例如:':root[data-color-palette="cyan"]'
  • styleRule (参数 2): 一个样式规则对象。它有一个 特殊的、由 Vanilla Extract 提供的属性 vars。这个 vars 属性专门用于 覆盖createGlobalTheme 创建的 CSS 变量。

API 语法: assignVars

1
assignVars(contract: ThemeContract | ThemeVars, tokens: Tokens): { vars: { [key: string]: string } };
  • contract (参数 1): 指定你想要覆盖的 变量范围。这是此 API 最强大的地方。我们将传入 themeVars.colors.palette.primary,精准地告诉它我们 想修改主品牌色相关的变量。
  • tokens (参数 2): 提供新的变量值。这个对象的结构 必须 与传入的 contract 子集的结构完全匹配。
  • 返回值: assignVars 会返回一个格式完美的对象 { vars: { ... } },该对象可以被直接赋值给 globalStylevars 属性。

总结assignVars 负责 生成 类型安全的覆盖指令,而 globalStyle 负责将这些指令 应用 到指定的 CSS 选择器上。

代码实现

(我们在 5.2.3 中已经提前定义了 presetsColors,所以这里可以直接使用。)

文件路径: src/theme/theme.css.ts (追加)

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
import { globalStyle, assignVars } from '@vanilla-extract/css';
// ... (其他 imports)

// --- 动态主题色生成 ---

// 循环遍历我们在 5.2.3 中定义的所有预设主题色 (default, cyan, purple...)
for (const preset of Object.values(ThemeColorPresets)) {

// 为每个预设色创建一个全局样式规则
globalStyle(
// CSS 选择器, e.g., : root [data-color-palette = "cyan"]
`:root[${HtmlDataAttribute.ColorPalette}=${preset}]`,
{
// 关键:使用 'vars' 属性来覆盖变量
vars: assignVars(
// 参数 1: [契约子集]
// 我们告诉 assignVars,我们【只】想覆盖
// 'themeVars.colors.palette.primary' 范围内的变量
themeVars.colors.palette.primary,

// 参数 2: [新的值]
// 我们从 presetsColors 中获取该预设的【扁平】颜色对象,
// 并再次使用 addColorChannels 转换器,
// 将其转换为 { value, channel } 的【契约形状】
{ ...addColorChannels(presetsColors[preset]) },
),
},
);
}

5.3.4. 验证最终系统

现在,src/theme/theme.css.ts 文件已经完整且功能强大。是时候验证它的编译结果了。

第一步:重启开发服务器
确保你的 Vite 开发服务器正在运行 (pnpm dev)。由于我们创建了新的 .css.ts 文件,Vite 的热更新 (HMR) 会自动编译它。

第二步:检查浏览器开发者工具
打开浏览器,访问你的应用页面,然后打开开发者工具(按 F12)。

  1. 切换到“Elements” (或“元素”) 面板。
  2. 选中 <html> 根元素。
  3. 在右侧的“Styles” (或“样式”) 面板中,向下滚动,找到以 -- 开头的 CSS 变量定义。

你应该能看到类似以下的景象:

检查要点

  • 变量名称:确认看到了大量由 Vanilla Extract 自动生成的、带有唯一 Hash 后缀的 CSS 变量名 (如 --colors-palette-primary-default-value__b4c5d6--colors-palette-primary-default-channel__b4c5d6)。
  • 亮/暗模式作用域:确认这些变量被正确地包裹在 :root[data-theme-mode="light"] (或 dark) 的选择器下。你可以 手动<html> 元素上添加 data-theme-mode="dark" 属性,观察变量值是否会立即切换。
  • 主题色作用域:确认你看到了用于 覆盖 主题色的规则,例如 :root[data-color-palette="cyan"]。你可以 手动<html> 元素上添加 data-color-palette="cyan" 属性,观察 --colors-palette-primary-... 相关变量的值是否被 cyan 颜色的覆盖规则所改变。
  • 变量值:确认变量的值与我们在 tokens/ 目录下定义的具体值一致。

如果以上都符合预期,那么恭喜你!Vanilla Extract 已经成功将我们的 TypeScript Design Tokens 编译成了浏览器可用的、支持亮/暗模式和动态主题色的 CSS 变量系统。


5.4. 本章小结

在本章中,我们从一个简单的样式集成项目,跃迁到了一个拥有企业级主题架构的系统。我们不再是样式的“使用者”,而是成为了样式系统的“构建者”。

我们围绕 Vanilla Extract 这一核心引擎,完成了从理想到现实的整个闭环。回顾本章,我们取得了以下关键的技术成就:

  1. 集成核心引擎 (5.1):我们成功地将 Vanilla Extract 及其 Vite 插件集成到项目中,获得了使用 TypeScript 编写零运行时 CSS 的能力。
  2. 定义“唯一事实来源” (5.2):我们系统性地在 src/theme/tokens/ 目录下,定义了所有 Design Tokens(颜色、间距、排版、阴影等)。我们还 修正了关键的逻辑依赖,将“预设主题色”和“枚举”提前定义,确保了 color.ts 文件的逻辑自洽。
  3. 攻克“契约转换”难题 (5.2.5):这是本章的 核心技术突破。我们摒弃了“天真”的类型欺骗实现,通过引入先进的 TypeScript 递归泛型 AddChannelToLeaf<T>,构建了一个 类型安全addColorChannels 核心转换器。这个函数完美地充当了“易于维护的扁平令牌”和“Vanilla Extract 所需的结构化契约”之间的桥梁。
  4. 生成动态 CSS 变量 (5.3):我们深入学习并正确使用了 Vanilla Extract 的四大核心 API:
    • createThemeContract:利用我们在 4.5.2 定义的 themeTokens 契约,创建了类型安全的变量引用 themeVars
    • createGlobalTheme:结合 getThemeTokens 辅助函数与 addColorChannels 转换器,成功生成了 亮/暗模式 的 CSS 变量。
    • globalStyle + assignVars:利用 assignVars 的“变量覆盖”能力,高效实现了 动态主题色(品牌色切换)的功能。

至此,Prorise-Admin 的主题系统已经完全建成。它不仅 类型安全、结构清晰,并且具备了完整的 亮/暗模式多色板 切换能力,为后续的业务开发和 UI 库适配(adapter/)打下了坚实的基础。


5.5. 代码入库:主题系统的基石

我们刚刚完成了一个巨大的里程碑:Prorise-Admin 的“主题引擎”已经正式构建完毕。这是我们项目中 最具价值的核心基础设置之一。现在,我们必须将这个阶段性的胜利成果,安全地提交到 Git 仓库中。

第一步:检查代码状态

使用 git status 查看我们海量的变更。

1
git status

你会看到一系列的新增文件和修改:

  • 新增: vite.config.ts 中增加了 Vanilla Extract 插件配置。
  • 新增: package.json / pnpm-lock.yaml 中增加了 color 库。
  • 新增: src/theme/theme.css.ts (核心引擎)。
  • 新增: src/theme/types/enum.ts (枚举)。
  • 新增: src/theme/utils/themeUtils.ts (包含 rgbAlpha 和类型安全的 addColorChannels)。
  • 新增: src/theme/tokens/ 目录下的所有 Design Tokens 文件 (color.ts, base.ts, typography.ts 等)。
  • 修改: src/theme/type.ts (在 4.5.2 重构时创建)。

第二步:暂存所有变更

将所有新文件和修改添加到暂存区。

1
git add .

第三步:执行提交

我们编写一条符合“约定式提交”规范的 Commit Message。feat (新功能) 是最合适的类型,theme 是最合适的 scope

1
git commit -m "feat(theme): build theme engine with vanilla extract and design tokens"

第四步:见证“质量铁三角”的守护

当你按下回车时,我们在第三章配置的自动化流程将再次启动:

  1. pre-commit 钩子(代码质量)

    • Biome (format & lint):会检查并格式化你所有的新增 .ts 文件。
    • tsc --noEmit将对我们全新的主题系统进行严格的类型检查。它会验证 addColorChannelsAddChannelToLeaf<T> 类型是否正确推导,并验证 createGlobalThemetokens 参数是否与 themeVars 契约 完全匹配
    • 如果存在任何类型不匹配(例如我们没有重构 5.2.5),tsc 将在此处失败,提交将被中止!
  2. commit-msg 钩子(提交规范)

    • commitlint:将检查你的 feat(theme): ... 消息是否符合规范。

只有当所有检查都通过时,这个代表着“主题系统 v1.0”的珍贵提交才会被安全地记录到我们的 Git 历史中。

原子提交的胜利: 这次提交是一个完美的“原子提交”范例。它完整地包含了“构建一个可工作的动态主题引擎”所需的所有代码。未来任何人想了解主题系统是如何从 0 到 1 建立的,都可以直接 git checkout 这一个 commit。

好的,我们已经完成了 `4.5` 到 `5.3` 的全部重构,一个健壮、类型安全的主题引擎已经就位。现在,是时候为本章画上一个句号了。

5.4. 本章小结

在本章中,我们从一个简单的样式集成项目,跃迁到了一个拥有企业级主题架构的系统。我们不再是样式的“使用者”,而是成为了样式系统的“构建者”。

我们围绕 Vanilla Extract 这一核心引擎,完成了从理想到现实的整个闭环。回顾本章,我们取得了以下关键的技术成就:

  1. 集成核心引擎 (5.1):我们成功地将 Vanilla Extract 及其 Vite 插件集成到项目中,获得了使用 TypeScript 编写零运行时 CSS 的能力。
  2. 定义“唯一事实来源” (5.2):我们系统性地在 src/theme/tokens/ 目录下,定义了所有 Design Tokens(颜色、间距、排版、阴影等)。我们还 修正了关键的逻辑依赖,将“预设主题色”和“枚举”提前定义,确保了 color.ts 文件的逻辑自洽。
  3. 攻克“契约转换”难题 (5.2.5):这是本章的 核心技术突破。我们摒弃了“天真”的类型欺骗实现,通过引入先进的 TypeScript 递归泛型 AddChannelToLeaf<T>,构建了一个 类型安全addColorChannels 核心转换器。这个函数完美地充当了“易于维护的扁平令牌”和“Vanilla Extract 所需的结构化契约”之间的桥梁。
  4. 生成动态 CSS 变量 (5.3):我们深入学习并正确使用了 Vanilla Extract 的四大核心 API:
    • createThemeContract:利用我们在 4.5.2 定义的 themeTokens 契约,创建了类型安全的变量引用 themeVars
    • createGlobalTheme:结合 getThemeTokens 辅助函数与 addColorChannels 转换器,成功生成了 亮/暗模式 的 CSS 变量。
    • globalStyle + assignVars:利用 assignVars 的“变量覆盖”能力,高效实现了 动态主题色(品牌色切换)的功能。

至此,Prorise-Admin 的主题系统已经完全建成。它不仅 类型安全、结构清晰,并且具备了完整的 亮/暗模式多色板 切换能力,为后续的业务开发和 UI 库适配(adapter/)打下了坚实的基础。


5.5. 代码入库:主题系统的基石

我们刚刚完成了一个巨大的里程碑:Prorise-Admin 的“主题引擎”已经正式构建完毕。这是我们项目中 最具价值的核心基础设置之一。现在,我们必须将这个阶段性的胜利成果,安全地提交到 Git 仓库中。

第一步:检查代码状态

使用 git status 查看我们海量的变更。

1
git status

你会看到一系列的新增文件和修改:

  • 新增: vite.config.ts 中增加了 Vanilla Extract 插件配置。
  • 新增: package.json / pnpm-lock.yaml 中增加了 color 库。
  • 新增: src/theme/theme.css.ts (核心引擎)。
  • 新增: src/theme/types/enum.ts (枚举)。
  • 新增: src/theme/utils/themeUtils.ts (包含 rgbAlpha 和类型安全的 addColorChannels)。
  • 新增: src/theme/tokens/ 目录下的所有 Design Tokens 文件 (color.ts, base.ts, typography.ts 等)。
  • 修改: src/theme/type.ts (在 4.5.2 重构时创建)。

第二步:暂存所有变更

将所有新文件和修改添加到暂存区。

1
git add .

第三步:执行提交

我们编写一条符合“约定式提交”规范的 Commit Message。feat (新功能) 是最合适的类型,theme 是最合适的 scope

1
git commit -m "feat(theme): build theme engine with vanilla extract and design tokens"

第四步:见证“质量铁三角”的守护

当你按下回车时,我们在第三章配置的自动化流程将再次启动:

  1. pre-commit 钩子(代码质量)

    • Biome (format & lint):会检查并格式化你所有的新增 .ts 文件。
    • tsc --noEmit将对我们全新的主题系统进行严格的类型检查。它会验证 addColorChannelsAddChannelToLeaf<T> 类型是否正确推导,并验证 createGlobalThemetokens 参数是否与 themeVars 契约 完全匹配
    • 如果存在任何类型不匹配(例如我们没有重构 5.2.5),tsc 将在此处失败,提交将被中止!
  2. commit-msg 钩子(提交规范)

    • commitlint:将检查你的 feat(theme): ... 消息是否符合规范。

只有当所有检查都通过时,这个代表着“主题系统 v1.0”的珍贵提交才会被安全地记录到我们的 Git 历史中。