
AI 设计系统的组件血缘追踪从 Token 变更到影响面分析的自动化一、修改一个 primary 色值需要手动排查 47 个文件——漏掉的那一个导致了生产事故设计系统发布了一个 minor 版本将--color-primary从#3B82F6调整为#2563EB。发布后 3 小时运营后台的通过审核按钮变成了完全不可辨认的深蓝背景——因为那个按钮用了一个已废弃的 color helper 函数在--color-primary的基础上又套了一层darken(20%)。Token 变了darken 的起点变了最终颜色从可读的蓝白对比变成了深蓝#1E3A8A上写深蓝文字。这次的伤疤是Team 知道 Token 变了但要改什么——没人知道。47 个import ./tokens.css的文件、79 个组件文件、320 个 CSS 变量——谁引用了--color-primary谁在 JS 中通过了getComputedStyle读了这个值再传给 Canvas谁在 SVG inline 里直接写了fillvar(--color-primary)这些问题在改动 token 时没有自动化的答案。组件血缘追踪就是给每个 Token 建立被谁引用、被谁依赖、被谁转换的关系图谱Token 变更时自动计算爆炸半径。二、组件与 Token 之间的有向依赖图谱flowchart TD T1[--color-primary] -- |被引用| C1[Button 组件] T1 -- |被引用| C2[Badge 组件] T1 -- |被引用| C3[Link 组件] T1 -- |被 JS read| H1[useThemeColor(primary)] H1 -- |传递给| C4[CanvasChart 组件] H1 -- |传递给| C5[SvgIcon 组件] T2[--spacing-md] -- |被引用| C1 T2 -- |被引用| C6[Card 组件] T2 -- |被引用| C7[Modal 组件] C1 -- |嵌套使用| C8[SearchForm 页面] C1 -- |嵌套使用| C9[UserProfile 页面] style T1 fill:#3B82F6,stroke:#1D4ED8,color:#fff style T2 fill:#10B981,stroke:#047857,color:#fff style H1 fill:#fef3c7,stroke:#f59e0b两种依赖类型依赖类型关系示例变更影响Token→组件直接引用CSS 中var(--token)color: var(--color-primary)直接颜色值变了→组件的视觉表现变了组件→页面嵌套引用TSX 中Button /SearchForm用了Button间接Button 颜色变了→所有使用 Button 的页面受影响Token→JS helper派生引用JS 中darken(token, 0.2)hsl(var(--color-primary))再调色级联Token 变→JS 函数结果变→Canvas/SVG 渲染异常Token→TokenToken 间依赖Token A 基于 Token B 计算--color-primary-hover: hsl(from var(--color-primary) h s calc(l - 10%))传导A 变→基于 A 的 B 自动变三、组件血缘追踪的生产级实现/** * 组件血缘追踪引擎 * * 功能 * 1. 扫描所有源码文件构建 Token→文件的引用图谱 * 2. 给定一个 Token计算影响面直接引用嵌套引用派生引用 * 3. 生成影响力报告和推荐修改列表 */ import { readFileSync } from fs; import { glob } from glob; import postcss from postcss; // ─── 类型定义 ────────────────────────────────────────── /** 依赖关系类型 */ type DepType direct-css | indirect-js | nested-component | token-to-token; /** 一条依赖边 */ interface DependencyEdge { source: string; // 源Token 或组件路径 target: string; // 目标文件路径或 Token type: DepType; line: number; // 引用所在行号 snippet: string; // 引用代码片段 } /** 完整的依赖图谱 */ interface DependencyGraph { /** Token → 引用它的文件列表 */ tokenToFiles: Mapstring, Setstring; /** 文件 → 它引用的 Token 列表 */ fileToTokens: Mapstring, Setstring; /** Token → 引用它的组件列表含 JS 间接引用 */ tokenToComponents: Mapstring, Setstring; /** 组件 → 使用它的页面列表 */ componentToPages: Mapstring, Setstring; /** 所有的依赖边 */ edges: DependencyEdge[]; } /** 影响力报告 */ interface ImpactReport { token: string; oldValue: string; newValue: string; /** 直接影响CSS 中用了 var(--token) 的文件 */ directFiles: string[]; /** 间接影响使用了这些组件的页面 */ indirectFiles: string[]; /** 级联影响JS 中读取并转换这个 Token 的文件 */ cascadedFiles: string[]; /** 总受影响文件数 */ totalAffectedFiles: number; /** 高风险JS 中使用了颜色变换darken/lighten 等 */ highRiskFiles: string[]; /** 检查清单变更后应手动审查的内容 */ checklist: string[]; } // ─── 1. 构建依赖图谱 ───────────────────────────────── /** * 全量扫描项目源码构建 Token 依赖图谱 * * 扫描策略 * - CSS/SCSS 文件匹配 var(--xxx) 引用 * - TSX/JSX 文件匹配 var(--xxx) 和 JS helper 调用 * - Vue SFC匹配 style 段和 script 段 */ async function buildDependencyGraph(rootDir: string): PromiseDependencyGraph { const graph: DependencyGraph { tokenToFiles: new Map(), fileToTokens: new Map(), tokenToComponents: new Map(), componentToPages: new Map(), edges: [], }; // 扫描所有样式文件 const styleFiles await glob(${rootDir}/**/*.{css,scss,less}, { ignore: [**/node_modules/**, **/dist/**], }); for (const file of styleFiles) { const content readFileSync(file, utf-8); // 匹配 var(--xxx) 引用 const varRefs content.matchAll(/var\((--[\w-])\)/g); for (const match of varRefs) { const token match[1]; // --color-primary const line content.slice(0, match.index!).split(\n).length; const snippet match[0]; // 双向记录 addToMapSet(graph.tokenToFiles, token, file); addToMapSet(graph.fileToTokens, file, token); graph.edges.push({ source: token, target: file, type: direct-css, line, snippet, }); } // 匹配 color-mix / hsl(from xxx) 等 Token 派生引用 const derivedRefs content.matchAll( /(?:hsl|hwb|oklch|color-mix)\s*\(\s*from\svar\((--[\w-])\)/g, ); for (const match of derivedRefs) { const token match[1]; const line content.slice(0, match.index!).split(\n).length; const snippet match[0]; addToMapSet(graph.tokenToFiles, token, file); addToMapSet(graph.fileToTokens, file, token); graph.edges.push({ source: token, target: file, type: token-to-token, line, snippet, }); } } // 扫描 JS/TS 文件查找 JS 中读取 Token 的代码 const scriptFiles await glob(${rootDir}/**/*.{ts,tsx,js,jsx}, { ignore: [**/node_modules/**, **/dist/**, **/*.test.*, **/*.spec.*], }); for (const file of scriptFiles) { const content readFileSync(file, utf-8); // 匹配getComputedStyle(el).getPropertyValue(--xxx) const computedRefs content.matchAll( /getPropertyValue\([](--[\w-])[]\)/g, ); for (const match of computedRefs) { const token match[1]; const line content.slice(0, match.index!).split(\n).length; addToMapSet(graph.tokenToFiles, token, file); addToMapSet(graph.fileToTokens, file, token); graph.edges.push({ source: token, target: file, type: indirect-js, line, snippet: match[0], }); } // 匹配颜色处理函数darken/lighten/transparentize 等 // 例如darken(var(--color-primary), 0.2) const colorFnRefs content.matchAll( /(?:darken|lighten|saturate|desaturate|transparentize|opacify)\s*\(\s*(?:[]?var\((--[\w-])\)[]?)\s*,/g, ); for (const match of colorFnRefs) { const token match[1]; const line content.slice(0, match.index!).split(\n).length; addToMapSet(graph.tokenToFiles, token, file); addToMapSet(graph.fileToTokens, file, token); graph.edges.push({ source: token, target: file, type: indirect-js, line, snippet: match[0], }); } } // 组件→页面嵌套关系TSX 中 import JSX 使用 // 简化实现用 import 关系推断组件嵌套 const componentImports new Mapstring, Setstring(); // 文件 → 它导入的组件 for (const file of scriptFiles) { const content readFileSync(file, utf-8); // 匹配 import { xxx } from /components/... const imports content.matchAll( /import\s(?:\{[^}]*\}|\w)\sfrom\s[]([^]*\/components\/[^])[]/g, ); for (const match of imports) { addToMapSet(componentImports, file, match[1]); } } // 反推出组件 → 使用它的页面 for (const [page, components] of componentImports) { for (const comp of components) { addToMapSet(graph.componentToPages, comp, page); } } return graph; } function addToMapSet(map: Mapstring, Setstring, key: string, value: string) { if (!map.has(key)) map.set(key, new Set()); map.get(key)!.add(value); } // ─── 2. 影响力分析 ──────────────────────────────────── /** * 计算 Token 变更的影响面 * * param token 被变更的 Token 名称如 --color-primary * param oldValue 旧值 * param newValue 新值 * param graph 依赖图谱 */ function computeImpact( token: string, oldValue: string, newValue: string, graph: DependencyGraph, ): ImpactReport { // 1. 直接引用文件 const directFiles Array.from(graph.tokenToFiles.get(token) || []); // 2. 间接影响使用了直接引用文件的页面 const indirectFiles: string[] []; for (const file of directFiles) { const pages graph.componentToPages.get(file); if (pages) { for (const page of pages) { if (!indirectFiles.includes(page)) { indirectFiles.push(page); } } } } // 3. 级联影响JS 中读取并可能转换这个 Token 的文件高风险 const jsFiles directFiles.filter(f /\.(ts|tsx|js|jsx)$/.test(f)); const cascadedFiles new Setstring(); const highRiskFiles: string[] []; for (const file of jsFiles) { // 寻找这个文件中涉及 Token 的依赖边 const relevantEdges graph.edges.filter( e e.source token e.target file e.type indirect-js, ); if (relevantEdges.length 0) { cascadedFiles.add(file); // 高风险JS 中使用了颜色处理函数 for (const edge of relevantEdges) { if (/darken|lighten|saturate|transparentize/.test(edge.snippet)) { if (!highRiskFiles.includes(file)) { highRiskFiles.push(file); } } } } } // 4. 生成检查清单 const checklist: string[] [ 检查 ${directFiles.length} 个文件的 CSS 视觉表现, ]; if (highRiskFiles.length 0) { checklist.push( ⚠️ ${highRiskFiles.length} 个 JS 文件使用了颜色处理函数darken/lighten 等新值可能导致计算结果完全不可预期——必须逐个人审, ); } if (indirectFiles.length 0) { checklist.push( 检查 ${indirectFiles.length} 个页面的视觉回归这些页面使用了受影响的组件, ); } checklist.push(运行视觉回归测试对比 ${token} 变更前后的截图差异); return { token, oldValue, newValue, directFiles, indirectFiles, cascadedFiles: Array.from(cascadedFiles), totalAffectedFiles: new Set([...directFiles, ...indirectFiles, ...Array.from(cascadedFiles)]).size, highRiskFiles, checklist, }; } // ─── 3. 增量更新Token 变更后自动修补引用 ────────────── /** * 给受影响文件生成 sed 命令批量替换 Token 引用 * * 注意仅对 simple 的值替换安全如文案、基础色值。 * 涉及相对位置如间距的 Token 重命名不能简单替换——需要语义理解。 */ function generateFixCommands( impact: ImpactReport, tokenName: string, newTokenName?: string, // 如果是重命名 Token ): string[] { const commands: string[] []; if (newTokenName) { // Token 重命名所有引用旧 Token 的地方替换为新 Token 名 for (const file of impact.directFiles) { commands.push( sed -i s/var(${tokenName})/var(${newTokenName})/g ${file}, ); } } return commands; } // ─── CLI 使用示例 ───────────────────────────────────── async function main() { const graph await buildDependencyGraph(./src); // 场景分析 --color-primary 从 #3B82F6 → #2563EB 的影响面 const impact computeImpact( --color-primary, #3B82F6, #2563EB, graph, ); console.log( ╔══════════════════════════════════════════════════════╗ ║ Token 变更影响分析报告 ║ ╠══════════════════════════════════════════════════════╣ ║ Token: ${impact.token.padEnd(42)} ║ ║ 旧值: ${impact.oldValue.padEnd(42)} ║ ║ 新值: ${impact.newValue.padEnd(42)} ║ ╠══════════════════════════════════════════════════════╣ ║ 直接影响: ${String(impact.directFiles.length).padEnd(42)} ║ ║ 间接影响: ${String(impact.indirectFiles.length).padEnd(42)} ║ ║ 级联影响: ${String(impact.cascadedFiles.length).padEnd(42)} ║ ║ 总受影响: ${String(impact.totalAffectedFiles).padEnd(42)} ║ ╠══════════════════════════════════════════════════════╣ ║ 高风险: ${String(impact.highRiskFiles.length).padEnd(42)} ║ ╚══════════════════════════════════════════════════════╝ ); for (const item of impact.checklist) { console.log( ${item}); } } main();四、边界分析血缘追踪在什么样的场景会失准4.1 动态拼接的 Token 名扫描不到getPropertyValue(\--color-${variant}) 这种模板字符串拼接——静态分析无法知道你实际在读取哪个 Token。解决方案在 JS 运行时打点用 Proxy 包装 getPropertyValue在开发模式中记录实际调用将运行时数据回写到图谱中。但这样图谱就不是纯静态的了需要维护静态扫描结果 运行时补丁的混合模型。4.2 间接引用链的深度爆炸如果一个 Button 被 30 个页面引用每个页面进一步被 5 个 Layout 组件引用间接引用数量会指数爆炸到 150 个文件——而其中很多 Layout 其实只是包装器不影响 Button 最终的视觉表现。目前的简单算法会把 Layout 文件也标记为受影响导致影响力报告充斥噪音。需要引入视觉叶子组件概念只在组件树的实际可见渲染节点上计算影响。4.3 CSS-in-JS 的场景styled-components或emotion/styled中用css{css\color: var(--color-primary)} 的写法——CSS 不在独立的 .css 文件中而是内嵌在 JS 模板字符串里。PostCSS 扫描 .css 文件的路径检测不到它。需要额外的 babel 插件或 AST 遍历器专门扫描 styled-component 的模板字符串中出现的 var(--xxx)。4.4 Token 语义变更 vs 值变更并不是所有 Token 变更都一样。--color-primary从蓝色变成灰色——这是语义变更所有引用它的地方都需要设计决策主色改灰后这个按钮还要强调吗。而--color-primary从 #3B82F6 变成 #2563EB——这是值精调不需要设计层面的重新决策。静态工具只能报告引用了 X 个文件无法区分需要设计决策和可以自动替换。这个判断最终必须由人类设计师做——工具的作用是确保人类知道该找谁。适用边界组件血缘追踪在 Token 数量 100-500、有结构化文件组织非 monorepo 但别太碎片化的团队效果最好。不适合 Token 名动态拼接的项目、大量 CSS-in-JS 无 babel 插件的项目、以及 Token 稳定度低每周变更 10 个的项目报告太多变噪音。五、总结Token→组件→页面→JS 派生构成四层依赖图谱直接引用CSS var 间接引用组件嵌套 级联引用JS 处理 Token Token 间派生hsl from var|buildDependencyGraph()全量扫描 .css/.scss/.ts/.tsx 三批文件用正则 import 推断构建有向图 | CSS 文件用var(--xxx)正则提取 Token 引用JS 文件用getPropertyValue(--xxx)和darken(var(--xxx))提取级联引用 |computeImpact()以 Token 为起点在图谱中 BFS 遍历分 direct/indirect/cascaded 三类报告受影响文件 | 高风险文件判定JS 中使用了 darken/lighten/saturate 等颜色变换函数的文件——Token 变后该函数的输出可能完全出乎意料 | 动态拼接 Token 名--color-${variant}) 静态分析检测不到需 JS 运行时 Proxy 打点补丁 | 间接引用的指数爆炸问题需引入视觉叶子组件概念过滤纯粹包装器Layout/Provider| CSS-in-JSstyled-components 模板字符串内的 var(--xxx)需要额外 babel 插件扫描 | 语义变更蓝色→灰色vs 值精调#3B82F6→#2563EB工具无法区分——人类设计师负责最终决策工具负责告知该找谁。