三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

AI 辅助 UI 生成与设计系统自动化实践:先收紧输入、状态与退出边界

AI 辅助 UI 生成与设计系统自动化实践:先收紧输入、状态与退出边界

AI 辅助 UI 生成与设计系统自动化实践:先收紧输入、状态与退出边界

1. 先看一眼生成结果:颜色是最容易失控的地方

把 Figma 节点直接交给模型生成 React 代码,最先出现偏差的往往不是 DOM,而是样式细节。同一个品牌蓝很容易被写成几个相近的十六进制值;单看差别不大,放进组件库后就会破坏 Token 的统一。

模型适合协助判断节点语义,不适合决定最终样式。生成代码在合并前应先经过 Token 校验,禁止把临时色值和内联样式直接带入主干。

# 抓取当前构建产物中未收敛的十六进制颜色数量 grep -E -o '#[a-fA-F0-9]{6}' src/components/generated/*.tsx | sort | uniq -c | sort -nr | head -n 10 # 校验 CSS 变量定义与 Token 映射覆盖率 npx stylelint "src/**/*.css" --custom-syntax stylelint-config-design-system

第一版不必追求从画板直接产出可上线代码。更实际的边界是:模型只参与节点语义和组件候选的判断,Token、样式和值的写入交给可复现的规则。这样即使模型输出有变化,结果也能被拦在管道里。

flowchart TD A[Figma REST API 节点导出] --> B[AST 结构降噪与属性清理] B --> C{JSON Schema 校验器} C -- 校验失败 --> D[中断解析并抛出具体 Node ID] C -- 校验通过 --> E[LLM 语义识别与组件映射] E --> F[Token 映射闸门 strict-token-matcher] F --> G{未定义色值/尺寸?} G -- 是 --> H[强行降级为最近邻标准 Token] G -- 否 --> I[输出符合规范的 React/TypeScript 代码]

2. 抓 Rest API 结构:在 AST 语法树入口挂上第一道 JSON Schema 闸门

Figma REST API 返回的文档树极其庞大,一个稍微复杂的弹窗画板导出的 JSON 文件就能突破 3MB。如果直接把这个原始 JSON 丢给大模型,不仅上下文 Token 浪费极度严重,而且包含了大量绝对坐标、导出渲染缓存以及历史修改标记等噪声数据。

我们必须在入口处使用 Node.js 编写预处理脚本,深度剪枝后只保留absoluteBoundingBoxfillsstrokeschildren的核心节点。同时,为防范 Figma 插件升级导致字段变更,必须加上 JSON Schema 校验。

import { z } from 'zod'; // 定义 Figma 原生节点的严格过滤 Schema export const FigmaNodeSchema = z.object({ id: z.string(), name: z.string(), type: z.enum(['FRAME', 'TEXT', 'RECTANGLE', 'COMPONENT', 'INSTANCE']), absoluteBoundingBox: z.object({ x: z.number(), y: z.number(), width: z.number(), height: z.number(), }), fills: z.array(z.object({ type: z.string(), color: z.object({ r: z.number(), g: z.number(), b: z.number(), a: z.number(), }).optional(), })).optional(), children: z.array(z.lazy(() => FigmaNodeSchema)).optional(), }); export type FigmaNode = z.infer<typeof FigmaNodeSchema>; export function sanitizeFigmaTree(rawNode: any): FigmaNode { const result = FigmaNodeSchema.safeParse(rawNode); if (!result.success) { console.error('Figma JSON 结构非法, 错误路径:', result.error.format()); throw new Error(`Node ${rawNode?.id || 'unknown'} 匹配 Schema 失败`); } const node = result.data; if (node.children) { node.children = node.children.map(sanitizeFigmaTree); } return node; }

这一步的目的不是替模型“洗白”数据,而是把输入范围说清楚。进入 Prompt 的节点字段越少、结构越固定,后续的映射和排错就越容易复现。

3. Node.js 转换器实现:把模糊语义压进确定性的 Token 词汇表

大模型输出代码后,绝对不能直接保存为.tsx文件。我们必须通过 Babel / TypeScript AST 解析器重新遍历代码,提取出所有硬编码的 style 属性与 class 名称,并在内存中与标准 Design Token 进行距离计算。

对于颜色,采用 CIEDE2000 色差算法计算数值接近度,强行覆盖非标准色值;对于 Padding 和 Margin,强制按 4px 网格进行向下或向上对齐。

