第六章. 主题驱动:Provider、适配器与状态管理
在第五章,我们利用 Vanilla Extract 将 Design Tokens 编译成了强大的 CSS 变量系统。但这只是静态的“蓝图”。要让主题真正“活”起来——响应用户的操作(切换亮/暗模式、改变主题色),我们需要一个可靠的 状态管理 机制来存储和驱动这些变化。
本章,我们将引入轻量级的状态管理器 Zustand,创建 settingStore 来中心化管理所有与主题(及未来其他全局设置)相关的状态。随后,我们将构建核心的 ThemeProvider 组件,连接状态与 CSS 变量。最后,我们将编写 Ant Design 适配器 和配置 Tailwind CSS,让它们都能正确地“消费”我们精心设计的主题系统。
6.1. 状态管理:引入 Zustand
6.1.1. 决策:为何选择 Zustand?
架构师的思考
Redux Toolkit 功能强大、生态成熟,为什么不用它?
expert
Redux Toolkit 非常适合管理复杂、规范化、需要时间旅行调试的应用状态,例如大型电商的购物车、订单状态。
expert
但对于我们的主题设置这类相对简单的 UI 状态,引入 Redux 会带来大量的样板代码:需要定义 Slice, Reducers, Actions, Selectors,配置 Store 等,心智负担较重。
expert
Zustand 基于 Hooks API,极其简洁、轻量。
expert
它用一个简单的 create 函数就能创建 Store,几乎没有样板代码。
expert
并且它无需 Provider 包裹,可以在任何地方直接 import 并使用 Store Hook。
expert
对于管理主题、布局等全局 UI 配置状态,Zustand 是 2025 年更现代、更轻量、开发体验更好的选择。
expert
我们将把更复杂的 服务端状态 交给 React Query (后续章节引入)。
6.1.2. 安装 Zustand
我们使用 pnpm 将 Zustand 添加到项目依赖中。
6.1.3. 扩展全局枚举
在编写 Store 之前,我们需要在我们 全局唯一的枚举文件 src/theme/types/enum.ts 中,追加 本章即将用到的 StorageEnum。
我们在第五章已经定义了主题相关的枚举,现在我们只需在文件末尾添加新内容即可。
文件路径: src/theme/types/enum.ts (修改)
1 2 3 4 5 6 7 8 9
|
export enum StorageEnum { Settings = "settings", }
|
架构说明:保持所有相关的枚举(主题、存储键)都存放在 src/theme/types 目录下,有助于维护“主题系统”这一内聚模块的完整性和单一事实来源。
6.1.4. 渐进式构建 settingStore.ts
现在,我们在 src 目录下创建 store 目录和 settingStore.ts 文件。我们将从一个最简化的 Store 开始,逐步增强其功能。
第一步:创建 Store 目录与文件
1 2 3
| mkdir store touch store/settingStore.ts
|
第二步:创建最简化的 Store
我们的第一个目标,仅仅是创建一个能存储和更新主题模式 (themeMode) 的 Store。
文件路径: src/store/settingStore.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
| import { create } from "zustand";
import { ThemeMode } from "@/theme/types/enum";
export type SettingsType = { themeMode: ThemeMode; };
type SettingStore = { settings: SettingsType; setThemeMode: (themeMode: ThemeMode) => void; };
const useSettingStore = create<SettingStore>((set) => ({ settings: { themeMode: ThemeMode.Light, }, setThemeMode: (themeMode) => { set((state) => ({ settings: { ...state.settings, themeMode }, })); }, }));
export default useSettingStore;
|
代码深度解析:
create<SettingStore>((set) => ({...})) 是 Zustand 的核心 API。它接收一个函数作为参数,这个函数的唯一参数 set 是一个用于更新状态的函数。- 我们返回的对象就是 Store 的初始内容,包含了初始状态
settings 和一个名为 setThemeMode 的 action。
第三步:重构 Store 以实现可扩展性
现在,我们扩展 SettingsType,并改进 Store 的结构,使其能管理所有与主题相关的设置,并让 actions 的组织更有条理。
文件路径: src/store/settingStore.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
| import { create } from "zustand"; import { FontFamilyPreset, typographyTokens } from "@/theme/tokens/typography";
import { ThemeColorPresets, ThemeMode } from "@/theme/types/enum";
export type SettingsType = { themeColorPresets: ThemeColorPresets; themeMode: ThemeMode; fontFamily: string; fontSize: number; };
type SettingStore = { settings: SettingsType; actions: { setSettings: (newSettings: Partial<SettingsType>) => void; }; };
const useSettingStore = create<SettingStore>((set) => ({ settings: { themeColorPresets: ThemeColorPresets.Default, themeMode: ThemeMode.Light, fontFamily: FontFamilyPreset.openSans, fontSize: Number(typographyTokens.fontSize.sm), }, actions: { setSettings: (newSettings) => set((state) => ({ settings: { ...state.settings, ...newSettings }, })), }, }));
export const useSettings = () => useSettingStore((state) => state.settings); export const useSettingActions = () => useSettingStore((state) => state.actions);
|
架构演进解析:
- 我们将所有
actions 归于一个独立的命名空间,使得 Store 的结构更清晰:state 是 settings,mutations 是 actions。 - 我们提供了一个通用的
setSettings action,它接受一个 部分 (Partial) 的 SettingsType 对象,这让我们可以一次更新一个或多个设置项,非常灵活。 - 我们导出了
useSettings 和 useSettingActions 两个 Hooks。这种做法可以优化 React 的重渲染。如果一个组件只需要调用 action 而不关心状态的变化,它只使用 useSettingActions 就不会因为 settings 的改变而触发不必要的重渲染。
第四步:引入持久化 (persist middleware)
用户选择的主题(亮/暗、颜色)应该在刷新页面后 保持不变。我们需要将这些设置持久化到浏览器的 localStorage 中。
文件路径: src/store/settingStore.ts (在 import { create }... 后追加)
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
| import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware'; import { FontFamilyPreset, typographyTokens } from '@/theme/tokens/typography';
import { StorageEnum, ThemeColorPresets, ThemeMode } from '@/theme/types/enum';
const useSettingStore = create<SettingStore>()( persist( (set) => ({ settings: { themeColorPresets: ThemeColorPresets.Default, themeMode: ThemeMode.Light, fontFamily: FontFamilyPreset.openSans, fontSize: Number(typographyTokens.fontSize.sm), }, actions: { setSettings: (newSettings) => set((state) => ({ settings: { ...state.settings, ...newSettings } })), }, }), { name: StorageEnum.Settings, storage: createJSONStorage(() => localStorage), partialize: (state) => ({ settings: state.settings }), } ) );
|
persist 中间件深度解析:
- 工作模式:
persist 是一个 高阶函数(函数返回函数),它像“洋葱皮”一样包裹住我们原来的 Store 定义。当状态发生变化时,persist 会拦截这次变化,并将新的状态(根据配置)自动保存到指定的存储中。 name: 指定存储在 localStorage 中的键名。我们使用 StorageEnum.Settings 来避免硬编码字符串,这是企业级开发的良好实践。storage: 显式指定使用 localStorage 作为存储介质。createJSONStorage 会自动处理 JavaScript 对象和 JSON 字符串之间的转换。partialize 的价值: 这是 性能和安全性的关键优化。默认情况下,persist 会尝试存储 Store 中的 所有 数据。但我们的 actions 对象中包含的是函数,函数无法被 JSON 序列化,会导致报错或无效存储。partialize 允许我们精确地告诉 persist:“嘿,你只需要关心 state.settings 就行了,忽略 actions。”这确保了只有纯粹的状态数据被持久化。
阶段性成果:我们成功引入了 Zustand,并创建了 settingStore 来中心化管理应用的主题设置。通过 极其循序渐进 的构建,我们从一个最小化的 Store 开始,逐步扩展并最终集成了 持久化 功能。我们深度解析了 persist 中间件的每一个配置项,特别是 partialize 的优化价值。导出的 useSettings 和 useSettingActions Hooks 为后续 ThemeProvider 消费这些状态做好了准备。
6.2. 构建 ThemeProvider
在 6.1 节,我们使用 Zustand 创建了 settingStore,它现在是我们应用主题状态的“唯一事实来源”。在第五章,我们利用 Vanilla Extract 将 Design Tokens 编译成了对应不同主题模式和颜色预设的 CSS 变量集,它们由 <html> 根元素上的 data-* 属性激活。
现在,我们需要一个“总控制器”来完成这最后一步:读取 settingStore 中的状态,并将这些状态实时地同步到 <html> 元素的属性上。这个总控制器,就是我们的 ThemeProvider React 组件。
6.2.1. 核心职责:状态订阅与 DOM 操作
ThemeProvider 的核心职责非常明确和聚焦:
- 订阅状态:使用
useSettings Hook 实时监听 settingStore 中与主题相关的状态变化。 - 执行副作用:当状态发生变化时,通过 React 的
useEffect Hook 执行副作用操作——直接修改 document.documentElement (<html> 标签) 的 data-* 属性或 style 属性,从而激活我们在 CSS 中定义的变量。
6.2.2. 渐进式构建 ThemeProvider.tsx
我们将在 src/theme/ 目录下创建 theme-provider.tsx 文件,并一步步实现其功能。
第一步:创建组件骨架并订阅状态
我们先创建 ThemeProvider 组件的基本结构,并使用 useSettings Hook 来获取当前的主题设置。
文件路径: src/theme/theme-provider.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| import type React from "react"; import { useEffect } from "react"; import { useSettings } from "@/store/settingStore"; import { HtmlDataAttribute } from "@/theme/types/enum";
interface ThemeProviderProps { children: React.ReactNode; }
export function ThemeProvider({ children }: ThemeProviderProps) { const settings = useSettings();
return <>{children}</>; }
|
代码解析:
- 通过调用
useSettings(),ThemeProvider 组件就 订阅 了 settingStore 的状态。当 settings 对象在 Zustand 中发生变化时,这个组件会自动重新渲染,从而获取到最新的 settings 值。
第二步:同步 themeMode,激活亮/暗模式
现在,我们添加第一个 useEffect,用于将 themeMode 状态同步到 <html> 元素的 data-theme-mode 属性上。
文件路径: src/theme/theme-provider.tsx (在 settings 下方添加)
1 2 3 4 5 6 7 8 9 10
| const settings = useSettings();
useEffect(() => { const root = window.document.documentElement; root.setAttribute(HtmlDataAttribute.ThemeMode, settings.themeMode); }, [settings.themeMode]);
|
useEffect 深度解析:
- 副作用处理: 在 React 函数组件中,直接修改 DOM(如
setAttribute)属于 副作用 (Side Effect)。useEffect 是 React 提供的、用于处理这类副作用的标准 Hook。它确保了 DOM 操作在 React 完成渲染 之后 执行。 - 依赖项数组
[settings.themeMode]: 这是 useEffect 的性能关键。这个数组告诉 React:“请监听 settings.themeMode 这个值。只有当它发生变化时(例如从 'light' 变成 'dark'),才需要重新执行这个 effect 函数”。这避免了在其他设置(如字体大小)变化时,也去执行不必要的 setAttribute 操作。
连接第五章: 当这个 useEffect 执行,<html> 标签上就会被添加 data-theme-mode="light" 或 data-theme-mode="dark" 属性。这会 精确地激活 我们在 theme.css.ts 中使用 createGlobalTheme 为对应选择器 (:root[data-theme-mode="..."]) 生成的 那一整套 CSS 变量!亮/暗模式切换的核心机制就此打通。
第三步:同步 themeColorPresets,激活动态主题色
我们以完全相同的方式,添加第二个 useEffect 来同步主题色预设。
文件路径: src/theme/theme-provider.tsx (追加第二个 useEffect)
1 2 3 4 5 6 7 8 9 10
|
useEffect(() => { const root = window.document.documentElement; root.setAttribute(HtmlDataAttribute.ColorPalette, settings.themeColorPresets); }, [settings.themeColorPresets]);
|
连接第五章: 当这个 useEffect 执行,<html> 标签上的 data-color-palette 属性就会被更新(例如 data-color-palette="cyan")。这会 精确地激活 我们在 theme.css.ts 中使用 globalStyle 和 assignVars 为该选择器生成的、用于覆盖主品牌色的 CSS 变量 规则!动态主题色切换的核心机制也打通了。
第四步:同步字体与基础字号
对于字体和基础字号,我们直接修改 <html> (针对 fontSize) 和 <body> (针对 fontFamily) 的 style 属性。
文件路径: src/theme/theme-provider.tsx (追加第三个 useEffect)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
|
useEffect(() => { const root = window.document.documentElement; const body = window.document.body;
root.style.fontSize = `${settings.fontSize}px`;
body.style.fontFamily = settings.fontFamily;
}, [settings.fontFamily, settings.fontSize]);
|
6.2.3. 应用 ThemeProvider 并验证
ThemeProvider 组件已经构建完成。现在,我们需要在应用的根部使用它,来包裹我们的整个应用。
文件路径: src/main.tsx (修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| 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'; import { ThemeProvider } from '@/theme/theme-provider';
ReactDOM.createRoot(document.getElementById('root')!).render( <React.StrictMode> {/* 2. 在所有组件的最外层包裹 ThemeProvider */} <ThemeProvider> <ConfigProvider> {/* 我们暂时保留 ConfigProvider,下一节会处理它 */} <AntdApp> <MyApp /> </AntdApp> </ConfigProvider> </ThemeProvider> </React.StrictMode>, );
|
验证:
- 重启开发服务器 (
pnpm dev)。 - 打开浏览器开发者工具,检查
<html> 元素。你会发现它已经被添加了 data-theme-mode="light" 和 data-color-palette="default" 属性,并且 style 属性中设置了 font-size,这正是我们在 Zustand 的预设 State 的功劳

6.3. 实现 Ant Design 适配器
在前面的章节,我们的 ThemeProvider 已经成功地将全局主题状态(如 'dark' 模式、'cyan' 色)转换为全局 CSS 变量(如 data-theme-mode="dark"),并应用到了 <html> 元素上。
这套机制对于使用原生 CSS 组件已经可以完美工作。但是,我们的核心组件库 Ant Design,拥有一个独立的主题系统,它默认 不消费 CSS 变量。
6.3.1. 问题的识别:主题系统的“语言”不通
Ant Design 的样式由一个名为 ConfigProvider 的 React Context 组件控制。它不读取 CSS 变量,而是接收一个 JavaScript theme 对象(例如 { token: { colorPrimary: '#...', fontSize: 16 } })来为其所有子组件注入样式。
这就导致了一个核心问题:ThemeProvider 广播的“CSS 变量信号”,Ant Design“听不懂”。这将导致我们的 Ant Design 组件(如 Button, Menu)与页面的其他部分(如布局背景、原生文本)在主题上完全脱节。
我们的目标是:让 Ant Design 能够完全理解并动态响应我们的全局主题状态。
6.3.2. 架构决策:规避“紧耦合”陷阱
最直观的错误想法,是在 ThemeProvider 内部“顺便”处理 Ant Design 的主题。
反面教材(错误的设计):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| import { ConfigProvider, theme as antdTheme } from 'antd'; import { useSettings } from "@/store/settingStore";
export function ThemeProvider({ children }) { const settings = useSettings(); useEffect(() => { }, [settings.themeMode]); const algorithm = settings.themeMode === 'light' ? ... : ...; const token = { colorPrimary: ..., fontSize: settings.fontSize };
return ( <ConfigProvider theme={{ algorithm, token }}> {children} </ConfigProvider> ); }
|
这种设计会导致两个严重问题:
- 紧耦合:
ThemeProvider 的核心职责是管理通用主题状态。一旦它 import { ConfigProvider },它就与 antd 这个 具体 的 UI 库“锁死”了。 - 违反开闭原则:此原则要求软件“对扩展开放,对修改关闭”。
- 无法扩展:如果我们想再支持 Material UI (MUI),我们就必须 修改
ThemeProvider 的内部,加入 MuiThemeProvider 和另一套“翻译”逻辑,使其代码加倍臃肿。 - 难以维护:如果我们想 替换 AntD,我们就必须深入
ThemeProvider 的核心代码进行“外科手术”。
架构师的思考
我明白了。直接在 ThemeProvider 里处理,会让它变得臃肿且脆弱。那正确的架构思路是什么?
expert
我们需要一个“翻译官”。这个翻译官的角色,专门负责将我们“通用的主题语言”翻译成“Ant Design 的特定语言”。
expert
正是 适配器模式。我们将为每个需要接入我们主题系统的第三方 UI 库,创建一个专属的适配器。比如 AntdAdapter、MuiAdapter 等。
expert
彻底解耦。ThemeProvider 保持纯净,只管广播通用主题。AntdAdapter 则订阅这个主题,并负责与 Ant Design 的 ConfigProvider 进行所有“对话”。添加或移除对某个 UI 库的支持,只需要增加或删除一个适配器文件,ThemeProvider 核心代码完全不用动。
6.3.3. 第一步:构建“翻译官” AntdAdapter
AntdAdapter 是一个独立的 React 组件,它的职责非常单一:订阅全局 settings,将其“翻译”为 AntD theme 对象,并渲染 ConfigProvider。
1. 准备“翻译工具” (removePx)
我们的主题令牌(baseThemeTokens)中,borderRadius 等值是 "8px" 这样的字符串。Ant Design 的 token 对象需要 8 这样的数字。我们首先创建一个工具函数来处理这种转换。
文件路径: 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
|
export const removePx = (value: string | number): number => { if (typeof value === "number") return value; if (!value) throw new Error("Invalid value: empty string"); const trimmed = value.trim(); const hasPx = /px$/i.test(trimmed); const num = hasPx ? trimmed.slice(0, -2) : trimmed; const result = Number.parseFloat(num); if (Number.isNaN(result)) { throw new Error(`Invalid value: ${value}`); } return result; };
|
2. 编写 AntdAdapter 核心逻辑
现在,我们创建 AntdAdapter 组件。它符合我们最早的时候创建的 UILibraryAdapter 接口(({ mode, children }) => React.ReactElement),在这里,我们可以把坑填上了,我们将他定义为枚举
文件路径: src/theme/type.ts (修改)
1 2 3 4 5 6 7 8 9
| import type { ThemeMode } from "./types/enum";
export type UILibraryAdapterProps = { mode: ThemeMode; children: React.ReactNode; }; export type UILibraryAdapter = React.FC<UILibraryAdapterProps>;
|
文件路径: src/theme/adapter/antd.adapter.tsx (新建)
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
| import type { ThemeConfig } from "antd"; import { App as AntdApp, ConfigProvider, theme } from "antd";
import { useSettings } from "@/store/settingStore";
import { ThemeMode } from "@/theme/types/enum";
import { removePx } from "@/theme/utils/themeUtils";
import { baseThemeTokens } from "../tokens/base"; import { darkColorTokens, lightColorTokens, presetsColors, } from "../tokens/color"; import type { UILibraryAdapter } from "../type";
export const AntdAdapter: UILibraryAdapter = ({ mode, children, }): React.ReactNode => { const { themeColorPresets, fontFamily, fontSize } = useSettings(); const algorithm = mode === ThemeMode.Light ? theme.defaultAlgorithm : theme.darkAlgorithm;
const colorTokens = mode === ThemeMode.Light ? lightColorTokens : darkColorTokens; const primaryColorToken = presetsColors[themeColorPresets];
const token: ThemeConfig["token"] = { colorPrimary: primaryColorToken.default, colorSuccess: colorTokens.palette.success.default, colorWarning: colorTokens.palette.warning.default, colorError: colorTokens.palette.error.default, colorInfo: colorTokens.palette.info.default, colorBgLayout: colorTokens.background.default, colorBgContainer: colorTokens.background.paper, colorBgElevated: colorTokens.background.default,
fontFamily: fontFamily, fontSize: fontSize,
borderRadius: removePx(baseThemeTokens.borderRadius.default), borderRadiusLG: removePx(baseThemeTokens.borderRadius.lg), borderRadiusSM: removePx(baseThemeTokens.borderRadius.sm),
wireframe: false, };
const components: ThemeConfig["components"] = { Breadcrumb: { separatorMargin: removePx(baseThemeTokens.spacing[1]), }, Menu: { colorFillAlter: "transparent", itemColor: colorTokens.text.secondary, }, };
return ( <ConfigProvider theme={{ algorithm, token, components }}> {/* 必须再次包裹 <App>,这是 AntD v5+ 的要求。 它能确保 message.success()、Modal.confirm() 等静态方法能正确继承 ConfigProvider 的主题。 */} <AntdApp>{children}</AntdApp> </ConfigProvider> ); };
|
这个适配器现在是完全独立的。它订阅 useSettings,接收 mode,并输出一个配置好的 ConfigProvider。
6.3.4. 第二步:升级 ThemeProvider 以支持适配器
现在,我们必须升级 ThemeProvider,使其拥有一个“插槽”来承载 AntdAdapter(以及未来可能的其他适配器),而 不需要知道 AntdAdapter 的具体实现。
文件路径: src/theme/theme-provider.tsx (修改)
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
| import type React from "react"; import { useEffect } from "react"; import { useSettings } from "@/store/settingStore"; import type { UILibraryAdapter } from "./type"; import { HtmlDataAttribute } from "./types/enum";
interface ThemeProviderProps { children: React.ReactNode; adapters?: UILibraryAdapter[]; }
export function ThemeProvider({ children, adapters = [] }: ThemeProviderProps) { const settings = useSettings();
const wrappedWithAdapters = adapters.reduceRight( (children, Adapter) => ( <Adapter key={Adapter.name} mode={settings.themeMode}> {children} </Adapter> ), children, );
return <>{wrappedWithAdapters}</>; }
|
通过这次升级,ThemeProvider 具备了“适配器插槽”的能力。它只依赖 UILibraryAdapter 这个 类型,从而与所有 具体 的适配器实现(如 AntdAdapter)保持了解耦。
6.3.5. 第三步:集成并验证
最后一步,我们在应用入口 main.tsx 中,将 AntdAdapter“插入”到 ThemeProvider 的插槽中。
文件路径: src/main.tsx (修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| import React from "react"; import ReactDOM from "react-dom/client"; import MyApp from "@/MyApp"; import "@/theme/theme.css";
import { AntdAdapter } from "@/theme/adapter/antd.adapter"; import { ThemeProvider } from "@/theme/theme-provider";
import "@/index.css"; const rootElement = document.getElementById("root"); if (rootElement) { ReactDOM.createRoot(rootElement).render( <React.StrictMode> <ThemeProvider adapters={[AntdAdapter]}> <MyApp /> </ThemeProvider> </React.StrictMode>, ); }
|
最终工作流程
- 应用启动,
ThemeProvider 渲染,并传入 adapters={[AntdAdapter]}。 ThemeProvider 内部,useEffect 开始工作,将 settings 状态同步为 <html> 上的 CSS 变量。- 同时,
ThemeProvider 的 reduceRight 逻辑执行,生成 <AntdAdapter mode={...}><MyApp /></AntdAdapter> 的 TSX 结构。 - React 开始渲染
AntdAdapter。AntdAdapter 内部订阅 useSettings,并将 settings “翻译”为 AntD theme 对象。 AntdAdapter 渲染出 <ConfigProvider theme={...}><App><MyApp /></App></ConfigProvider>。- 最终,
MyApp 及其内部所有组件,都被正确地包裹在两个主题系统(CSS 变量 和 AntD ConfigProvider)中,两者均由 useSettings 同一个数据源驱动,保持了完美同步。
阶段性成果:我们成功地完成了主题系统的闭环。通过 创建 AntdAdapter、升级 ThemeProvider、最终集成 这三个清晰的步骤,我们构建了一个高内聚、低耦合、可扩展的主题架构。现在,Ant Design 组件已经能够完全响应我们全局主题状态的任何变化。
6.4. 配置 Tailwind 消费 Tokens:实现样式统一
我们已经让 Ant Design 能够响应我们的主题系统。现在,轮到混合样式架构的另一位主角——Tailwind CSS 了。
目标:确保我们使用 Tailwind 原子类(如 bg-primary, p-4, rounded-md, lg:text-xl)构建的自定义布局和组件,其视觉效果(颜色、间距、字体、断点等)与我们的 Design Tokens 完全一致,并能 动态响应 主题切换。
核心思路:我们将配置 Tailwind,使其 theme 对象中的值,不再 使用 Tailwind 的默认值或硬编码值,而是 引用 我们在第五章通过 Vanilla Extract 生成的 全局 CSS 变量。
6.4.1. 第一步:在 CSS 中配置 darkMode 策略
Tailwind v4 推荐将 darkMode 策略定义在 CSS 文件中。我们需要告诉 Tailwind 如何识别暗黑模式。在 Prorise-Admin 中,我们通过 ThemeProvider 在 <html> 元素上添加 data-theme-mode="dark" 属性来标识暗黑模式。
文件路径: src/index.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
|
@import "tailwindcss"; @import "tw-animate-css";
@config "../tailwind.config.ts";
@layer base { *, ::after, ::before, ::backdrop, ::file-selector-button { border-color: rgba(var(--colors-palette-gray-500Channel) / var(--opacity-border)); } body { background-color: var(--colors-background-default); } }
@theme { }
@variant dark (&:is([data-theme-mode="dark"] *));
|
关键配置解析:
@config "../tailwind.config.ts";: 明确告诉 Tailwind v4 去加载我们的 JS 配置文件。这是必需的,因为 v4 不再自动查找。@variant dark (&:is([data-theme-mode="dark"] *));: 这是 v4 中配置 darkMode: 'selector' 的 新方式。它精确地将 Tailwind 的 dark: 变体与我们 ThemeProvider 设置的 data-theme-mode="dark" 属性关联起来。@layer base 覆盖: 我们保留了对 border-color 的覆盖,确保边框颜色使用我们 VE 定义的灰色变量和透明度。
6.4.2. 第二步:创建/配置 tailwind.config.ts 映射 Tokens
现在,我们来配置 tailwind.config.ts。它的核心作用是将我们 src/theme/tokens/ 中的 结构 映射到 Tailwind 的 theme 对象中,但 值 是指向 CSS 变量的引用。
前置准备:确认工具函数
我们需要确保 src/utils/theme.ts 中包含 createTailwinConfg 和 creatColorChannel 这两个关键函数(您已提供源码)。
文件路径: src/utils/theme.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 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
| import color from 'color';
import { themeTokens } from '@/theme/type';
export const toCssVar = (propertyPath: string) => { return `--${propertyPath.split(".").join("-")}`; };
export const getThemeTokenVariants = (propertyPath: string): string[] => { const keys = propertyPath.split("."); let current: any = themeTokens; for (const key of keys) { if (current && typeof current === 'object' && key in current) { current = current[key]; } else { console.warn(`[getThemeTokenVariants] Path not found: ${propertyPath}`); return []; } } if (current && typeof current === 'object' && Object.values(current).every(v => v === null)) { return Object.keys(current); } console.warn(`[getThemeTokenVariants] Path does not point to a leaf object: ${propertyPath}`); return []; };
export const createTailwinConfg = (propertyPath: string) => { const variants = getThemeTokenVariants(propertyPath); const result = variants.reduce( (acc, variant) => { const themeKey = Number.isNaN(Number(variant)) ? variant : `${variant}`; acc[themeKey] = `var(${toCssVar(`${propertyPath}.${variant}`)})`; return acc; }, {} as Record<string, string>, ); return result; };
export const creatColorChannel = (propertyPath: string) => { const variants = getThemeTokenVariants(propertyPath); const result = variants.reduce( (acc, variant) => { const variantKey = variant === "default" ? "DEFAULT" : variant; acc[variantKey] = `rgb(var(${toCssVar(`${propertyPath}.${variant}Channel`)}))`; return acc; }, {} as Record<string, string>, ); return result; };
|
配置 tailwind.config.ts
现在,我们利用这些工具函数来填充 tailwind.config.ts。
文件路径: tailwind.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 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
| import type { Config } from "tailwindcss"; import { breakpointsTokens } from "./src/theme/tokens/breakpoints";
import { createColorChannel, createTailwinConfg, } from "./src/theme/utils/themeUtils";
export default { content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: { fontFamily: createTailwinConfg("typography.fontFamily"),
extend: { colors: { primary: { ...createColorChannel("colors.palette.primary"), foreground: "rgb(var(--colors-common-white-channel))", }, destructive: { ...createColorChannel("colors.palette.error"), foreground: "rgb(var(--colors-common-white-channel))", }, secondary: { DEFAULT: "rgb(var(--colors-palette-primary-default-channel) / 0.1)", foreground: "rgb(var(--colors-palette-primary-default-channel))", }, accent: { DEFAULT: "rgb(var(--colors-background-neutral-channel))", foreground: "rgb(var(--colors-text-primary-channel))", },
success: createColorChannel("colors.palette.success"), warning: createColorChannel("colors.palette.warning"), error: createColorChannel("colors.palette.error"), info: createColorChannel("colors.palette.info"), gray: createColorChannel("colors.palette.gray"),
background: { DEFAULT: "rgb(var(--colors-background-default-channel))", paper: "rgb(var(--colors-background-paper-channel))", }, foreground: "rgb(var(--colors-text-primary-channel))",
border: "rgb(var(--colors-palette-gray-300-channel))", input: "rgb(var(--colors-palette-gray-300-channel))", ring: "rgb(var(--colors-palette-primary-default-channel))", }, opacity: createTailwinConfg("opacity"), borderRadius: createTailwinConfg("borderRadius"), boxShadow: createTailwinConfg("shadows"), spacing: createTailwinConfg("spacing"), zIndex: createTailwinConfg("zIndex"),
screens: breakpointsTokens, }, }, plugins: [], } satisfies Config;
|
关键配置解析:
darkMode: 配置为 ["selector", "[data-theme-mode='dark']"],与 CSS 中的 @variant dark 以及 ThemeProvider 的行为完全一致。content: 必须包含所有可能使用 Tailwind 类名的文件路径,否则 Tailwind 无法生成对应的 CSS。theme & theme.extend: 这是核心。我们 没有 在这里写死任何具体值(如 #00b96b 或 16px),而是全部通过 createTailwinConfg 和 creatColorChannel 动态生成了指向我们 Vanilla Extract CSS 变量的引用 (var(...) 或 rgb(var(...)))。- 工具函数的作用:
createTailwinConfg: 用于生成简单的 var(--variable-name) 引用,适用于 spacing, borderRadius, fontFamily 等。creatColorChannel: 专门用于颜色,生成 rgb(var(--variable-nameChannel)) 引用,使得 Tailwind 的颜色透明度修饰符(如 bg-primary/50)能够正确工作。
- Shadcn/UI 映射: 直接将 Tailwind 的
background, foreground 等 key 映射到我们在 index.css 中定义的 CSS 变量别名 (var(--background)),提供了良好的兼容性。
6.4.3. 第三步:最终验证
配置完成后,最重要的一步是验证 Tailwind 是否真正地消费了我们的主题系统,并且能够响应主题切换。
第一步:重启开发服务器
确保 vite.config.ts, tailwind.config.ts, src/index.css 的修改都被加载。
第二步:在 MyApp.tsx 中使用映射后的 Tailwind 类
文件路径: src/MyApp.tsx (修改)
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
| import { App as AntdApp, Button, Divider, Space, Typography } from "antd";
import { useSettingActions } from "@/store/settingStore"; import { ThemeMode, ThemeColorPresets } from "@/types/enum";
function MyApp() { const { setSettings } = useSettingActions();
const toggleThemeMode = () => { setSettings({ themeMode: document.documentElement.getAttribute(HtmlDataAttribute.ThemeMode) === ThemeMode.Light ? ThemeMode.Dark : ThemeMode.Light, }); };
const changeColorPreset = (color: ThemeColorPresets) => { setSettings({ themeColorPresets: color }); };
return ( <div className="flex flex-col items-center justify-center min-h-screen bg-bg-default p-8 transition-colors duration-300"> {/* 卡片:使用 bg-bg-paper, rounded-lg (来自 VE borderRadius.lg), shadow-card (来自 VE shadows.card) */} <div className="w-full max-w-lg p-8 space-y-6 bg-bg-paper rounded-lg shadow-card"> {/* 标题:使用 text-primary (来自 VE colors.palette.primary.default) */} <h1 className="text-2xl font-bold text-center text-primary"> Prorise-Admin </h1>
{/* 使用 Antd Typography (样式应与原生 h1 一致) */} <div className="text-center"> <Typography.Title level={2} className="text-text-primary">Antd Title (h2)</Typography.Title> {/* 使用 text-text-secondary */} <Typography.Text className="text-text-secondary">Antd Text</Typography.Text> </div>
{/* 使用 Antd Button (其 primary 颜色应与 text-primary 一致) */} <div className="flex justify-center"> <Button type="primary">Antd Button</Button> </div>
<Divider className="border-border" /> {/* 使用 border-border (来自 shadcn 映射) */}
{/* 测试主题切换按钮 */} <Space wrap className="flex justify-center"> <Button onClick={toggleThemeMode}>Toggle Light/Dark</Button> <Button onClick={() => changeColorPreset(ThemeColorPresets.Blue)}>Set Blue Preset</Button> <Button onClick={() => changeColorPreset(ThemeColorPresets.Default)}>Set Default Preset</Button> </Space>
{/* 测试间距:使用 p-4 (来自 VE spacing.4) */} <div className="p-4 bg-gray-100 dark:bg-gray-800 rounded-md"> {/* 测试字体:使用 font-openSans (来自 VE typography.fontFamily.openSans) */} <p className="font-openSans text-text-primary">This uses Open Sans font.</p> {/* 测试响应式:只在 lg 断点及以上显示 (来自 VE screens.lg) */} <p className="hidden lg:block text-text-secondary">Only visible on large screens.</p> </div> </div> </div> ); }
export default MyApp;
|
验证要点:
- 颜色一致性:
h1 的颜色 (text-primary)、Antd Button 的主色、以及 border-border 的颜色,是否都正确地反映了当前的主题色(默认是绿色)? - 间距/圆角/字体/断点:
p-4, rounded-lg, font-openSans, lg:block 这些基于 Tokens 映射的 Tailwind 类是否按预期工作? - 亮/暗模式切换:点击 “Toggle Light/Dark” 按钮,整个页面的背景 (
bg-bg-default)、卡片背景 (bg-bg-paper)、文字颜色 (text-text-primary/secondary) 是否流畅地切换?Tailwind 的 dark: 前缀是否生效(例如 dark:bg-gray-800)? - 主题色切换:点击 “Set Blue Preset” 按钮,
h1 (text-primary) 和 Antd Button 的主色是否都变成了蓝色?点击 “Set Default Preset” 是否能切换回绿色?
如果以上所有验证都通过,那么恭喜你!我们已经成功地让 Tailwind CSS 完全融入了我们的主题系统,实现了 Ant Design 与 Tailwind 视觉风格的完美统一和动态响应。
阶段性成果:我们成功配置了 Tailwind CSS v4,使其能够 消费 由 Vanilla Extract 生成的 CSS 变量。通过在 CSS 中定义 darkMode 策略,并在 tailwind.config.ts 中利用工具函数 映射 Design Tokens 结构(值为 var(...) 引用),我们确保了 Tailwind 原子类与 Ant Design 组件在视觉上保持 完全一致,并且都能 动态响应 亮/暗模式和主题色的切换。混合样式架构的关键协同机制已经打通。
6.4. 配置 Tailwind 消费 Tokens:实现原子化样式的统一
我们已经成功地为 Ant Design 这座“城市”修建了接入主题高速公路的“匝道”(AntdAdapter)。现在,轮到我们庞大的“乡村公路网络”——Tailwind CSS 了。
我们的目标是让每一个原子化类(如 bg-primary, p-4)都精确地行驶在我们的主题“高速公路”上,确保所有自定义布局和组件的视觉表现,都与我们的 Design Tokens 保持绝对一致。
本章的核心目标:我们将对 Tailwind CSS 进行一次精密的“配置连接”,将其默认的“引擎”和“燃料”全部替换掉。我们将配置它,使其 不再使用任何内置的默认值,而是 完全依赖并消费 我们通过 Vanilla Extract 生成的全局 CSS 变量。
6.4.1. 核心策略:让 Tailwind 成为 CSS 变量的“消费者”
在深入配置之前,我们必须明确我们的核心指导思想。策略非常简单,但威力巨大:
我们将修改 tailwind.config.ts,让 theme 对象中的每一个值(颜色、间距、圆角等),都变成一个指向我们 CSS 变量的引用(例如 var(--colors-palette-primary-default-value))。
这样一来,Tailwind JIT 编译器在扫描到 text-primary 这样的类时,生成的 CSS 不再是 color: #some-hardcoded-color;,而是 color: rgb(var(--colors-palette-primary-default-channel));。
其结果是:当 ThemeProvider 通过修改 <html> 属性来切换 CSS 变量的值时,所有由 Tailwind 生成的样式都会 自动地、响应式地 更新,因为它们引用的“源头”已经改变了。这就是我们打通 Tailwind 与主题系统协同工作的关键所在。
6.4.2. 第一步:配置 Tailwind 的暗黑模式“开关”
我们要做的第一件事,是告诉 Tailwind 如何识别“现在是暗黑模式”。我们的 ThemeProvider 会在 <html> 元素上添加 data-theme-mode="dark" 属性。我们需要让 Tailwind 的 dark: 变体能够识别这个“信号”。
在 Tailwind v4 中,推荐在 CSS 文件中定义这个策略。
文件路径: src/index.css (修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| @import "tailwindcss";
@import "tw-animate-css";
@config "../tailwind.config.ts";
@variant dark (&:is([data-theme-mode="dark"] *));
|
思路落地:通过这两行简单的配置,我们已经完成了最关键的一步连接。Tailwind 的 dark: 变体现在已经和我们的 ThemeProvider 状态同步,为后续的动态样式切换奠定了基础。
6.4.3. 第二步:构建“Token-to-Tailwind”的自动化映射工具
接下来,我们需要填充 tailwind.config.ts。如果我们手动去写 'primary': 'var(--...)' 这样的映射,会非常繁琐且容易出错。因此,我们需要创建一套自动化工具函数,来读取我们的 Tokens 结构,并生成对应的 Tailwind 配置。
1. 工具 A: getThemeTokenVariants - 获取 Token 变体 (健壮版)
第一个工具需要能够读取我们定义的 themeTokens 结构,并返回指定路径下的所有“叶子节点”的 key。
关键:这个函数必须足够“聪明”,既能识别像 spacing 这样的 null 叶子,也能识别像 colors.palette.primary 这样的 { value: null, channel: null } 叶子。
文件路径: 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 38 39 40 41 42 43
| import { themeTokens } from '@/theme/type';
export const getThemeTokenVariants = (propertyPath: string): string[] => { const keys = propertyPath.split("."); let current: Record<string, unknown> = themeTokens; for (const key of keys) { if (current && typeof current === "object" && key in current) { current = current[key] as Record<string, unknown>; } else { console.warn(`[getThemeTokenVariants] Path not found: ${propertyPath}`); return []; } }
if (current && typeof current === "object") { const values = Object.values(current);
const isStandardLeaf = values.every((v) => v === null);
const isColorLeaf = values.every( (v) => v && typeof v === "object" && "value" in v && v.value === null, );
if (isStandardLeaf || isColorLeaf) { return Object.keys(current); } }
console.warn( `[getThemeTokenVariants] Path does not point to a leaf object: ${propertyPath}`, ); return []; };
|
意图解析:这个健壮的 getThemeTokenVariants 是后续所有映射的基础。通过同时检查两种叶子节点结构,它能正确地为 spacing 和 colors.palette.primary 返回它们的 keys,而不会像天真的实现那样在遇到颜色时返回 []。
2. 工具 B: toCssVar - 拼接 CSS 变量名
这个工具很简单,负责将一个点分隔的路径(如 colors.palette.primary.default)转换成一个标准的 CSS 变量名(--colors-palette-primary-default)。
文件路径: src/theme/utils/themeUtils.ts (添加新函数)
1 2 3 4 5 6
|
export const toCssVar = (propertyPath: string) => { return `--${propertyPath.split(".").join("-")}`; };
|
3. 工具 C: createTailwinConfg - 生成标准 var() 引用
这个工具将组合前两个工具,为间距、圆角等 非颜色 Token 生成 Tailwind 配置。
文件路径: src/theme/utils/themeUtils.ts (添加新函数)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
|
export const createTailwinConfg = (propertyPath: string) => { const variants = getThemeTokenVariants(propertyPath); return variants.reduce( (acc, variant) => { acc[variant] = `var(${toCssVar(`${propertyPath}.${variant}`)})`; return acc; }, {} as Record<string, string>, ); };
|
4. 工具 D: createColorChannel - 为颜色生成特殊引用 (修正版)
颜色比较特殊。为了让 Tailwind 的 透明度修饰符(如 bg-primary/50)能正常工作,我们必须引用 Channel 变量,并用 rgb() 包裹。
关键:我们必须正确拼接由 Vanilla Extract 生成的 -channel 后缀。
文件路径: 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
|
export const createColorChannel = (propertyPath: string) => { const variants = getThemeTokenVariants(propertyPath);
return variants.reduce( (acc, variant) => { const variantKey = variant === "default" ? "DEFAULT" : variant;
const cssVar = toCssVar(`${propertyPath}.${variant}`); acc[variantKey] = `rgb(var(${cssVar}-channel))`; return acc; }, {} as Record<string, string>, ); };
|
思路落地:我们已经打造了一套完整的、职责清晰的、且 逻辑正确 的自动化工具。现在,填充 tailwind.config.ts 将会变得极其简单和清晰。
6.4.4. 第三步:分部组装 tailwind.config.ts
现在,我们利用刚刚创建的工具箱,一步步地构建我们的 tailwind.config.ts 文件。
1. 基础配置:content
首先,我们创建文件并配置最基本的部分。
文件路径: tailwind.config.ts (新建)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| import type { Config } from "tailwindcss"; export default { content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: { extend: {}, }, plugins: [], } satisfies Config;
|
2. 填充 theme.extend.colors
接下来,使用 createColorChannel 工具来映射我们所有的颜色 Token。
文件路径: tailwind.config.ts (修改 theme 部分)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| import type { Config } from "tailwindcss"; import { createColorChannel } from "./src/theme/utils/themeUtils"; export default { theme: { extend: { colors: { primary: createColorChannel("colors.palette.primary"), success: createColorChannel("colors.palette.success"), warning: createColorChannel("colors.palette.warning"), error: createColorChannel("colors.palette.error"), info: createColorChannel("colors.palette.info"), gray: createColorChannel("colors.palette.gray"), text: createColorChannel("colors.text"), bg: createColorChannel("colors.background"), action: createColorChannel("colors.action"), }, }, }, plugins: [], } satisfies Config;
|
意图解析:现在,当你在代码中使用 bg-primary 或 bg-primary/50,Tailwind 生成的 CSS 将是 background-color: rgb(var(--colors-palette-primary-default-channel) / 0.5),完全由我们的主题系统驱动。
3. 填充其他 Tokens
最后,我们使用 createTailwinConfg 和直接导入的方式,完成剩余所有 Token 的映射。
文件路径: tailwind.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 37 38 39 40 41
| import type { Config } from "tailwindcss"; import { breakpointsTokens } from "./src/theme/tokens/breakpoints";
import { createColorChannel, createTailwinConfg, } from "./src/theme/utils/themeUtils";
export default { content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: { fontFamily: createTailwinConfg("typography.fontFamily"),
extend: { colors: { primary: createColorChannel("colors.palette.primary"), success: createColorChannel("colors.palette.success"), warning: createColorChannel("colors.palette.warning"), error: createColorChannel("colors.palette.error"), info: createColorChannel("colors.palette.info"), gray: createColorChannel("colors.palette.gray"), text: createColorChannel("colors.text"), bg: createColorChannel("colors.background"), action: createColorChannel("colors.action"), }, opacity: createTailwinConfg("opacity"), borderRadius: createTailwinConfg("borderRadius"), boxShadow: createTailwinConfg("shadows"), spacing: createTailwinConfg("spacing"), zIndex: createTailwinConfg("zIndex"),
screens: breakpointsTokens, }, }, plugins: [], } satisfies Config;
|
思路落地:我们的 tailwind.config.ts 已经组装完毕。它现在是一个纯粹的“声明文件”,声明了 Tailwind 主题与我们 Design Tokens CSS 变量之间的映射关系,没有任何硬编码的样式值,并且 逻辑正确。
6.4.5. 第四步:最终验证
理论必须通过实践来检验。我们将通过修改 MyApp.tsx 来验证我们的配置是否完美生效。
文件路径: src/MyApp.tsx (修改)

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 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215
| import { Button as AntButton, Card as AntCard, Space } from "antd"; import { useSettingActions, useSettings } from "@/store/settingStore"; import { ThemeColorPresets, ThemeMode } from "@/theme/types/enum";
function MyApp() { const settings = useSettings(); const { setSettings } = useSettingActions();
const toggleThemeMode = () => { setSettings({ themeMode: settings.themeMode === ThemeMode.Light ? ThemeMode.Dark : ThemeMode.Light, }); };
const changeColorPreset = (preset: ThemeColorPresets) => { setSettings({ themeColorPresets: preset }); };
return ( <div className="min-h-screen bg-bg-default p-8"> <div className="max-w-7xl mx-auto space-y-8"> {/* 标题 */} <h1 className="text-4xl font-bold text-text-primary text-center mb-12"> 主题系统测试页面 </h1>
{/* 控制面板 */} <div className="flex flex-col items-center gap-6 p-6 bg-bg-paper rounded-lg shadow-card"> <h2 className="text-2xl font-semibold text-text-primary">主题控制</h2>
{/* 亮暗模式切换 */} <div className="flex items-center gap-4"> <span className="text-text-secondary">当前模式:</span> <button type="button" onClick={toggleThemeMode} className="px-6 py-2 bg-primary text-white rounded-lg hover:opacity-90 transition-opacity shadow-primary" > {settings.themeMode === ThemeMode.Light ? "🌞 切换到暗色" : "🌙 切换到亮色"} </button> </div>
{/* 主题预设切换 */} <div className="flex flex-col items-center gap-3"> <span className="text-text-secondary">选择主题色:</span> <div className="flex flex-wrap gap-3 justify-center"> {Object.values(ThemeColorPresets).map((preset) => ( <button key={preset} type="button" onClick={() => changeColorPreset(preset)} className={`px-4 py-2 rounded-lg transition-all ${ settings.themeColorPresets === preset ? "bg-primary text-white shadow-lg scale-105" : "bg-bg-neutral text-text-primary hover:bg-action-hover" }`} > {preset.charAt(0).toUpperCase() + preset.slice(1)} </button> ))} </div> </div> </div>
{/* 卡片对比 */} <div className="grid grid-cols-1 md:grid-cols-2 gap-8"> {/* Tailwind CSS 卡片 */} <div className="space-y-4"> <h3 className="text-xl font-semibold text-text-primary text-center"> Tailwind CSS 卡片 </h3> <div className="bg-bg-paper rounded-lg shadow-card p-6 space-y-4"> <h4 className="text-lg font-semibold text-primary"> 使用 Tailwind 构建 </h4> <p className="text-text-secondary"> 这个卡片使用了我们自定义的 Tailwind CSS 类名,包括颜色、间距、圆角和阴影。 </p>
{/* 颜色展示 */} <div className="space-y-2"> <div className="flex items-center gap-2"> <div className="w-8 h-8 bg-primary rounded"></div> <span className="text-text-primary text-sm">Primary</span> </div> <div className="flex items-center gap-2"> <div className="w-8 h-8 bg-success rounded"></div> <span className="text-text-primary text-sm">Success</span> </div> <div className="flex items-center gap-2"> <div className="w-8 h-8 bg-warning rounded"></div> <span className="text-text-primary text-sm">Warning</span> </div> <div className="flex items-center gap-2"> <div className="w-8 h-8 bg-error rounded"></div> <span className="text-text-primary text-sm">Error</span> </div> <div className="flex items-center gap-2"> <div className="w-8 h-8 bg-info rounded"></div> <span className="text-text-primary text-sm">Info</span> </div> </div>
{/* 按钮示例 */} <div className="flex gap-3 flex-wrap"> <button type="button" className="px-4 py-2 bg-primary text-white rounded hover:opacity-90" > Primary </button> <button type="button" className="px-4 py-2 bg-success text-white rounded hover:opacity-90" > Success </button> <button type="button" className="px-4 py-2 bg-warning text-white rounded hover:opacity-90" > Warning </button> <button type="button" className="px-4 py-2 bg-error text-white rounded hover:opacity-90" > Error </button> </div> </div> </div>
{/* Ant Design 卡片 */} <div className="space-y-4"> <h3 className="text-xl font-semibold text-text-primary text-center"> Ant Design 卡片 </h3> <AntCard title="使用 Ant Design 构建" bordered={false} style={{ boxShadow: "var(--shadows-card)" }} > <p style={{ marginBottom: 16 }}> 这个卡片使用了 Ant Design 组件,通过 AntdAdapter 自动应用了我们的主题令牌。 </p>
<Space direction="vertical" style={{ width: "100%" }}> <AntButton type="primary" block> Primary Button </AntButton> <AntButton type="default" block> Default Button </AntButton> <AntButton type="dashed" block> Dashed Button </AntButton> <AntButton type="text" block> Text Button </AntButton> <AntButton type="link" block> Link Button </AntButton> </Space>
<Space style={{ marginTop: 16 }} wrap> <AntButton type="primary">Primary</AntButton> <AntButton type="primary" danger> Danger </AntButton> <AntButton disabled>Disabled</AntButton> </Space> </AntCard> </div> </div>
{/* 状态显示 */} <div className="bg-bg-paper p-6 rounded-lg shadow-card"> <h3 className="text-xl font-semibold text-text-primary mb-4"> 当前主题配置 </h3> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-text-secondary"> <div> <span className="font-medium">主题模式:</span>{" "} {settings.themeMode} </div> <div> <span className="font-medium">主题预设:</span>{" "} {settings.themeColorPresets} </div> <div> <span className="font-medium">字体:</span> {settings.fontFamily} </div> <div> <span className="font-medium">基础字号:</span> {settings.fontSize} px </div> </div> </div> </div> </div> ); }
export default MyApp;
|
启动你的应用,并逐一核对以下清单:
- 初始渲染:页面背景、卡片背景、标题颜色、字体是否都正确应用了我们 Tokens 中定义的默认(亮色)主题?
bg-bg-default 是否生效? - 切换暗黑模式:点击按钮,背景、卡片、文字颜色是否都平滑地过渡到了暗黑模式的对应值?
dark:bg-gray-800 是否生效? - 切换主题色:点击 “Set Blue Preset” 按钮,
text-primary 和 Ant Design 的主按钮颜色是否 同时、同步 地改变?点击 “Set Default Preset” 是否能切换回绿色? - 检查开发者工具:审查
text-primary 的元素,它的 color 样式是否为 rgb(var(--colors-palette-primary-default-channel))?(注意:default 和 channel 之间有破折号)。
如果所有问题的答案都是“是”,那么,我们已经取得了里程碑式的成功。
阶段性成果:我们通过 构建健壮的自动化工具 和 精简的配置文件,成功地将 Tailwind CSS 的主题系统与我们的 Design Tokens CSS 变量进行了深度绑定。现在,Tailwind 不再是一个独立的样式孤岛,而是我们统一主题生态系统中的一个强大、可动态响应的“消费者”。Ant Design 和 Tailwind 真正实现了视觉上的“同源”和行为上的“同步”。
6.5. 本章小结
在本章中,我们完成了从“静态蓝图”到“动态系统”的终极飞跃。我们为 Prorise-Admin 注入了“灵魂”——一个由状态驱动、响应式的、统一的主题引擎。
回顾本章,我们取得了四大关键成就,构建了一个完整的主题驱动闭环:
“大脑”:Zustand 状态管理 (6.1)
- 我们引入了轻量级的
zustand,并创建了 settingStore 作为所有主题设置的“唯一事实来源”。 - 我们遵循了“渐进式构建”的最佳实践,从一个最小化的 Store 演进到支持
Partial 更新的、按 settings 和 actions 命名空间隔离的优雅结构。 - 最重要的是,我们集成了
persist 中间件,并使用 partialize 进行了关键优化,实现了状态的本地持久化。 - 我们 统一了类型定义,将
StorageEnum 添加到全局唯一的 src/theme/types/enum.ts 文件中,确保了类型安全且无冲突。
“中枢神经”:ThemeProvider (6.2)
- 我们构建了
ThemeProvider 组件,它充当了“状态”与“DOM”之间的中枢神经。 - 它订阅
useSettings Hook,并使用 useEffect 将 Zustand 中的状态(themeMode, themeColorPresets 等)实时同步 到 <html> 根元素的 data-* 属性和 style 属性上。 - 这一步 激活 了我们在第五章中创建的所有 CSS 变量,使亮/暗模式和主题色切换成为了现实。
“专属翻译官”:Ant Design 适配器 (6.3)
- 我们遇到了一个核心架构问题:Ant Design 的
ConfigProvider “听不懂” CSS 变量。 - 我们 摒弃了“紧耦合”的错误设计,而是采用了优雅的 适配器模式 (Adapter Pattern)。
- 我们创建了
AntdAdapter,它作为一个独立的“翻译官”,订阅 useSettings 状态,并将其“翻译”为 Antd ConfigProvider 所需的 theme 对象。 - 我们升级了
ThemeProvider 使其支持 adapters 数组,实现了 对扩展开放、对修改关闭 的架构原则。
“统一网络”:Tailwind CSS 消费 (6.4)
- 这是打通混合架构的“最后一公里”。我们通过 健壮的自动化工具,将 Tailwind CSS 彻底转变为我们主题系统的“纯消费者”。
- 我们 创建了
getThemeTokenVariants 工具,使其能智能识别 null 和 { value, channel } 两种契约结构。 - 我们 创建了
createColorChannel 工具,使其能正确拼接 -channel 后缀,以引用正确的 CSS 变量。 - 我们 精简了配置,在
index.css 中使用 @variant dark 定义暗黑模式,移除了 tailwind.config.ts 中的冗余 darkMode 键,实现了纯粹的 CSS-First 配置。
最终成果:我们拥有了一个由 Zustand 单一状态源驱动的、可动态切换、可持久化的主题系统。这个系统能够同时、同步地驱动我们的 原生 CSS 变量、Ant Design 组件 和 Tailwind CSS 原子类,实现了三位一体的完美视觉统一。
6.6. 代码入库:动态主题系统闭环
我们已经完成了 Prorise-Admin 核心基础设置中最复杂、最关键的一章。我们构建了一个完整的、从状态管理到 UI 适配的动态主题系统。是时候将这个巨大的里程碑存入我们的 Git 历史了。
第一步:检查代码状态
使用 git status 查看我们海量的变更。这可能是我们迄今为止最大的一次提交。
你会看到一系列的新增和修改:
- 依赖:
package.json / pnpm-lock.yaml (新增 zustand)。 - 状态:
src/store/settingStore.ts (新目录和文件)。 - 类型:
src/theme/types/enum.ts (修改,新增 StorageEnum)。 - Provider:
src/theme/theme-provider.tsx (新增)。 - 适配器:
src/theme/adapter/antd.adapter.tsx (新目录和文件),src/theme/type.ts (修改,新增 UILibraryAdapter 类型)。 - 工具链:
src/utils/theme.ts (修改,新增 removePx 和所有 Tailwind 映射工具)。 - Tailwind:
src/index.css (修改,新增 @config 和 @variant),tailwind.config.ts (新增/修改,配置了所有映射)。 - 入口:
src/main.tsx (修改,使用了 ThemeProvider 和 AntdAdapter)。 - 测试:
src/MyApp.tsx (修改,用于验证主题切换)。
第二步:暂存所有变更
将所有新文件和修改添加到暂存区。
第三步:执行提交
我们编写一条符合“约定式提交”规范的 Commit Message。feat 是最合适的类型,theme 和 state 都是合适的 scope。
1
| git commit -m "feat(theme): implement dynamic theme with zustand, provider, and adapters"
|
这条消息准确地概括了我们的工作:使用 Zustand 实现了动态主题,并通过 Provider 和 Adapters 将其应用。
一个统一的系统: 我们的这次提交,标志着项目从“样式集成”阶段演进到了“样式驱动”阶段。Zustand 成为了驱动一切的“心脏”,而 ThemeProvider、AntdAdapter 和 tailwind.config.ts 构成了连接到所有 UI 末端的“动脉”。