设计 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:语义丢失
删除了一个不再使用的 Tokencolor-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 UIKit\n\n'; 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\';\n\n'; 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 层级会导致过度抽象,增加团队认知负担