设计 Token 管理踩坑集:命名冲突、值漂移与跨平台同步的解决之道
设计 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-padding 和 list-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 之间插入一条分叉路径。
五、总结
- Token 管理的五个核心问题是命名冲突、值漂移、跨平台不同步、语义丢失、版本断裂
- 三层 Token 架构(Primitive → Semantic → Component)是生产验证的最佳实践
- Token 定义文件必须作为"单一权威源"存储在 Git 中,Figma 为下游消费者
- CI 自动生成各平台代码是解决跨平台同步的唯一可规模化方案
- Token 引用解析器 + 引用计数 + 变更审计日志是 Token 管理的基础设施
- 移动端的 Token 更新存在数天到数周的延迟窗口,Breaking Change 必须提供宽限期
- 超过三层的 Token 层级会导致过度抽象,增加团队认知负担