
设计 Token 的语义化版本控制如何让组件库平滑升级而不破坏页面一、改了 Token 值二十个页面变色——但没人知道产品说品牌主色要从 #1890FF 改成 #1677FF。听起来很简单改一个 CSS 变量。但两周后用户反馈侧边栏选中态的蓝色太亮了——那是--color-primary-hover通过 Sass 的darken()函数从主色派生而来。改了主色所有依赖主色派生的颜色都变了而这些派生值分布在三个 npm 包、二十个页面的深层组件中。问题本质设计 Token 没有语义化的版本号。在 npm 包的语义化版本semver里1.2.3 → 1.3.0告诉我们新增了功能对现有 API 向后兼容。Token 变更也需要同样的语义——「这是一个 PatchBug Fix不改变视觉还是一个 Minor新增 Token还是一个 Major改了现有 Token 的色值」二、Token 版本控制模型关键设计Token 的版本号不是随意打的数字而是对消费者的契约声明。Major bump 你的页面可能需要开发介入才能消费新版本Minor bump 新增 Token不影响现有 Token安全升级Patch bump 文档或元数据变更零风险升级三、实现方案// scripts/token-version-bump.ts // Token 版本自动判定工具 // 比较两个版本的 tokens.json自动建议正确的版本号 bump import semver from semver; import { execSync } from child_process; interface TokenNode { value?: string; type?: string; description?: string; [key: string]: any; } interface DiffResult { added: string[]; // 新增 Token 路径 removed: string[]; // 删除 Token 路径 modified: Array{ // 修改 Token值变化 path: string; oldValue: string; newValue: string; }; metadataChanged: string[]; // 仅元数据描述等变化 } /** * 比较两个 Token 文件的差异 * * param oldTokens - 旧版本的 Token 对象解析后的 JSON * param newTokens - 新版本的 Token 对象 */ function diffTokens(oldTokens: TokenNode, newTokens: TokenNode): DiffResult { const result: DiffResult { added: [], removed: [], modified: [], metadataChanged: [] }; // 深度遍历 Token 树收集所有叶子节点路径 const oldPaths flattenTokens(oldTokens); const newPaths flattenTokens(newTokens); // 检测新增 for (const [path, node] of Object.entries(newPaths)) { if (!(path in oldPaths)) { result.added.push(path); } } // 检测删除 for (const path of Object.keys(oldPaths)) { if (!(path in newPaths)) { result.removed.push(path); } } // 检测修改 for (const [path, newNode] of Object.entries(newPaths)) { const oldNode oldPaths[path]; if (!oldNode) continue; if (oldNode.value ! newNode.value) { result.modified.push({ path, oldValue: oldNode.value, newValue: newNode.value }); } else if (oldNode.description ! newNode.description) { // 值相同但描述变了 → 仅元数据变化 result.metadataChanged.push(path); } } return result; } /** * 将嵌套 Token 树平铺为 路径 → 节点 的 Map * * 示例输入: * { color: { primary: { value: #1677ff } } } * * 输出: * { color.primary: { value: #1677ff } } */ function flattenTokens( obj: TokenNode, prefix ): Recordstring, TokenNode { const result: Recordstring, TokenNode {}; for (const [key, val] of Object.entries(obj)) { const path prefix ? ${prefix}.${key} : key; if (val typeof val object val.value ! undefined) { // 叶子节点有 value 字段 result[path] val; } else if (val typeof val object) { // 分支节点递归 Object.assign(result, flattenTokens(val, path)); } } return result; } /** * 根据 Diff 结果判定 semver bump 类型 * * 规则 * - 有删除 或 有值修改 → MajorBreaking Change * - 有新增无删除无修改→ MinorFeature * - 仅有元数据变更 → Patch * - 完全相同 → 不 bump */ function determineBumpType(diff: DiffResult): major | minor | patch | null { if (diff.removed.length 0 || diff.modified.length 0) { return major; } if (diff.added.length 0) { return minor; } if (diff.metadataChanged.length 0) { return patch; } return null; } /** * 生成迁移指南Markdown 格式 * 列出所有变更帮助消费方评估升级影响 */ function generateMigrationGuide( diff: DiffResult, oldVersion: string, newVersion: string ): string { let guide # Token 迁移指南v${oldVersion} → v${newVersion}\n\n; if (diff.removed.length 0) { guide ## 删除的 Token (${diff.removed.length})\n\n; guide 以下 Token 已被移除请在代码中替换它们\n\n; for (const path of diff.removed) { guide - \${path}\\n; } guide \n; } if (diff.modified.length 0) { guide ## 值变更的 Token (${diff.modified.length})\n\n; guide | Token | 旧值 | 新值 |\n; guide |-------|------|------|\n; for (const mod of diff.modified) { guide | \${mod.path}\ | \${mod.oldValue}\ | \${mod.newValue}\ |\n; } guide \n; } if (diff.added.length 0) { guide ## 新增的 Token (${diff.added.length})\n\n; for (const path of diff.added) { guide - \${path}\\n; } guide \n; } return guide; } // ---- CI 集成自动 bump 版本并发布 ---- async function ciTokenVersionBump() { // 1. 获取上一个版本的 tokens.json从 git tag 检出 const lastTag execSync(git describe --tags --abbrev0).toString().trim(); execSync(git checkout ${lastTag} -- tokens.json); const oldTokensStr require(fs).readFileSync(tokens.json, utf-8); execSync(git checkout HEAD -- tokens.json); // 2. 读取当前版本的 tokens.json const newTokensStr require(fs).readFileSync(tokens.json, utf-8); // 3. 执行 diff const oldTokens JSON.parse(oldTokensStr); const newTokens JSON.parse(newTokensStr); const diff diffTokens(oldTokens, newTokens); // 4. 判定 bump 类型 const bumpType determineBumpType(diff); if (!bumpType) { console.log(Token 无变更跳过发布); return; } // 5. 读取当前版本号并计算新版本 const pkg require(../package.json); const newVersion semver.inc(pkg.version, bumpType); console.log(Token 变更: ${bumpType} bump (${pkg.version} → ${newVersion})); console.log(新增: ${diff.added.length}, 删除: ${diff.removed.length}, 修改: ${diff.modified.length}); // 6. 生成迁移指南 const guide generateMigrationGuide(diff, pkg.version, newVersion!); require(fs).writeFileSync(MIGRATION.md, guide); // 7. 如果是 Major bump在消费方项目中自动创建 Issue if (bumpType major) { // 扫描所有引用 design/tokens 的项目 // 在各自的仓库中创建 Issue附上迁移指南链接 // 实现略 } // 8. 更新版本号并发布 execSync(npm version ${newVersion}); execSync(npm publish); } // 本地执行 ciTokenVersionBump();四、消费方如何安全消费Token 版本Token 的版本控制要想真正起作用消费方业务项目需要遵守三条规则锁定主版本号。package.json中 Token 包的依赖写design/tokens: ^1.x而非^1.2.3或1.0.0。这样 Minor 和 Patch 自动更新Major 需要手动介入。CI 中检查 Token 兼容性。在业务项目的 CI 中加一个 Job安装最新 Token 包后运行tokens-audit检查是否有 Token 引用在新版本中变更了值。有则警告。不要用 Sass/JS 对 Token 做二次加工。darken($color-primary, 10%)这种操作绕过了 Token 版本控制——darken的 10% 可能在新主色下产出意料之外的色值。所有派生色应该都是 Token 体系内的独立 Token。五、总结设计 Token 的语义化版本控制本质上是基础设施层面的一致性保障。当你改了一个 Token 值semver 机制自动告诉你这是一个 Breaking Change你的 37 个消费方需要手动确认升级。三个关键环节Token Diff——自动对比两个版本的 Token 文件识别新增/删除/修改Bump 规则——值修改 MajorBreaking新增 MinorSafe元数据 Patch迁移指南——自动生成变更清单列出每个 Token 的新旧值对比不要让 Token 变更靠Readme 里更新了一行来通知团队。版本号就是 Token 的契约声明。