设计 Token 多主题管理与跨端同步:从单一变量到系统化主题引擎

设计 Token 多主题管理与跨端同步:从单一变量到系统化主题引擎 设计 Token 多主题管理与跨端同步从单一变量到系统化主题引擎一、主题切换的工程困境CSS 变量不是万能药CSS 自定义属性Custom Properties为前端主题切换提供了原生支持——通过修改:root上的变量值所有引用该变量的元素自动更新。这种方案在单页面、单框架的场景下运作良好但当产品扩展到多端Web、React Native、Flutter和多品牌主品牌、子品牌、白标客户时CSS 变量方案的局限性暴露无遗。核心问题有三个1. 语义层缺失。CSS 变量--color-primary只是一个键值对它不携带类型信息颜色间距字号、不携带层级关系属于哪个主题是基础层还是语义层、不携带版本信息这个值从哪个版本开始变更的。当 Token 数量超过 200 个时纯 CSS 变量的管理变成无结构的平面列表。2. 跨端格式不兼容。CSS 变量的值是字符串但 React Native 使用 JavaScript 对象{ color: #1a73e8 }Flutter 使用 Dart 常量static const Color primary Color(0xFF1A73E8)。同一套 Token 定义需要在三种格式间转换手工维护三份文件的一致性成本极高。3. 主题组合爆炸。当存在 3 种品牌主题 x 2 种明暗模式 x 2 种密度模式时主题组合达到 12 种。如果每种组合都是一份完整的 CSS 变量覆盖文件维护成本随组合数线性增长。二、分层 Token 架构与主题组合策略flowchart TD A[原始层 Raw Tokens] -- B[语义层 Semantic Tokens] B -- C[组件层 Component Tokens] A1[color-blue-500: #3B82F6] -- B1[color-primary: {color-blue-500}] A2[color-gray-900: #111827] -- B2[color-text-default: {color-gray-900}] A3[spacing-4: 16px] -- B3[spacing-component-padding: {spacing-4}] B1 -- C1[button-primary-bg: {color-primary}] B2 -- C2[button-text-color: {color-text-default}] B3 -- C3[button-padding: {spacing-component-padding}] D[品牌主题覆盖] --|覆盖语义层| B E[暗色模式覆盖] --|覆盖语义层| B F[密度模式覆盖] --|覆盖组件层| C style A fill:#fdd,stroke:#333,stroke-width:1px style B fill:#dfd,stroke:#333,stroke-width:1px style C fill:#ddf,stroke:#333,stroke-width:1px三层 Token 架构的设计逻辑原始层Raw Tokens与设计工具直接对应的基础值。如color-blue-500: #3B82F6、spacing-4: 16px。这一层的值不随主题变化是整个系统的原子。语义层Semantic Tokens将原始值赋予业务语义。如color-primary: {color-blue-500}、color-text-default: {color-gray-900}。主题切换在这一层发生——暗色模式下color-text-default指向color-gray-100而原始层的color-gray-900和color-gray-100都不变。组件层Component Tokens将语义 Token 绑定到具体组件属性。如button-primary-bg: {color-primary}、button-padding: {spacing-component-padding}。密度模式切换在这一层发生——紧凑模式下button-padding指向spacing-2而非spacing-4。主题组合策略通过分层架构主题组合不再是笛卡尔积。品牌主题覆盖语义层明暗模式覆盖语义层密度模式覆盖组件层。三层独立变化组合数为品牌数 明暗模式数 密度模式数而非三者的乘积。三、工程实现Token 编译器与跨端同步Step 1Token 定义与校验// token-schema.ts // 设计 Token 的类型定义与校验规则 interface BaseToken { $type: color | dimension | fontFamily | fontWeight | duration | cubicBezier; $value: string | number; $description?: string; } interface AliasToken extends BaseToken { // 别名 Token值引用其他 Token如 {color-blue-500} $value: string; // 格式: {token-name} } interface ColorToken extends BaseToken { $type: color; $value: string; // hex, rgb, hsl } interface DimensionToken extends BaseToken { $type: dimension; $value: string; // 带单位的值如 16px, 1.5rem } type DesignToken ColorToken | DimensionToken | AliasToken; interface TokenSet { [tokenName: string]: DesignToken; } /** * Token 校验器确保定义符合规范 * 校验在编译前执行防止无效 Token 进入生成流程 */ class TokenValidator { private errors: string[] []; validate(tokenSet: TokenSet): { valid: boolean; errors: string[] } { this.errors []; for (const [name, token] of Object.entries(tokenSet)) { this.validateName(name); this.validateValue(name, token); } // 检测循环引用A - B - A 会导致编译死循环 this.detectCircularRefs(tokenSet); return { valid: this.errors.length 0, errors: this.errors }; } private validateName(name: string): void { // Token 名称必须使用 kebab-case且包含分类前缀 if (!/^[a-z][a-z0-9]*(-[a-z0-9])*$/.test(name)) { this.errors.push(Token ${name} 名称格式错误必须使用 kebab-case); } } private validateValue(name: string, token: DesignToken): void { if (this.isAlias(token.$value)) { // 别名引用校验确保引用的 Token 存在 const refName this.extractAlias(token.$value); // 引用存在性校验在 detectCircularRefs 中一并处理 return; } switch (token.$type) { case color: if (!/^#([0-9a-fA-F]{3,8})$/.test(token.$value as string) !/^rgb/.test(token.$value as string) !/^hsl/.test(token.$value as string)) { this.errors.push(Token ${name} 的色值格式无效: ${token.$value}); } break; case dimension: if (!/^-?\d(\.\d)?(px|rem|em|%|vh|vw)$/.test(token.$value as string)) { this.errors.push(Token ${name} 的尺寸格式无效: ${token.$value}); } break; } } private isAlias(value: string | number): boolean { return typeof value string /^\{[^}]\}$/.test(value); } private extractAlias(value: string): string { return value.replace(/^\{|\}$/g, ); } private detectCircularRefs(tokenSet: TokenSet): void { const visited new Setstring(); const stack new Setstring(); for (const name of Object.keys(tokenSet)) { this.dfsCircular(name, tokenSet, visited, stack, []); } } private dfsCircular( name: string, tokenSet: TokenSet, visited: Setstring, stack: Setstring, path: string[] ): void { if (stack.has(name)) { const cycle [...path, name].join( - ); this.errors.push(检测到循环引用: ${cycle}); return; } if (visited.has(name)) return; const token tokenSet[name]; if (!token) { this.errors.push(Token 引用了不存在的名称: ${name}); return; } stack.add(name); visited.add(name); if (this.isAlias(token.$value)) { const refName this.extractAlias(token.$value as string); this.dfsCircular(refName, tokenSet, visited, stack, [...path, name]); } stack.delete(name); } } export { TokenValidator, TokenSet, DesignToken };Step 2Token 编译器——别名解析与跨端格式输出// token-compiler.ts // 将 Token 定义编译为各端可消费的格式 class TokenCompiler { /** * 解析别名引用将 {token-name} 替换为实际值 * 支持多层嵌套引用A - B - C - #1a73e8 */ resolveAliases(tokenSet: TokenSet): TokenSet { const resolved: TokenSet {}; const resolving new Setstring(); // 正在解析中的 Token用于检测循环 for (const name of Object.keys(tokenSet)) { resolved[name] this.resolveToken(name, tokenSet, resolving); } return resolved; } private resolveToken( name: string, tokenSet: TokenSet, resolving: Setstring ): DesignToken { const token tokenSet[name]; if (!token) throw new Error(Token ${name} 不存在); if (typeof token.$value string /^\{[^}]\}$/.test(token.$value)) { const refName token.$value.replace(/^\{|\}$/g, ); if (resolving.has(refName)) { throw new Error(循环引用: ${[...resolving, refName].join( - )}); } resolving.add(name); const resolved this.resolveToken(refName, tokenSet, resolving); resolving.delete(name); // 返回解析后的 Token保留原始类型信息 return { ...token, $value: resolved.$value }; } return token; } /** * 编译为 CSS 自定义属性格式 * 输出: :root { --token-name: value; } */ toCSS(tokenSet: TokenSet, selector :root): string { const resolved this.resolveAliases(tokenSet); const lines: string[] [${selector} {]; for (const [name, token] of Object.entries(resolved)) { lines.push( --${name}: ${token.$value};); } lines.push(}); return lines.join(\n); } /** * 编译为 React Native JavaScript 对象格式 * RN 不支持 CSS 变量必须编译为 JS 常量 */ toReactNative(tokenSet: TokenSet): string { const resolved this.resolveAliases(tokenSet); const lines: string[] [// Auto-generated from design tokens, export const tokens {]; for (const [name, token] of Object.entries(resolved)) { const jsName name.replace(/-([a-z])/g, (_, c) c.toUpperCase()); let value token.$value; // 颜色值转换hex - RN 可接受格式 if (token.$type color typeof value string) { value ${value}; } // 尺寸值转换16px - 16 (RN 使用无单位数字) if (token.$type dimension typeof value string) { value value.replace(/px$/, ); } lines.push( ${jsName}: ${value},); } lines.push(};); return lines.join(\n); } /** * 编译为 Flutter Dart 常量格式 */ toFlutter(tokenSet: TokenSet, className DesignTokens): string { const resolved this.resolveAliases(tokenSet); const lines: string[] [ // Auto-generated from design tokens, class ${className} {, ${className}._();, ]; for (const [name, token] of Object.entries(resolved)) { const dartName name.replace(/-([a-z])/g, (_, c) c.toUpperCase()); if (token.$type color typeof token.$value string) { // hex - Flutter Color: #1A73E8 - Color(0xFF1A73E8) const hex (token.$value as string).replace(#, ); const alpha hex.length 8 ? hex.substring(0, 2) : FF; const rgb hex.length 8 ? hex.substring(2) : hex; lines.push( static const Color ${dartName} Color(0x${alpha}${rgb});); } else if (token.$type dimension typeof token.$value string) { // 16px - 16.0 const numVal parseFloat(token.$value); lines.push( static const double ${dartName} ${numVal};); } } lines.push(}); return lines.join(\n); } /** * 编译为 JSON 格式设计工具中间格式 */ toJSON(tokenSet: TokenSet): string { const resolved this.resolveAliases(tokenSet); return JSON.stringify(resolved, null, 2); } } export { TokenCompiler };Step 3主题组合引擎// theme-composer.ts // 将多个主题层叠加组合为最终生效的 Token 集合 interface ThemeLayer { name: string; /** 该层覆盖的 Token 值 */ tokens: TokenSet; /** 层优先级数值越大优先级越高 */ priority: number; } class ThemeComposer { private baseTokens: TokenSet; private layers: ThemeLayer[] []; constructor(baseTokens: TokenSet) { this.baseTokens baseTokens; } /** * 添加主题覆盖层 * 后添加的层如果优先级更高会覆盖先添加的同名 Token */ addLayer(layer: ThemeLayer): void { this.layers.push(layer); // 按优先级排序低优先级在前 this.layers.sort((a, b) a.priority - b.priority); } /** * 合成最终 Token 集合 * 合成顺序基础层 - 低优先级覆盖 - 高优先级覆盖 * 高优先级层的同名 Token 覆盖低优先级层 */ compose(): TokenSet { let result { ...this.baseTokens }; for (const layer of this.layers) { for (const [name, token] of Object.entries(layer.tokens)) { // 覆盖基础层或低优先级层的同名 Token result[name] token; } } return result; } /** * 生成 CSS 主题切换代码 * 每个主题组合对应一个 CSS 类选择器 */ generateThemeCSS(): string { const cssBlocks: string[] []; // 生成基础主题 const compiler new TokenCompiler(); cssBlocks.push(compiler.toCSS(this.baseTokens, :root)); // 为每个覆盖层生成对应的 CSS 类 for (const layer of this.layers) { cssBlocks.push(compiler.toCSS(layer.tokens, :root[data-theme${layer.name}])); } return cssBlocks.join(\n\n); } } export { ThemeComposer, ThemeLayer };四、多主题架构的工程权衡1. 运行时切换 vs 构建时生成CSS 自定义属性方案支持运行时切换主题修改data-theme属性即可但代价是所有主题的 Token 值都必须包含在 CSS 包中。当主题组合超过 20 种时多品牌白标场景CSS 体积可能增加 50-100KB。构建时方案每个主题生成独立的 CSS 文件可以按需加载但切换主题时需要重新加载样式表产生闪烁。折中方案将当前主题和最可能切换的下一个主题内联其余主题按需异步加载。2. 跨端同步的延迟问题Token 编译器在构建时生成各端文件但各端的构建和发布节奏不同。Web 可能每天发布RN 可能每周发布Flutter 可能双周发布。这期间 Token 定义可能已经变更导致各端短暂不一致。应对策略在 Token 仓库中维护版本号各端构建时锁定 Token 版本。Token 变更通过语义化版本号major/minor/patch传达破坏性各端按自身节奏升级。3. 组件层 Token 的粒度权衡组件层 Token如button-primary-bg提供了最精细的主题控制但粒度过细会导致 Token 数量爆炸——一个包含 30 个组件的设计系统组件层 Token 可能超过 500 个。维护成本与灵活性之间的平衡点仅对需要跨主题差异化定制的组件属性定义组件层 Token其余直接引用语义层 Token。4. 暗色模式的自动生成理论上暗色模式可以通过算法从浅色主题自动生成——将颜色 Token 的亮度反转。但实际效果往往不理想因为品牌色的暗色变体需要人工调整饱和度和色相简单的亮度反转会导致颜色发灰。推荐策略算法生成初稿设计师逐个校准品牌色和语义色。五、总结设计 Token 的多主题管理核心在于建立原始层-语义层-组件层的三层架构将主题切换从全量覆盖转变为分层叠加。原始层提供不变的基础值语义层承载主题差异组件层承载密度和尺寸差异。通过分层主题组合数从笛卡尔积降为线性叠加维护成本大幅降低。落地路线建议首先建立 Token 定义规范和校验器确保所有 Token 都有正确的类型和格式然后实现 Token 编译器支持 CSS、React Native、Flutter 三端格式输出最后构建主题组合引擎通过优先级叠加机制支持品牌、明暗、密度三个维度的独立切换。跨端同步通过 Token 仓库版本锁定解决组件层 Token 的粒度以需要跨主题差异化定制为判断标准。