最新下载
热门教程
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
AI 编码提速却让组件失控?用工程闭环阻止重复造轮子
时间:2026-09-17 11:12:01 编辑:袖梨 来源:一聚教程网
当 AI 开始批量生成页面和组件,交付速度提升的同时,项目里也可能悄悄出现大量职责相同、命名不同的实现。仅靠提示词或代码评审很难持续拦住这类重复。要真正控制组件膨胀,需要把复用要求嵌入工程流程,让 AI 在动手前看见库存、编码时查询真实接口,并在提交前接受自动校验。
前言
Hello~大家好,我是秋天的一阵风
AI 现在已经是前端开发的标配。让它写个 Vue 组件、TSX 页面,几秒钟就能出代码,迭代快得肉眼可见。
但快是有代价的。AI 只负责"快速产出",不关心你项目里已经有什么——设计系统、组件规范、存量组件,它一概不知。
最常见的画面:你让它写个提交按钮,它现编一个;隔壁同事让它写"保存并提交",又现编一个。
两个组件长得几乎一样,只是文件名和入参不同。等代码进了 PR,评审才发现:又重复了。
一次两次还好,日积月累,业务目录里堆满同质化组件。等哪天要全局换主题色、适配无障碍、统一交互逻辑,就得人肉全网排查,漏改错改是家常便饭,维护成本直接失控。
一、AI为什么总爱"从零造轮子"
把 AI 想象成一个能力很强、但完全不了解你项目的临时工。
你让它写个基础提交按钮,它按网上最常见的写法随手写一个,根本不知道你们项目里早就有封装好的 SubmitAction,也不知道配色、圆角、间距都要用统一的 CSS 变量。
把这个问题捋一捋,真正的痛点其实就三个:
- AI 看不见存量:写代码前不会主动去读组件目录、导出清单,项目里有什么可复用的,它一无所知;
- AI 默认新写:没有存量参考时,它总是选"现场编一套"这条最省事的路,而不是先查查能不能复用;
- 事后补救太贵:代码已经落盘、进了 diff,再回头做复用优化,AI 生成的成本白花了,还得搭上人工清理、重构、对齐规范的时间。
所以治本的路子不是调提示词、训 AI"乖一点",而是改造工程工具链:写代码前让它知道项目有哪些组件,写的时候按项目真实 API 来,合入主干前过一遍复用校验。
二、关键词搜索根本防不住"换皮组件"
很多人第一反应是:用 IDE 全局搜索不就行了?Ctrl+Shift+F 搜个"Button",看看有没有现成的。
这个办法只能拦住"长得一模一样"的代码。真正麻烦的是那些换皮、异形、同源的隐性重复,关键词根本搜不出来。看几个典型场景:
| 漏检场景 | 核心原因 |
|---|---|
| 命名体系分裂 | 项目里新旧两套命名并存,老组件叫 FfBtn,新组件统一叫 ActionTrigger,关键词对不上,搜索完全落空 |
| 实现形态不同 | 同一个功能的组件,一个用 Vue 单文件写,一个用 render 函数工厂写,文本差异巨大,关键词检索识别不了同源 |
| DOM 结构不同、职责一致 | 同样是可点击操作,一个用原生 button,一个用带按钮角色的 div,语义和结构都不同,但业务功能完全重合 |
所以靠谱的治理得两条腿走路:
- 确定性清单校验:把组件导出表、Storybook 文档、样式变量规范整理好,在 AI 写代码前主动喂进它的上下文;
- 智能近似匹配:用 AST 结构指纹、向量检索,在代码生成后和 CI 阶段精准揪出"换皮不换功能"的重复组件。
三、生成前:先让AI"看见"项目库存
这一步是整个方案的地基:强制 AI 在调用写文件、改文件工具之前,先加载项目的可复用组件清单。
不管钩子叫 PreToolUse 还是 before_submit,核心都一样——前置拦截,把存量约束塞进去,从源头拦住盲目新建。
(一)自动扫描组件,生成库存清单
靠人工口头叮嘱"项目有哪些通用组件"完全不靠谱,也跟不上迭代。写个脚本自动扫组件目录,把名称、路径、props、用到的全局样式变量全解析出来,生成一份标准 JSON 库存,一劳永逸。
// scripts/inventory-components.ts
import fg from 'fast-glob'
import { parse } from '@vue/compiler-sfc'
import fs from 'node:fs/promises'
import path from 'node:path'
export type ComponentCard = {
name: string
file: string
exportName: string
props: string[]
tokensUsed: string[]
}
// 匹配CSS变量
const TOKEN_RE = /var(--([a-z0-9-]+))/gi
// 构建项目组件库存清单
export async function buildInventory(root = 'packages'): Promise<ComponentCard[]> {
// 遍历目标目录下的组件文件,过滤无效文件
const files = await fg(['**/components/**/*.{vue,tsx,ts}'], {
cwd: root,
absolute: true,
ignore: ['**/node_modules/**', '**/dist/**', '**/*.test.*'],
})
const cards: ComponentCard[] = []
for (const file of files) {
const source = await fs.readFile(file, 'utf8')
const base = path.basename(file, path.extname(file))
// 提取当前组件使用的所有全局样式变量
const tokensUsed = [...source.matchAll(TOKEN_RE)].map((m) => `--${m[1]}`)
// 解析Vue单文件组件
if (file.endsWith('.vue')) {
const { descriptor } = parse(source)
// 提取组件props定义
const props =
descriptor.scriptSetup?.content.match(/defineProps<s*{([^}]+)}/s)?.[1]
?.split('n')
.map((l) => l.trim().split(/[?:]/)[0])
.filter(Boolean) ?? []
cards.push({
name: base,
file: path.relative(process.cwd(), file),
exportName: base,
props,
tokensUsed: [...new Set(tokensUsed)],
})
continue
}
// 解析TS/TSX组件导出
const exports = [...source.matchAll(/exports+(?:function|const)s+([A-Z]w+)/g)].map(
(m) => m[1],
)
for (const exportName of exports) {
cards.push({
name: exportName,
file: path.relative(process.cwd(), file),
exportName,
props: [],
tokensUsed: [...new Set(tokensUsed)],
})
}
}
return cards
}
// 执行脚本,生成库存JSON文件
if (import.meta.url === `file://${process.argv[1]}`) {
const inventory = await buildInventory()
await fs.writeFile('component-inventory.json', JSON.stringify(inventory, null, 2))
console.log(`组件库存扫描完成,共${inventory.length}个可复用组件`)
}
生成出来的库存文件长这样,信息一次到位,直接喂给 AI:
[
{
"name": "SubmitAction",
"file": "packages/ui/src/components/SubmitAction.vue",
"exportName": "SubmitAction",
"props": ["pending", "tone", "disabled"],
"tokensUsed": ["--color-action", "--radius-control"]
}
]
(二)写文件前注入复用约束
AI 每次执行写入、编辑、补丁操作前,自动在它的上下文里注入组件复用规范和库存清单,逼它优先用现成的,禁止顺手内联实现同类控件。
// agent-hooks/before-write.ts
import inventory from '../component-inventory.json'
export function beforeWriteTool(toolName: string, input: { prompt?: string }) {
// 仅拦截文件写入、编辑、补丁操作
if (!['Write', 'Edit', 'ApplyPatch'].includes(toolName)) return input
// 截取核心组件清单注入上下文
const digest = inventory
.slice(0, 40)
.map(
(c) =>
`- ${c.exportName} @ ${c.file} | props: ${c.props.join(', ') || '-'} | tokens: ${c.tokensUsed.join(', ') || '-'}`,
)
.join('n')
// 落地强制约束规则
const guard = `
【代码落盘强制约束】
1. 优先导入使用下列已有组件,禁止在业务页面内联实现同职责控件
2. 页面颜色、圆角、间距等样式,仅使用项目CSS变量,禁止手写固定色值、尺寸
3. 清单无适配组件时,需先说明组件缺口、提出新增建议,再进行代码修改
可复用组件清单(节选):
${digest}
`
return {
...input,
prompt: `${guard}nn${input.prompt ?? ''}`,
}
}
这套钩子解决的问题,其实还有更轻的做法:把同样的约束直接写进项目的 AGENTS.md。
AGENTS.md 是给 AI 看的"项目说明书",AI 每次启动都会自动读一遍,Claude Code、Cursor、Copilot 这些工具都认它。
把 "优先复用已有组件、禁止内联实现同类控件" 写进它的编码规范模块,再把 component-inventory.json 的路径写进文档索引,AI 从一开始就知道项目里有什么、去哪儿找,不用等钩子临时注入。
Claude Code 自带的 Hooks 就是这类钩子的现成实现,可以直接用。
钩子负责"写之前强制注入",AGENTS.md 负责"启动时就知道",两层叠一起,效果更稳。
AGENTS.md 具体怎么编写、怎么使用,我在另一篇《AGENTS.md:你的AI代码助手,需要一份"项目说明书"》里拆过完整模板,从工具适配到进阶技巧都有,需要的话可以直接看:AGENTS.md:你的AI代码助手,需要一份"项目说明书"。
(三)代码落盘后自动拦截重复
光有前置约束还不够。AI 写完代码后,再自动把新文件和库存组件做结构相似度比对,重合度高的直接报错阻断,让它老老实实回去复用。
// agent-hooks/after-write.ts
import { jaccardAstSimilarity } from '../lib/ast-similarity'
import inventory from '../component-inventory.json'
export async function afterWriteTool(createdFiles: string[]) {
// 遍历所有新增文件,逐一比对库存组件
for (const file of createdFiles) {
for (const item of inventory) {
const score = await jaccardAstSimilarity(file, item.file)
// 相似度阈值0.85,可根据团队规范调整
if (score >= 0.85) {
throw new Error(
`${file} 与已有组件 ${item.file} 结构高度重合(相似度${score.toFixed(2)}),请直接复用 ${item.exportName} 组件,禁止重复实现`,
)
}
}
}
}
前置约束定方向,后置校验卡结果,这套组合拳比人肉评审盯 PR 靠谱得多。
四、生成中:让AI查着"说明书"写代码
库存清单解决了"项目里有什么",解决不了"该怎么用"。
组件 API 一直在变,光靠清单,AI 很容易凭旧印象写出过期 props、错误用法,照样出 bug。
最省事的办法是搭一个最小组件知识库——MCP 工具,强制 AI 写 UI 前先查一下组件真实文档和最新 API,一切以查询结果为准。
组件知识库MCP服务
// mcp-component-kb/server.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import inventory from '../component-inventory.json'
import docs from './component-docs.json'
const server = new McpServer({ name: 'component-kb', version: '0.1.0' })
// 注册组件查询工具
server.tool(
'lookup_component',
'按组件名称、业务职责查询项目内组件的完整API和使用示例',
{
query: z.string().describe('查询关键词,例如「提交按钮」「危险操作确认」'),
},
async ({ query }) => {
const q = query.toLowerCase()
// 模糊匹配符合需求的组件
const hits = inventory.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.file.toLowerCase().includes(q) ||
(docs[c.name]?.summary ?? '').toLowerCase().includes(q),
)
// 返回组件完整信息(API+示例)
const payload = hits.slice(0, 5).map((c) => ({
...c,
api: docs[c.name]?.api ?? null,
example: docs[c.name]?.example ?? null,
}))
return {
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
}
},
)
再把调用逻辑写进团队编码规范(规范清单),强制 AI 遵守:
- 修改、新增界面组件前,必须先调用
lookup_component查询存量组件; - 组件的 props、事件名以查询返回的最新 API 为准,禁止自定义编造;
- 存量组件能满足需求就默认复用;确实要新增,先说清楚理由和差异化场景。
不用堆超长系统提示词,把"查询校验"变成固定动作,API 误用和组件瞎编自然就少了。
五、生成后:扫描兜底,揪出隐性重复
前后钩子拦得住显性问题,但改几个变量、调一点逻辑的隐性换皮组件,还是可能漏网。
这步用可重复执行的扫描脚本补位,专治结构重复、样式变量漂移、样式写死。
AST结构相似度查重
文本 diff 对"改名换皮、微调逻辑"完全无效。AST 比对不一样——它忽略变量名、注释这些无关信息,只比核心节点结构,同源重复一眼就能认出来。
// lib/ast-similarity.ts
import { parse } from '@babel/parser'
import traverse from '@babel/traverse'
import fs from 'node:fs/promises'
// 提取代码核心结构Token,忽略无关细节
function structuralTokens(code: string): Set<string> {
const ast = parse(code, { sourceType: 'module', plugins: ['jsx', 'typescript'] })
const tokens = new Set<string>()
traverse(ast, {
enter(path) {
// 记录节点类型
tokens.add(path.node.type)
// 记录JSX标签名
if (path.isJSXIdentifier()) tokens.add(`jsx:${path.node.name}`)
// 记录自定义组件标识符
if (path.isIdentifier() && /^[A-Z]/.test(path.node.name)) {
tokens.add(`id:${path.node.name}`)
}
},
})
return tokens
}
// 计算两个文件的杰卡德结构相似度
export async function jaccardAstSimilarity(aPath: string, bPath: string): Promise<number> {
const [a, b] = await Promise.all([fs.readFile(aPath, 'utf8'), fs.readFile(bPath, 'utf8')])
const A = structuralTokens(a)
const B = structuralTokens(b)
// 计算交集、并集
let inter = 0
for (const t of A) if (B.has(t)) inter++
const union = A.size + B.size - inter
return union === 0 ? 0 : inter / union
}
遇上结构微调、语义仍重复的复杂场景,可以再叠一层向量嵌入(Embedding),把去标识后的 AST 序列向量化比对,双保鲜。配一个 pnpm scan:near-dup 命令,一键扫全项目。
禁止业务代码写死色值
AI 特别喜欢随手写死十六进制色值、固定尺寸,组件一写死就脱离设计系统,全局迭代直接报废。用一条自定义 ESLint 规则把它摁住:
// 自定义ESLint规则:禁止硬编码颜色值
module.exports = {
meta: { type: 'problem', docs: { description: '禁止业务代码写死十六进制色值,统一使用全局样式变量' } },
create(context) {
// 匹配所有十六进制颜色值
const HEX = /#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})b/
return {
// 校验普通字符串色值
Literal(node) {
if (typeof node.value === 'string' && HEX.test(node.value)) {
context.report({
node,
message: `禁止写死颜色值 ${node.value},请使用项目CSS变量(例:var(--color-action))`,
})
}
},
// 校验模板字符串内色值
TemplateElement(node) {
if (HEX.test(node.value.raw)) {
context.report({
node,
message: '模板字符串中禁止写死颜色值,请替换为全局样式变量',
})
}
},
}
},
}
效果对比:
<!-- AI容易生成的不规范代码 -->
<button style="background:#2563EB">提交</button>
<!-- 符合设计系统的规范代码 -->
<SubmitAction tone="primary">提交</SubmitAction>
这条扫描管道还能继续扩展:无效导入、废弃组件引用、只改展示名的复制组件,都能查。规范固化成工具,就不靠口头自觉了。
这里有个判断标准值得记住:像写死色值这种 linter 能自动抓的错,不用写进 AGENTS.md;
真正值得写进去的,是"复用组件"这种 linter 抓不住、只能靠 AST 扫描发现的硬约束。
AGENTS.md 只写 AI 自己推断不出来的规则,其他交给工具链。
六、CI门禁:PR合并前最后一道闸
本地钩子、本地扫描都能被人为跳过,但 CI 不行。代码要合入主干,就必须过复用校验。
# .github/workflows/reuse-gate.yml
name: component-reuse-gate
on: [pull_request]
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- run: pnpm i
# 重新扫描生成最新组件库存
- run: pnpm exec tsx scripts/inventory-components.ts
# 执行组件重复相似度扫描
- run: pnpm exec tsx scripts/scan-near-duplicates.ts --threshold 0.85
# 校验样式变量规范,禁止硬编码样式
- run: pnpm lint:design-tokens
只要扫出高相似度重复组件或违规硬编码样式,CI 直接失败、阻断合并。冗余代码入库这条路,从工程上就堵死了。
总结
让 AI 写代码很容易,难的是让它贴着你项目的规范、设计系统和存量能力来写。
组件库存让 AI 知道"项目里有什么",AGENTS.md 让它启动就懂规矩,MCP 知识库让它搞懂"该怎么用",扫描校验和 CI 门禁守住"不能乱改"的底线,闭环迭代让这套体系越用越聪明。
把这套链路跑通之后,代码评审不用再人肉查重、人肉纠错,AI 才真正从"运维负担"变成"提效工具"。
相关文章
- ASP.NET M5C模式中应用程序结构详解 09-17
- ASP.NET M5C模式简介 09-17
- ASP.NET CORE读取json格式配置文件 09-17
- Copilot Word实操:快速生成管理层销售项目周报 09-17
- 别只对 AI 说“我要什么”:把需求讲清楚才有好结果 09-17
- EntityFramework系统架构与原理介绍 09-17