设计 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 架构的设计逻辑

  1. 原始层(Raw Tokens):与设计工具直接对应的基础值。如 color-blue-500: #3B82F6spacing-4: 16px。这一层的值不随主题变化,是整个系统的"原子"。

  2. 语义层(Semantic Tokens):将原始值赋予业务语义。如 color-primary: {color-blue-500}color-text-default: {color-gray-900}。主题切换在这一层发生——暗色模式下 color-text-default 指向 color-gray-100,而原始层的 color-gray-900color-gray-100 都不变。

  3. 组件层(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 的粒度以"需要跨主题差异化定制"为判断标准。

Logo

一站式 AI 云服务平台

更多推荐