AI写代码越跑越快,项目组件却越来越乱?一套工程闭环根治重复造轮子
🤖 AI写代码越跑越快,项目组件却越来越乱?一套工程闭环根治重复造轮子 ⚡
前端开发的朋友,AI写代码快但容易重复造轮子,导致项目组件混乱。这套工程闭环方案能根治这个问题,推荐试试。
AI写代码快但会重复造轮子,比如写两个功能相似的按钮组件,导致项目组件混乱。工程闭环方案通过生成项目组件清单,让AI写代码前知道项目已有组件,并在代码生成后用AST结构指纹和向量检索揪出重复组件。
🤖 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 ( /export\s+(?: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} \n\n ${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 才真正从"运维负担"变成"提效工具"。