设计 Token 管理踩坑集:命名冲突、值漂移与跨平台同步的解决之道

AI4小时前发布 beixibaobao
3 0 0

设计 Token 管理踩坑集:命名冲突、值漂移与跨平台同步的解决之道

一、引子:当 color-primary 同时是蓝色和红色

项目进行到第六个月,iOS 端的设计师发来消息:"你们 Android 的按钮颜色不对,应该是 #1976D2。" Android 端的小伙伴截图回复:"我这边的 color-primary 确实是 #1976D2,但设计师给的设计稿上标注的是 #1565C0。"

问题出在 Token 名字上。iOS 端用了 color_primary(定义于 6 个月前),Android 端用了 colorPrimary(定义于 3 个月前,品牌升级后改了颜色值),Web 端用了 --color-primary(定义于上个月,颜色又改了一次)。同一个语义"主题色",三个平台三个值,还都认为自己是正确的。

设计 Token 的初衷是消除这种不一致。但 Token 本身也需要管理——它们会冲突、会漂移、会过时、会在跨平台同步时丢失语义。这篇文章记录的踩坑经历,比成功经验更有价值。

二、Token 管理的五个核心问题

问题 1:命名冲突

这不是一个 Token 叫了两个名字的问题,而是一个名字在不同上下文中被赋予了不同的含义。spacing-md 在卡片组件中是 16px,在列表项组件中是 12px——因为两个组件的设计师不是同一个人,他们各自定义了"中等间距"。

深层原因是 Token 分层的缺失。spacing-md 应该是一个原始 Token(Primitive),它的值必须是固定且唯一的。如果不同组件需要不同的间距,应该定义组件级 Token(Component Token),如 card-paddinglist-item-padding,它们可以引用同一个 Primitive Token,也可以引用不同的。

问题 2:值漂移

品牌升级时,市场团队决定主题色从 #1976D2 改为 #1565C0。设计师更新了 Figma 中的 Color Styles,但只通知了 iOS 团队。Android 和 Web 团队一个月后才从用户投诉中得知"按钮颜色不对"。

值漂移的根因是 Token 定义的权威源(Source of Truth)不唯一。Figma 是设计师的权威源,代码仓库是开发的权威源,两者没有自动同步机制。必须强制单一权威源——通常是代码仓库(如 GitHub 上的 JSON/YAML 文件),然后通过工具自动同步到 Figma 和各个代码端。

问题 3:跨平台不同步

iOS 的 UIColor、Android 的 Color Resource、Web 的 CSS 变量、Flutter 的 ThemeData——每个平台对颜色、字体、间距的定义方式完全不同。把一套 Token 同步到五个平台,本质上是一个代码生成问题。

直接手工维护五套文件是灾难。正确的方案是:单一 Token 定义文件(JSON/YAML)+ 多平台代码生成器。Token 定义更新后,CI 自动生成各平台的代码文件并提交 PR。

问题 4:语义丢失

删除了一个不再使用的 Token color-warning-light,但一个老旧页面的 CSS 中依然引用着它,颜色变成了浏览器的默认值(通常是黑色)。引用计数是 Token 管理中缺失的一环。

问题 5:版本断裂

Token 的 Breaking Change(如改变某个 Token 的数值类型:从 px 改为 rem)会导致所有引用它的组件渲染异常。传统方案是 Semantic Versioning(SemVer),但 Token 的消费者是五端应用,它们的发版节奏各不相同。

三、生产级代码:Token 管理中心

/**
 * 设计 Token 管理中心
 * 
 * 解决 Token 管理的三个核心问题:
 * 1. 命名冲突 → 三层 Token 架构(Primitive / Semantic / Component)
 * 2. 值漂移 → 单一权威源 + 变更审计日志
 * 3. 跨平台同步 → 定义文件 + 代码生成器
 */