import { parse } from '@babel/parser'; import traverse from '@babel/traverse'; import generate from '@babel/generator'; import * as t from '@babel/types'; const DESIGN_TOKENS = { colors: { 'var(--color-primary-500)': '#3b82f6', 'var(--color-neutral-100)': '#f3f4f6', 'var(--color-neutral-900)': '#111827', }, spacing: [0, 4, 8, 12, 16, 24, 32, 48, 64], }; export function enforceDesignTokens(sourceCode: string): string { const ast = parse(sourceCode, { sourceType: 'module', plugins: ['jsx', 'typescript'], }); traverse(ast, { JSXAttribute(path) { // 检查并替换 style 内联属性 if (path.node.name.name === 'style' && t.isJSXExpressionContainer(path.node.value)) { const expression = path.node.value.expression; if (t.isObjectExpression(expression)) { expression.properties.forEach((prop) => { if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) { // 处理 color 和 backgroundColor if (['color', 'backgroundColor'].includes(prop.key.name) && t.isStringLiteral(prop.value)) { const matchedToken = findClosestColorToken(prop.value.value); prop.value = t.stringLiteral(matchedToken); } } }); } } }, }); return generate(ast).code; } function findClosestColorToken(hexValue: string): string { // 此处简化为精准匹配与兜底降级 for (const [token, hex] of Object.entries(DESIGN_TOKENS.colors)) { if (hex.toLowerCase() === hexValue.toLowerCase()) { return token; } } // 若模型幻觉产生色值,默认回退至 primary-500 并记录告警 console.warn(`检测到非标准色值 [${hexValue}],强制收敛至 var(--color-primary-500)`); return 'var(--color-primary-500)'; }

转换器把颜色和间距的决定权收回到 Token 表中。它不能保证生成结果完全正确,但能让非标准值在进入仓库前暴露出来,便于人工确认或回退。

4. 落地剪枝策略:第一版放弃复杂 flex 兜底,只收敛基础组件

许多团队在做 UI 自动生成的第一版时容易踩进“完美主义陷阱”——试图让 AI 一口气把最复杂的响应式表格、级联选择器和动态 Form 表单全都自动生成出来。结果是 Prompt 膨胀到上万字,模型的 Edge Case 越来越多,最终生成出的代码充满了庞大的 if-else 嵌套,维护成本比从零手写还高。

工程实践证明,第一版的剪枝策略必须够狠。我们明确界定了系统的能力边界:

能力边界划分表: | 维度 | 第一版纳入支持范围 | 第一版严格禁止支持 | | :--- | :--- | :--- | | **组件类型** | Card, Button, Avatar, Badge, Simple List | Complex Table, Dynamic Form, Tree Select | | **布局模式** | 固定宽度与 Flex 基础排列 | 复杂 Grid 网格、跨行跨列响应式布局 | | **状态映射** | Hover, Active, Disabled 基础伪类 | 异步 Data Fetching、复杂的 Redux/Zustand 状态流 | | **代码交付** | 展示型 JSX 模版 + CSS 变量 | 包含业务 validation 逻辑的交互代码 |

范围缩到展示型基础组件后,评估会简单很多:检查生成的 DOM 骨架、Token 引用和视觉差异即可。业务状态、校验和数据请求仍由工程师接手,避免把无法验证的逻辑混进生成结果。

5. 线上发布闸门:只要 Diff 覆盖率低于 92% 自动阻断 CI

为了验证生成的 UI 组件与原始 Figma 设计稿是否保持一致,我们在 Jenkins / GitHub Actions 流程里挂载了无头浏览器 Playwright 进行像素级 Visual Diff 检测。

CI 阶段自动启动 Storybook 渲染生成的代码,生成截图后再与 Figma Rest API 拿到的 Image Render 进行矩阵对比。

# 执行像素对比诊断脚本 npx playwright test tests/visual-diff.spec.ts --reporter=json > visual-diff-report.json # 检查 Diff 占比 node -e " const fs = require('fs'); const report = JSON.parse(fs.readFileSync('visual-diff-report.json')); const passRate = report.stats.expected / report.stats.total; console.log('UI 像素比对通过率:', (passRate * 100).toFixed(2) + '%'); if (passRate < 0.92) { console.error('CRITICAL: 像素一致性低于 92%,自动化构建已被阻断!'); process.exit(1); } "

视觉比对是发布前的一个信号,不应替代人工走查。把阈值、基准截图和豁免理由留在仓库里,团队才能判断差异是预期改动还是生成误差。第一版先把输入、Token 和视觉回归这三件事做稳,再逐步扩大支持范围。

← 返回列表