欢迎光临
我们一直在努力

AI 设计系统的组件血缘追踪:从 Token 变更到影响面分析的自动化

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 里直接写了 fill="var(–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→Token(Token 间依赖) 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: Map<string, Set<string>>;
/** 文件 → 它引用的 Token 列表 */
fileToTokens: Map<string, Set<string>>;
/** Token → 引用它的组件列表(含 JS 间接引用) */
tokenToComponents: Map<string, Set<string>>;
/** 组件 → 使用它的页面列表 */
componentToPages: Map<string, Set<string>>;
/** 所有的依赖边 */
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): Promise<DependencyGraph> {
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\\s+var\\((–[\\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 Map<string, Set<string>>(); // 文件 → 它导入的组件

for (const file of scriptFiles) {
const content = readFileSync(file, 'utf-8');
// 匹配 import { xxx } from '@/components/…'
const imports = content.matchAll(
/import\\s+(?:\\{[^}]*\\}|\\w+)\\s+from\\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: Map<string, Set<string>>, 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 Set<string>();
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-JS(styled-components 模板字符串内的 var(–xxx))需要额外 babel 插件扫描 | 语义变更(蓝色→灰色)vs 值精调(#3B82F6→#2563EB)工具无法区分——人类设计师负责最终决策,工具负责告知"该找谁"。

赞(0)
未经允许不得转载:171主机测评 » AI 设计系统的组件血缘追踪:从 Token 变更到影响面分析的自动化
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址