// ===== Token 定义文件 (tokens.json) =====
// 这是唯一的权威源,存储在 Git 中
interface TokensDefinition {
  version: string;              // SemVer
  lastModified: string;         // ISO 8601
  primitives: PrimitiveTokens;
  semantic: SemanticTokens;
  components: ComponentTokens;
}
// 原始 Token:最底层的原子值
interface PrimitiveTokens {
  colors: Record<string, string>;       // #RRGGBB
  spacing: Record<string, number>;      // 基准 px 值
  typography: {
    fontFamilies: Record<string, string>;
    fontSizes: Record<string, number>;
    fontWeights: Record<string, number>;
    lineHeights: Record<string, number>;
  };
  radii: Record<string, number>;
  shadows: Record<string, string>;
  breakpoints: Record<string, number>;
}
// 语义 Token:具名别名,引用 Primitive
interface SemanticTokens {
  colors: Record<string, string>;       // 如 "primary": "{colors.blue.600}"
  spacing: Record<string, string>;      // 如 "container-padding": "{spacing.4}"
}
// 组件 Token:组件特定覆盖
interface ComponentTokens {
  [componentName: string]: {
    [property: string]: string;
  };
}
// ===== 实际 Token 文件内容示例 =====
const tokensExample: TokensDefinition = {
  version: '2.3.0',
  lastModified: '2026-07-27T00:00:00Z',
  primitives: {
    colors: {
      'blue-50': '#E3F2FD',  'blue-100': '#BBDEFB',
      'blue-500': '#2196F3', 'blue-600': '#1E88E5',
      'blue-700': '#1976D2', 'blue-800': '#1565C0',
      'red-500': '#F44336',  'green-500': '#4CAF50',
      'grey-50': '#FAFAFA',  'grey-100': '#F5F5F5',
      'grey-900': '#212121', 'white': '#FFFFFF',
    },
    spacing: {
      '0': 0, '1': 4, '2': 8, '3': 12,
      '4': 16, '5': 20, '6': 24, '8': 32,
      '10': 40, '12': 48, '16': 64,
    },
    typography: {
      fontFamilies: {
        'sans': 'Inter, -apple-system, sans-serif',
        'mono': 'JetBrains Mono, monospace',
      },
      fontSizes: {
        'xs': 12, 'sm': 14, 'base': 16,
        'lg': 18, 'xl': 20, '2xl': 24,
        '3xl': 30, '4xl': 36,
      },
      fontWeights: {
        'normal': 400, 'medium': 500,
        'semibold': 600, 'bold': 700,
      },
      lineHeights: {
        'tight': 1.25, 'normal': 1.5, 'relaxed': 1.75,
      },
    },
    radii: { 'none': 0, 'sm': 4, 'md': 8, 'lg': 12, 'xl': 16, 'full': 9999 },
    shadows: {
      'sm': '0 1px 2px rgba(0,0,0,0.05)',
      'md': '0 4px 6px rgba(0,0,0,0.07)',
      'lg': '0 10px 15px rgba(0,0,0,0.1)',
    },
    breakpoints: { 'sm': 640, 'md': 768, 'lg': 1024, 'xl': 1280 },
  },
  semantic: {
    colors: {
      'primary': '{colors.blue-600}',
      'primary-hover': '{colors.blue-700}',
      'primary-light': '{colors.blue-50}',
      'danger': '{colors.red-500}',
      'success': '{colors.green-500}',
      'text-primary': '{colors.grey-900}',
      'text-secondary': '{colors.grey-600}',
      'bg-primary': '{colors.white}',
      'bg-secondary': '{colors.grey-50}',
    },
    spacing: {
      'page-padding': '{spacing.4}',
      'section-gap': '{spacing.6}',
      'card-padding': '{spacing.4}',
    },
  },
  components: {
    'Button': {
      'padding-x': '{spacing.4}',
      'padding-y': '{spacing.2}',
      'border-radius': '{radii.md}',
      'font-size': '{typography.fontSizes.base}',
      'font-weight': '{typography.fontWeights.medium}',
    },
    'Card': {
      'padding': '{spacing.4}',
      'border-radius': '{radii.lg}',
      'shadow': '{shadows.sm}',
      'bg': '{semantic.colors.bg-primary}',
    },
  },
};
// ===== Token 解析器 =====
/**
 * 解析 Token 中的引用语法 {path.to.token}
 * 支持三层引用:{colors.blue-600} / {spacing.4} / {semantic.colors.primary}
 */
