设计 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 的粒度以"需要跨主题差异化定制"为判断标准。
更多推荐


所有评论(0)