设计 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 1:Token 定义与校验
// 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 Set<string>(); const stack = new Set<string>(); for (const name of Object.keys(tokenSet)) { this.dfsCircular(name, tokenSet, visited, stack, []); } } private dfsCircular( name: string, tokenSet: TokenSet, visited: Set<string>, stack: Set<string>, 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 2:Token 编译器——别名解析与跨端格式输出
// token-compiler.ts // 将 Token 定义编译为各端可消费的格式 class TokenCompiler { /** * 解析别名引用:将 {token-name} 替换为实际值 * 支持多层嵌套引用:A -> B -> C -> #1a73e8 */ resolveAliases(tokenSet: TokenSet): TokenSet { const resolved: TokenSet = {}; const resolving = new Set<string>(); // 正在解析中的 Token,用于检测循环 for (const name of Object.keys(tokenSet)) { resolved[name] = this.resolveToken(name, tokenSet, resolving); } return resolved; } private resolveToken( name: string, tokenSet: TokenSet, resolving: Set<string> ): 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 的粒度以"需要跨主题差异化定制"为判断标准。
