设计系统的 Token 治理:从单源真相到多平台交付的自动化管线 设计系统的 Token 治理从单源真相到多平台交付的自动化管线一、Token 治理的规模化困境——当一个颜色改三天成为常态在一个跨 5 个产品线、3 个平台Web / iOS / Android的组织中品牌升级的代价令人窒息。设计师在 Figma 中修改了主色但这个变更需要同步到Web 的 CSS 自定义属性、iOS 的 Swift Asset Catalog、Android 的 XML 资源文件、React Native 的 JS 常量、邮件模板的内联样式。某次品牌色从#3B82F6调整为#2563EB团队花了 4 天时间逐个平台排查遗漏上线后仍发现 3 处未更新的硬编码值。问题的根源是设计 Token 存在于多个孤岛中——Figma 变量、CSS 文件、Swift 代码、Android XML——它们之间没有自动化的同步机制。当 Token 在一处修改后其他平台的更新完全依赖人工传递。设计系统的 Token 治理核心就是建立单源真相Single Source of Truth和多平台自动交付的管线。二、Token 治理的架构——从设计源到多平台产物的编译管线flowchart LR A[设计源: Figma Variables] -- B[Token 提取器] B -- C[Token JSON — 单源真相] C -- D[平台编译器] D -- D1[CSS 自定义属性] D -- D2[iOS Swift Asset] D -- D3[Android XML] D -- D4[React Native JS] D -- D5[Tailwind 配置] C -- E[Token 校验器] E -- E1[命名规范检查] E -- E2[对比度校验] E -- E3[别名循环检测] C -- F[变更检测器] F --|Token 变更| G[CI 自动发布] style C fill:#fff3e0,stroke:#ef6c00 style D fill:#e8f5e9,stroke:#2e7d322.1 单源真相——Token JSON 的结构设计Token JSON 是整个管线的核心它定义了所有设计决策的规范化表示。一个成熟的 Token 结构应包含以下层级原始值Primitive最基本的色值、尺寸、字重如{ color.blue.500: #3B82F6 }语义值Semantic带业务含义的别名如{ color.primary: {color.blue.500} }组件值Component组件级 Token如{ button.primary.background: {color.primary} }三级结构的价值在于原始值变更只影响语义层的映射语义值变更只影响组件层的引用。品牌升级只需修改原始值组件级 Token 自动跟随。2.2 Token 别名与引用解析Token 的别名机制如{color.primary}引用{color.blue.500}是单源真相的关键实现。但别名引入了循环引用的风险——{a}引用{b}{b}又引用{a}。编译管线必须在解析阶段检测并报告循环引用。2.3 多平台编译——同一份 Token不同产物每个平台有不同的 Token 消费方式CSS 使用var(--color-primary)iOS 使用UIColor.tokenColorPrimaryAndroid 使用color/token_color_primary。编译器将同一份 Token JSON 转换为各平台的惯用格式。三、生产级 Token 治理系统——代码实现3.1 Token 定义与校验/** * 设计 Token 定义与校验系统 * 支持 Token 别名、引用解析和循环检测 */ // Token 值类型 type TokenValue string | number | { value: string; type: string; description?: string }; // Token 集合类型 interface TokenSet { [tokenName: string]: TokenValue | TokenSet; } // 完整的设计 Token 定义 interface DesignTokenFile { // 元信息 $meta: { version: string; source: string; // 来源标识如 figma:variables:abc123 lastModified: string; }; // 原始值 primitive: TokenSet; // 语义值 semantic: TokenSet; // 组件值 component: TokenSet; } /** * Token 校验器 * 在编译前执行完整性检查 */ class TokenValidator { private errors: ValidationError[] []; private warnings: ValidationWarning[] []; /** * 校验 Token 文件 */ validate(tokenFile: DesignTokenFile): ValidationResult { this.errors []; this.warnings []; // 检查 1命名规范——必须使用 kebab-case this.checkNamingConvention(tokenFile); // 检查 2别名循环引用检测 this.checkCircularReferences(tokenFile); // 检查 3色彩对比度校验 this.checkColorContrast(tokenFile); // 检查 4间距阶梯连续性 this.checkSpacingScale(tokenFile); return { valid: this.errors.length 0, errors: this.errors, warnings: this.warnings, }; } /** * 检查命名规范 * Token 名称必须使用 kebab-case禁止 camelCase 和 snake_case */ private checkNamingConvention(tokenFile: DesignTokenFile): void { const checkObject (obj: TokenSet, path: string) { for (const key of Object.keys(obj)) { const fullPath path ? ${path}.${key} : key; // 跳过 $meta 等特殊键 if (key.startsWith($)) continue; // 检查是否为 kebab-case if (/[A-Z]/.test(key) || key.includes(_)) { this.errors.push({ code: naming-convention, message: Token 名称 ${fullPath} 不符合 kebab-case 规范, suggestion: 建议改为 ${fullPath.replace(/([A-Z])/g, -$1).toLowerCase().replace(/_/g, -)}, }); } // 递归检查嵌套对象 const value obj[key]; if (typeof value object value ! null !(value in value)) { checkObject(value as TokenSet, fullPath); } } }; checkObject(tokenFile.primitive, primitive); checkObject(tokenFile.semantic, semantic); checkObject(tokenFile.component, component); } /** * 检查循环引用 * 使用 DFS 检测 Token 别名链中的环 */ private checkCircularReferences(tokenFile: DesignTokenFile): void { // 扁平化所有 Token const flatTokens this.flattenTokens(tokenFile); const visited new Setstring(); const stack new Setstring(); const dfs (tokenName: string): void { if (stack.has(tokenName)) { this.errors.push({ code: circular-reference, message: 检测到循环引用: ${[...stack, tokenName].join( → )}, suggestion: 检查 Token 别名链移除循环依赖, }); return; } if (visited.has(tokenName)) return; visited.add(tokenName); stack.add(tokenName); const value flatTokens[tokenName]; if (typeof value string) { // 提取所有 {token.name} 引用 const references value.match(/\{([^}])\}/g) || []; for (const ref of references) { const refName ref.slice(1, -1); if (flatTokens[refName] ! undefined) { dfs(refName); } else { this.errors.push({ code: undefined-reference, message: Token ${tokenName} 引用了不存在的 Token ${refName}, suggestion: 检查引用路径确认 ${refName} 是否已定义, }); } } } stack.delete(tokenName); }; for (const tokenName of Object.keys(flatTokens)) { dfs(tokenName); } } /** * 检查色彩对比度 * 确保语义色 Token 的前景/背景组合满足 WCAG AA */ private checkColorContrast(tokenFile: DesignTokenFile): void { const flatTokens this.flattenTokens(tokenFile); // 检查常见的语义色组合 const pairs [ [semantic.color.text-primary, semantic.color.bg-primary], [semantic.color.text-secondary, semantic.color.bg-primary], [semantic.color.text-on-primary, semantic.color.color-primary], ]; for (const [fg, bg] of pairs) { const fgValue this.resolveTokenValue(flatTokens, fg); const bgValue this.resolveTokenValue(flatTokens, bg); if (fgValue bgValue) { const ratio this.calculateContrastRatio(fgValue, bgValue); if (ratio 4.5) { this.warnings.push({ code: contrast-ratio, message: ${fg} 与 ${bg} 的对比度为 ${ratio.toFixed(2)}:1不满足 WCAG AA (4.5:1), suggestion: 调整前景色亮度或更换色彩 Token, }); } } } } /** * 检查间距阶梯连续性 * 确保间距 Token 形成合理的递增序列 */ private checkSpacingScale(tokenFile: DesignTokenFile): void { const flatTokens this.flattenTokens(tokenFile); const spacingTokens: { name: string; value: number }[] []; for (const [name, rawValue] of Object.entries(flatTokens)) { if (name.includes(spacing) || name.includes(space)) { const value this.resolveTokenValue(flatTokens, name); if (value) { const px parseFloat(value); if (!isNaN(px)) { spacingTokens.push({ name, value: px }); } } } } // 按值排序 spacingTokens.sort((a, b) a.value - b.value); // 检查相邻间距的比率是否合理应在 1.2-2.0 之间 for (let i 1; i spacingTokens.length; i) { const ratio spacingTokens[i].value / spacingTokens[i - 1].value; if (ratio 1.2) { this.warnings.push({ code: spacing-scale, message: ${spacingTokens[i].name} (${spacingTokens[i].value}px) 与 ${spacingTokens[i - 1].name} (${spacingTokens[i - 1].value}px) 差异过小 (比率: ${ratio.toFixed(2)}), suggestion: 合并过近的间距阶梯或增大步进, }); } if (ratio 2.5) { this.warnings.push({ code: spacing-scale, message: ${spacingTokens[i].name} (${spacingTokens[i].value}px) 与 ${spacingTokens[i - 1].name} (${spacingTokens[i - 1].value}px) 差异过大 (比率: ${ratio.toFixed(2)}), suggestion: 考虑在中间插入过渡间距, }); } } } /** * 扁平化 Token 结构 * 将嵌套的 { primitive: { color: { blue: { 500: #3B82F6 } } } } * 转换为 { primitive.color.blue.500: #3B82F6 } */ private flattenTokens(tokenFile: DesignTokenFile): Recordstring, string { const result: Recordstring, string {}; const flatten (obj: TokenSet, prefix: string) { for (const [key, value] of Object.entries(obj)) { const path prefix ? ${prefix}.${key} : key; if (typeof value object value ! null !(value in value)) { flatten(value as TokenSet, path); } else if (typeof value string) { result[path] value; } else if (typeof value object value in value) { result[path] (value as { value: string }).value; } else if (typeof value number) { result[path] String(value); } } }; flatten(tokenFile.primitive, primitive); flatten(tokenFile.semantic, semantic); flatten(tokenFile.component, component); return result; } /** * 解析 Token 引用链获取最终值 */ private resolveTokenValue(flatTokens: Recordstring, string, name: string): string | null { const visited new Setstring(); let current name; while (current) { if (visited.has(current)) return null; // 循环引用 visited.add(current); const value flatTokens[current]; if (!value) return null; // 检查是否是别名引用 const aliasMatch value.match(/^\{([^}])\}$/); if (aliasMatch) { current aliasMatch[1]; } else { return value; } } return null; } private calculateContrastRatio(fg: string, bg: string): number { // 简化实现生产环境应使用完整 WCAG 算法 return 4.5; // 占位 } } interface ValidationError { code: string; message: string; suggestion: string; } interface ValidationWarning { code: string; message: string; suggestion: string; } interface ValidationResult { valid: boolean; errors: ValidationError[]; warnings: ValidationWarning[]; }3.2 多平台编译器/** * Token 多平台编译器 * 将 Token JSON 编译为各平台的产物文件 */ class TokenCompiler { private flatTokens: Recordstring, string; constructor(private tokenFile: DesignTokenFile) { // 预处理扁平化并解析所有别名 this.flatTokens this.resolveAllAliases(tokenFile); } /** * 编译为 CSS 自定义属性 */ compileToCSS(): string { const lines: string[] [ /* , * 设计 Token — 自动生成请勿手动修改, * 生成时间: ${new Date().toISOString()}, * 来源: ${this.tokenFile.$meta.source}, * */, :root {, ]; for (const [name, value] of Object.entries(this.flatTokens)) { // 转换命名primitive.color.blue.500 → --color-blue-500 const cssName -- name .replace(/^(primitive|semantic|component)\./, ) .replace(/\./g, -); lines.push( ${cssName}: ${value};); } lines.push(}); return lines.join(\n); } /** * 编译为 iOS Swift Asset Catalog */ compileToSwift(): string { const lines: string[] [ // 设计 Token — 自动生成请勿手动修改, // 生成时间: ${new Date().toISOString()}, , import UIKit, , extension DesignTokens {, ]; for (const [name, value] of Object.entries(this.flatTokens)) { // 转换命名semantic.color.primary → tokenColorPrimary const swiftName token name .replace(/^(primitive|semantic|component)\./, ) .split(.) .map(part part.charAt(0).toUpperCase() part.slice(1)) .join(); // 根据值类型生成不同的 Swift 代码 if (value.startsWith(#) || value.startsWith(rgb) || value.startsWith(hsl)) { lines.push( static let ${swiftName} UIColor(hex: ${value})); } else if (value.endsWith(px)) { const cgFloat parseFloat(value); lines.push( static let ${swiftName}: CGFloat ${cgFloat}); } else if (value.endsWith(ms)) { const interval parseFloat(value) / 1000; lines.push( static let ${swiftName}: TimeInterval ${interval}); } else { lines.push( static let ${swiftName} ${value}); } } lines.push(}); return lines.join(\n); } /** * 编译为 Android XML 资源 */ compileToAndroidXML(): string { const lines: string[] [ ?xml version1.0 encodingutf-8?, !-- 设计 Token — 自动生成请勿手动修改 --, !-- 生成时间: ${new Date().toISOString()} --, resources, ]; for (const [name, value] of Object.entries(this.flatTokens)) { // 转换命名semantic.color.primary → token_color_primary const xmlName token_ name .replace(/^(primitive|semantic|component)\./, ) .replace(/\./g, _) .replace(/-/g, _) .toLowerCase(); if (value.startsWith(#)) { lines.push( color name${xmlName}${value}/color); } else if (value.endsWith(px)) { const dp parseFloat(value); lines.push( dimen name${xmlName}${dp}dp/dimen); } else if (value.endsWith(ms)) { const ms parseInt(value); lines.push( integer name${xmlName}${ms}/integer); } } lines.push(/resources); return lines.join(\n); } /** * 解析所有别名引用 * 将 {color.blue.500} 替换为实际值 */ private resolveAllAliases(tokenFile: DesignTokenFile): Recordstring, string { const raw this.flattenTokensRaw(tokenFile); const resolved: Recordstring, string {}; for (const [name, value] of Object.entries(raw)) { resolved[name] this.resolveAlias(raw, name, new Set()); } return resolved; } private resolveAlias( flat: Recordstring, string, name: string, visited: Setstring ): string { if (visited.has(name)) { throw new Error(循环引用: ${[...visited, name].join( → )}); } visited.add(name); const value flat[name]; if (!value) return value; // 替换所有 {alias} 引用 return value.replace(/\{([^}])\}/g, (_, refName) { return this.resolveAlias(flat, refName, new Set(visited)); }); } private flattenTokensRaw(tokenFile: DesignTokenFile): Recordstring, string { const result: Recordstring, string {}; const flatten (obj: TokenSet, prefix: string) { for (const [key, value] of Object.entries(obj)) { const path prefix ? ${prefix}.${key} : key; if (typeof value object value ! null !(value in value)) { flatten(value as TokenSet, path); } else if (typeof value string) { result[path] value; } else if (typeof value object value in value) { result[path] (value as { value: string }).value; } } }; flatten(tokenFile.primitive, primitive); flatten(tokenFile.semantic, semantic); flatten(tokenFile.component, component); return result; } }3.3 CI 集成——Token 变更自动发布# .github/workflows/token-publish.yml # Token 变更时自动编译并发布到各平台包 name: Token Publish on: push: paths: - design-tokens/**/*.json jobs: validate-and-publish: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - name: 安装依赖 run: npm ci - name: 校验 Token run: npx token-validator design-tokens/source.json - name: 编译 CSS run: npx token-compiler --formatcss --outputpackages/css/tokens.css - name: 编译 iOS run: npx token-compiler --formatswift --outputpackages/ios/DesignTokens.swift - name: 编译 Android run: npx token-compiler --formatandroid --outputpackages/android/values/tokens.xml - name: 检测变更 id: changes run: | git diff --quiet packages/ || echo changedtrue $GITHUB_OUTPUT - name: 发布包 if: steps.changes.outputs.changed true run: | npm version patch -m chore: update design tokens npm publish四、Token 治理的架构权衡——标准化与灵活性的拉锯4.1 三级 Token 结构的复杂度原始值→语义值→组件值的三级结构提供了良好的抽象层次但也增加了认知成本。新成员需要理解三级之间的映射关系以及应该在哪一级定义新 Token。如果团队规模较小 5 人可以简化为两级原始值 语义值省略组件级 Token。4.2 别名解析的性能深度嵌套的别名链A→B→C→D→...→最终值在解析时需要递归遍历。在 Token 数量超过 500 个的大型项目中全量解析可能耗时 100-200ms。对于 CI 场景这不是问题但对于需要运行时动态切换主题的 Web 应用需要将解析结果缓存为扁平映射。4.3 多平台编译的格式差异不同平台对色彩值的格式要求不同CSS 支持hsl()和rgb()iOS 需要UIColor(hex:)Android 需要#AARRGGBB。编译器需要处理格式转换但某些高级 CSS 特性如color-mix()、相对色彩语法无法直接映射到其他平台。4.4 禁用场景以下场景不建议使用 Token 治理系统单平台、单产品的小型项目Token 管理的 ROI 为负高度定制化的营销页面每个页面都是独特的Token 复用率低遗留系统迁移初期先完成组件化再引入 Token 治理。五、总结设计系统的 Token 治理核心是建立单源真相和多平台自动交付的管线。Token JSON 作为单源真相通过三级结构原始值→语义值→组件值实现分层抽象。Token 校验器在编译前执行命名规范、循环引用、对比度和间距阶梯的完整性检查。多平台编译器将同一份 Token JSON 编译为 CSS、Swift、Android XML 等平台产物。CI 集成确保 Token 变更自动触发编译和发布。三级结构的复杂度需要根据团队规模权衡别名解析的性能需要通过缓存优化。Token 治理的价值与项目规模正相关——跨平台、多产品线的组织受益最大。