class TokenResolver {
  private tokens: TokensDefinition;
  constructor(tokens: TokensDefinition) {
    this.tokens = tokens;
  }
  /**
   * 解析所有 Token 为扁平化的实际值
   */
  resolveAll(): {
    cssVariables: Record<string, string>;
    platformTokens: {
      ios: string;      // Swift code
      android: string;  // XML resources
      web: string;      // CSS variables
      flutter: string;  // Dart ThemeData
    };
  } {
    // 先解析 Semantic Token
    const resolvedSemantic: Record<string, string> = {};
    for (const [key, value] of Object.entries(this.tokens.semantic.colors)) {
      resolvedSemantic[`color-${key}`] = this.resolveValue(value);
    }
    for (const [key, value] of Object.entries(this.tokens.semantic.spacing)) {
      resolvedSemantic[`spacing-${key}`] = this.resolveValue(value) + 'px';
    }
    // CSS 变量
    const cssVariables: Record<string, string> = {};
    for (const [key, value] of Object.entries(resolvedSemantic)) {
      cssVariables[`--${key}`] = value;
    }
    // 生成各平台代码
    return {
      cssVariables,
      platformTokens: this.generatePlatformTokens(resolvedSemantic),
    };
  }
  /**
   * 解析引用路径 {path.to.token}
   */
  private resolveValue(reference: string): string {
    // 匹配引用语法 {section.key.subkey}
    const refMatch = reference.match(/^{([^}]+)}$/);
    if (!refMatch) {
      // 非引用,直接返回
      return reference;
    }
    const path = refMatch[1].split('.');
    let current: any = this.tokens;
    for (const segment of path) {
      // 处理连字符键: colors.blue-600 → colors['blue-600']
      current = current[segment.replace(/-(d)/g, '-$1')] ?? current[segment];
      if (current === undefined) {
        console.warn(`Token 引用解析失败: ${reference}, 未找到: ${segment}`);
        return reference; // 返回原始引用作为 fallback
      }
    }
    return typeof current === 'object' ? JSON.stringify(current) : String(current);
  }
  /**
   * 生成各平台 Token 代码
   */
  private generatePlatformTokens(resolved: Record<string, string>) {
    // iOS: Swift UIColor extension
    const iosCode = this.generateIOSCode(resolved);
    // Android: XML color resources
    const androidCode = this.generateAndroidCode(resolved);
    // Web: CSS Custom Properties
    const webCode = this.generateCSSCode(resolved);
    // Flutter: Dart ThemeData
    const flutterCode = this.generateFlutterCode(resolved);
    return { ios: iosCode, android: androidCode, web: webCode, flutter: flutterCode };
  }
  private generateCSSCode(resolved: Record<string, string>): string {
    let css = ':root {n';
    for (const [key, value] of Object.entries(resolved)) {
      css += `  --${key}: ${value};n`;
    }
    css += '}n';
    return css;
  }
  private generateIOSCode(resolved: Record<string, string>): string {
    let swift = 'import UIKitnn';
    swift += 'extension UIColor {n';
    for (const [key, value] of Object.entries(resolved)) {
      if (value.startsWith('#')) {
        swift += `  static let ${this.toCamelCase(key)} = UIColor(hex: "${value}")n`;
      }
    }
    swift += '}n';
    return swift;
  }
  private generateAndroidCode(resolved: Record<string, string>): string {
    let xml = '<?xml version="1.0" encoding="utf-8"?>n<resources>n';
    for (const [key, value] of Object.entries(resolved)) {
      if (value.startsWith('#')) {
        xml += `  <color name="${key}">${value}</color>n`;
      } else if (value.endsWith('px')) {
        xml += `  <dimen name="${key}">${value}</dimen>n`;
      }
    }
    xml += '</resources>n';
    return xml;
  }
  private generateFlutterCode(resolved: Record<string, string>): string {
    let dart = 'import 'package:flutter/material.dart';nn';
    dart += 'class AppTokens {n';
    for (const [key, value] of Object.entries(resolved)) {
      dart += `  static const ${this.toCamelCase(key)} = ${JSON.stringify(value)};n`;
    }
    dart += '}n';
    return dart;
  }
  private toCamelCase(kebab: string): string {
    return kebab.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
  }
}
// ===== 变更审计日志 =====
interface TokenChange {
  timestamp: string;
  tokenPath: string;       // 'semantic.colors.primary'
  oldValue: string;
  newValue: string;
  author: string;
  reason: string;
  breakingChange: boolean;
}
class TokenAuditLog {
  private changes: TokenChange[] = [];
  recordChange(change: TokenChange): void {
    this.changes.push(change);
    // 生产环境中持久化到文件
  }
  /**
   * 检测 Breaking Change
   * - 类型改变:px → rem
   * - 值大幅度改变:颜色色差 > 30(ΔE)
   * - Token 被删除
   */
  isBreakingChange(change: TokenChange): boolean {
    // 数值类型变化
    if (change.oldValue.endsWith('px') && change.newValue.endsWith('rem')) {
      return true;
    }
    // Token 删除
    if (change.newValue === '__DELETED__') {
      return true;
    }
    return false;
  }
  /**
   * 查询某个 Token 的变更历史
   */
  getHistory(tokenPath: string): TokenChange[] {
    return this.changes.filter((c) => c.tokenPath === tokenPath);
  }
}
// 使用
const resolver = new TokenResolver(tokensExample);
const result = resolver.resolveAll();
console.log('CSS Variables:', result.cssVariables);

四、边界分析

自动同步的延迟问题:Token 定义更新 → CI 自动生成各平台代码 → 各端拉取新版本 → 用户更新 App。这个链路的端到端延迟在 Web 端是分钟级(CDN 更新),在移动端是数天到数周(App 审核)。这意味着 Token 的 Breaking Change 在移动端有很长的"危险窗口"——旧版本 App 仍然显示旧的颜色。

过度抽象的陷阱:定义过多层级的 Token(5 层、6 层)会导致"代码考古"——一个按钮的背景色要追溯 4 层引用才能找到实际值。三层是生产验证的最佳实践:Primitive → Semantic → Component。超过三层就会引入认知负担。

多品牌/多主题场景:如果有多个子品牌(主品牌 + 子品牌 A + 子品牌 B),Token 的复杂度会指数增长。建议引入主题层(Theme),在 Semantic Token 和 Component Token 之间插入一条分叉路径。

五、总结

  1. Token 管理的五个核心问题是命名冲突、值漂移、跨平台不同步、语义丢失、版本断裂
  2. 三层 Token 架构(Primitive → Semantic → Component)是生产验证的最佳实践
  3. Token 定义文件必须作为"单一权威源"存储在 Git 中,Figma 为下游消费者
  4. CI 自动生成各平台代码是解决跨平台同步的唯一可规模化方案
  5. Token 引用解析器 + 引用计数 + 变更审计日志是 Token 管理的基础设施
  6. 移动端的 Token 更新存在数天到数周的延迟窗口,Breaking Change 必须提供宽限期
  7. 超过三层的 Token 层级会导致过度抽象,增加团队认知负担
© 版权声明

相关文章