接手H35这个需求的时候,说实话我人是麻的。那是一个典型的后台管理系统页面,列表、筛选、详情、状态标签、操作按钮,属于一套再普通不过的CRUD界面。但当我打开同事用AI生成提交上来的代码,发现这哥们儿给AI描述的每一个区块都生成了全新的组件,同一个状态标签的样式逻辑,在页面里出现了四次,四次实现细节还都不一样,连颜色值都有两套,一个用的#1677ff,一个用的var(--brand-primary),肉眼可见的不一致。
这其实不是个例。最近半年团队里AI生成代码的比例越来越高,组件复用率却肉眼可见在往下掉。不是AI不会写可复用的代码,而是我们交给AI的上下文和约束本身,就没有告诉它"哪些东西应该复用"。我前后花了三个晚上把这批AI生成的代码过了一遍,抽出了规律,也把一套抽组件的方法沉淀了下来。这篇就聊聊我在H35项目里发现的那些规律,以及具体怎么把AI生成的"一次性代码"改造成真正能被复用的组件。
如果你现在也在被AI生成的代码搞得焦头烂额,或者正在带团队做前端基础设施治理,这篇文章应该能给你一些直接能抄作业的思路。
1. 为什么AI生成代码会让组件复用彻底失控
1.1 一个H35需求引发的"代码爆炸"
先交代一下背景。H35是我们内部的一个需求编号,内容是重构一个用户运营后台的列表页。原本这个页面用jQuery写的,数据量大了之后性能扛不住,决定用React重写。按正常节奏,这种页面应该拆成表格组件、搜索表单组件、状态标签组件、分页组件,再往上拼装。
但AI干活不是这么干的。AI拿到Prompt之后的第一反应是"把这件事完成",而不是"把这件事完成且尽量做得体面"。所以H35第一版生成的代码里,登录之后整个页面就是一个两三千行的大组件,所有状态变量平铺在顶层,筛选条件变化的时候重新拉数据,然后setData,整个列表区域又根据不同类型的数据渲染三套几乎一样的JSX结构。
这种写法的核心问题不是"代码丑",而是它把大量业务逻辑和展示细节做成了不可替换的硬编码。后续如果要加一个导出按钮,你得先在这两千行里找到对应的区块,然后再小心翼翼地改。如果test环境要显示不同的列表列,你得在这三套JSX里分别改。
1.2 AI是优秀的码农,但不是优秀的设计师
AI写代码的底层逻辑是"预测下一个最可能的Token"。它看过海量的代码,知道一个列表页大概长什么样,能给你写得像模像样。但AI没有"这个项目里已经有三个页面用了类似的结构"这种全局视角,除非你把这段信息喂给它。它的上下文窗口有限,对话越往后它越会忘记最开始你告诉过它的技术栈和规范。
我后来做了一个小实验:同一个需求,分别用"直接生成代码"和"先让AI阅读项目内的组件清单再生成代码"两种方式跑了一遍。前者的代码里,按钮全部用原生<button>实现,而后者的代码里,AI会主动引用项目里现成的<Button>、<Table>和<Modal>,布局风格和间距规范也自动对齐了。
差距不在AI的代码能力,而在我们给AI的"题目条件"。
1.3 组件复用的本质是接口设计,不是代码复用
很多人一提组件复用,脑子里的印象就是把重复的代码抽成一个函数。其实组件复用真正的内核,是把变化的部分和不变的部分切开,把不变的部分做成稳定的接口,把变化的部分留给使用者通过props、插槽、事件去注入。这套思路适用于人写的代码,也适用于AI生成的代码。
H35里最典型的反例:状态标签。用户的订单有"待支付""已支付""已取消""退款中"四种状态,AI在四个区块里分别手写了四段三目运算式的标签逻辑,颜色、文本、icon全部内联。如果抽成一个OrderStatusTag组件,入参只有一个status字段,内部维护好状态到{ color, text, icon }的映射,那么无论AI还是人,后续都只需要写一行<OrderStatusTag status={row.status} />。这不是把代码变短了,而是把"状态字段和展示样式的映射关系"这个业务规则收束到了一个地方,以后改状态标签的文案和颜色,只需要改一个文件。
抽组件的方向不对,AI写得再快,也只能是给存量债务加码。
2. AI生成代码里藏着的五类复用信号
在H35里我养成了一个习惯:不急着清代码,先把代码里的"复用信号"扫一遍。AI生成的代码再乱,它也是有规律的。因为这玩意儿本质上是统计学习,它在同一个上下文里生成多个区块时,会反复祭出自己最熟悉的那套写法。以下五类信号,基本百试百灵。
2.1 命名规律:前缀一致不是巧合
如果你发现AI生成的三个变量名分别叫handleVipSearch、handleNewSearch、handleAllSearch,或者三个组件文件叫VipOrderList、NewOrderList、AllOrderList,这就是最明显的复用信号。
这种命名规律说明AI内部其实是把同一个逻辑模板套用了不同的业务对象。最合理的处理方式是抽一个通用的OrderList组件,再通过props去区分是VIP订单还是新客订单。你要做的就是看这几个名字的后半段是不是一个东西,前半段是不是业务前缀,如果是,直接进入抽取流程。
2.2 结构规律:JSX骨架撞脸
把两个区块的JSX结构并排放一起,如果它们连标签嵌套层次都一样——div -> header -> input -> button -> table -> tbody,只有中间的字段名不同,恭喜,这是一个活脱脱的通用列表组件。
结构规律是最可靠的信号,因为AI在生成一段高度重复的结构时,它不会重新发明轮子,几乎就是把上一段代码复制过去改了字段名。这种撞脸通常发生在同一个页面里的不同tab,或者不同页面里的相似模块。
2.3 状态规律:同一套逻辑换皮三次
看useState、useEffect、useCallback,如果多个组件里出现了恰好三个状态:keyword、page、pageSize,然后一个fetchData函数,外加一个useEffect在状态变化时调用它,那么这段逻辑完全可以抽取成一个自定义Hook,比如useTableList。
这里有个识别诀窍:不看变量名,看变量之间的关系。keyword一变就重新请求,page一变就重新请求,这种"状态和副作用强耦合"的组,就是一块完整的逻辑单元,抽成Hook之后,这坨逻辑能让AI以后生成的新页面直接用,省掉一半的心智负担。
2.4 样式规律:className片段整段搬家
AI生成的代码里,经常出现一长串的className,比如"mt-4 flex items-center justify-between border-b border-gray-200 pb-4"。如果这段字符串在多个地方一字不差地出现,意味着"这段内容和它周围的结构是一个整体",可以考虑抽成一个子组件或者一个CSS类。
更需要注意的是,当同一份样式语义AI用了两套实现——比如一处写了#1677ff,另一处写了var(--brand-primary),说明AI没能理解项目的设计变量体系,这是在用自己的常识在写样式。这种时候要的不是马上去抽成组件,而是先把这份"同一语义多份实现"的情况暴露出来,在抽取组件时顺手把颜色统一成变量。
2.5 接口规律:props类型定义开始重叠
TypeScript项目里有一个特别实在的信号:扫一遍interface和type,如果多个地方出现了形状几乎一样的props定义,比如都有data: Order[]、loading: boolean、onRetry: () => void,那这几个组件之间一定存在可以共用的基础接口。
甚至不用肉眼去看,IDE的智能感知会告诉你"这个类型再别的地方已经定义过了"。AI对类型定义有一个坏习惯:它喜欢就地写一个尽量自洽的类型,而不关心项目里有没有完全一样的现成类型。所以在H35里,我专门过了一遍types目录,把三个页面独自维护的OrderItem、OrderRecord、OrderRow合成一个Order类型,这一步的收益比抽组件还高。
3. 实操:把一段AI生成代码改造成可复用组件
理论讲完了,来点实际的。下面我用一段简化过的示例代码,走一遍我在H35里完整跑过的抽取流程。你看到的这段代码就是典型的AI生成风格,看着能跑,但浑身是隐患。
3.1 先看原始代码长什么样
假设AI帮你生成了一张订单列表页,页面上方有一个搜索框,中间是一个表格,右下角是分页,状态栏有三段几乎一样的标签渲染逻辑。我第一次抽的时候,代码大概是这个味道:
export default function OrderList() { const [keyword, setKeyword] = useState(''); const [status, setStatus] = useState('ALL'); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); const [list, setList] = useState<OrderItem[]>([]); const [loading, setLoading] = useState(false); const loadData = async () => { setLoading(true); const params = { keyword, status, page, pageSize }; const res = await fetch(`/api/order/list?${qs.stringify(params)}`); const data = await res.json(); setList(data.list); setLoading(false); }; useEffect(() => { loadData(); }, [keyword, status, page, pageSize]); const renderStatus = (status: string) => { if (status === 'PENDING') return <span style={{ color: '#faad14' }}>待支付</span>; if (status === 'PAID') return <span style={{ color: '#52c41a' }}>已支付</span>; if (status === 'CANCELLED') return <span style={{ color: '#999' }}>已取消</span>; return <span>未知</span>; }; return ( <div className="page-container"> <div className="filter-bar"> <input placeholder="搜索订单号" value={keyword} onChange={(e) => setKeyword(e.target.value)} /> <select value={status} onChange={(e) => setStatus(e.target.value)}> <option value="ALL">全部状态</option> <option value="PENDING">待支付</option> <option value="PAID">已支付</option> <option value="CANCELLED">已取消</option> </select> </div> <table className="data-table"> <thead> <tr> <th>订单号</th> <th>状态</th> <th>金额</th> <th>操作</th> </tr> </thead> <tbody> {list.map((item) => ( <tr key={item.id}> <td>{item.orderNo}</td> <td>{renderStatus(item.status)}</td> <td>{item.amount}</td> <td> <button onClick={() => handleDetail(item)}>详情</button> <button onClick={() => handleCancel(item)}>取消</button> </td> </tr> ))} </tbody> </table> <Pagination current={page} total={list.length} onChange={setPage} /> </div> ); }这段代码单看没什么问题,但H35里类似结构的代码出现了五次,分布在不同权限的运营角色页面里,搜索字段不一样,表格列不一样,操作按钮不一样,但整体骨架一个模子。
3.2 识别重复段落,标记变化点
拿到这种代码,我第一步不是动手改,而是拿荧光笔把"一模一样"和"每个页面都不一样"的部分分别标记出来。以这段代码为例:
重复的部分是:
- 搜索框的交互逻辑(keyword + 触发刷新)
- 状态标签的渲染逻辑(status 映射到样式和文案)
- 列表数据的加载流程(请求、loading、错误处理)
- 分页的联动逻辑(page/pageSize 变化重新请求)
变化的部分是:
- 搜索框的字段数量(有时一个,有时两个三个)
- 表格的列定义(列名、宽度、怎么渲染)
- 操作按钮的数量和业务回调
- 请求的具体URL和参数名
这个"重复与变化"的清单,就是你设计组件props的说明书。
3.3 抽象接口,划定组件边界
第二步,按照上面的标记设计接口。我的习惯是先把"重复部分"做成稳定的接口,然后给"变化部分"留出插槽。
先说状态标签,这是最简单的抽取对象,把字符串状态映射到展示内容的逻辑收进一个组件:
// components/OrderStatusTag.tsx const STATUS_MAP = { PENDING: { text: '待支付', color: '#faad14' }, PAID: { text: '已支付', color: '#52c41a' }, CANCELLED: { text: '已取消', color: '#999999' }, } as const; interface OrderStatusTagProps { status: keyof typeof STATUS_MAP; } export default function OrderStatusTag({ status }: OrderStatusTagProps) { const config = STATUS_MAP[status]; if (!config) return <span>未知状态</span>; return <span style={{ color: config.color }}>{config.text}</span>; }再说列表加载逻辑,这个不要做成组件,做成自定义Hook:
// hooks/useTableList.ts import { useCallback, useEffect, useRef, useState } from 'react'; interface UseTableListOptions<T> { fetcher: (params: Record<string, unknown>) => Promise<{ list: T[]; total: number }>; initialParams?: Record<string, unknown>; defaultPageSize?: number; } export function useTableList<T>({ fetcher, initialParams = {}, defaultPageSize = 20 }: UseTableListOptions<T>) { const [params, setParams] = useState(initialParams); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(defaultPageSize); const [list, setList] = useState<T[]>([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); const fetchRef = useRef(0); const loadData = useCallback(async () => { const current = ++fetchRef.current; setLoading(true); try { const res = await fetcher({ ...params, page, pageSize }); if (current === fetchRef.current) { setList(res.list); setTotal(res.total); } } finally { if (current === fetchRef.current) setLoading(false); } }, [fetcher, params, page, pageSize]); useEffect(() => { loadData(); }, [loadData]); const search = useCallback((newParams: Record<string, unknown>) => { setParams((old) => ({ ...old, ...newParams })); setPage(1); }, []); const reset = useCallback(() => { setParams(initialParams); setPage(1); }, [initialParams]); return { list, total, loading, page, pageSize, setPage, setPageSize, search, reset, reload: loadData }; }这里我加了一个fetchRef计数器,用来防止竞态条件。AI生成的代码里最常见的bug之一就是快速切换条件时,上一次请求比下一次慢导致数据覆盖。这个细节也是我在H35里被坑了之后才加的。
3.4 把变化点变成配置项和插槽
表格是最灵活的部分,也是最需要克制的地方。通用表格组件最容易被做成"万能表格",最后又丑又难用。我的选择是做一个轻量级的DataTable组件,它接收列配置数组和操作列的render函数:
// components/DataTable.tsx import { ReactNode } from 'react'; export interface ColumnConfig<T> { title: ReactNode; dataIndex?: keyof T; render?: (record: T, index: number) => ReactNode; width?: number | string; } interface DataTableProps<T> { columns: ColumnConfig<T>[]; dataSource: T[]; rowKey: (record: T) => string; loading?: boolean; operationRender?: (record: T) => ReactNode; } export default function DataTable<T>({ columns, dataSource, rowKey, loading, operationRender }: DataTableProps<T>) { return ( <table className="data-table"> <thead> <tr> {columns.map((col, i) => ( <th key={i} style={{ width: col.width }}> {col.title} </th> ))} {operationRender ? <th>操作</th> : null} </tr> </thead> <tbody> {loading ? ( <tr><td colSpan={columns.length + (operationRender ? 1 : 0)}>加载中...</td></tr> ) : ( dataSource.map((record, index) => ( <tr key={rowKey(record)}> {columns.map((col, i) => ( <td key={i}> {col.render ? col.render(record, index) : String(record[col.dataIndex!] ?? '')} </td> ))} {operationRender ? <td>{operationRender(record)}</td> : null} </tr> )) )} </tbody> </table> ); }注意,我没有把分页做进DataTable里,因为分页位置在不同项目里差异太大,硬塞进去只会让组件在各种场景里被if/else吞掉。分页就交给页面自身去组合<Pagination>组件。
3.5 最终抽出的组件代码
把所有东西组合起来,原本那段AI生成的页面代码,重构成这样:
// pages/order-list.tsx import { useTableList } from '@/hooks/useTableList'; import DataTable, { ColumnConfig } from '@/components/DataTable'; import OrderStatusTag from '@/components/OrderStatusTag'; import { Pagination } from 'antd'; import { fetchOrderList, OrderItem } from '@/services/order'; export default function OrderListPage() { const { list, total, loading, page, pageSize, setPage, setPageSize, search, reload, } = useTableList<OrderItem>({ fetcher: fetchOrderList, initialParams: { keyword: '' }, }); const columns: ColumnConfig<OrderItem>[] = [ { title: '订单号', dataIndex: 'orderNo' }, { title: '状态', render: (record) => <OrderStatusTag status={record.status} />, }, { title: '金额', dataIndex: 'amount' }, ]; return ( <div className="page-container"> <div className="filter-bar"> <input placeholder="搜索订单号" onChange={(e) => search({ keyword: e.target.value })} /> </div> <DataTable columns={columns} dataSource={list} rowKey={(record) => record.id} loading={loading} operationRender={(record) => ( <> <button onClick={() => handleDetail(record)}>详情</button> <button onClick={() => handleCancel(record)}>取消</button> </> )} /> <Pagination current={page} pageSize={pageSize} total={total} onChange={setPage} /> </div> ); }这个版本相比原始版本,多了几个文件,但每一个文件都是单职责的。useTableList管数据流,OrderStatusTag管状态映射,DataTable管表格渲染,页面只负责组装。更关键的是,以后AI再帮其他同事生成一个新的"订单审核列表",只要把这份代码丢给它当参考,它就会照着这些组件去拼装,不会再造一套轮子。
4. 让AI一开始就生成"可复用"代码的4个办法
抽代码是治病,让AI别生病才是治本。H35项目走到后期,我已经总结出了一套给AI"立规矩"的方法,这里分享一下具体怎么落地。
4.1 在项目里维护一份"组件契约"文档
组件的复用规则不能只存在于技术负责人的脑子里。我在H35项目的根目录放了一个docs/component-contract.md,里面写清楚了:
- 哪些UI基础组件是全局唯一的(按钮、弹窗、表格、表单控件的封装名)
- 业务组件的边界是什么(订单状态用
OrderStatusTag,其余地方禁止直接渲染status字段) - 自定义Hook的使用约束(列表页逻辑统一走
useTableList,禁止页面里散写useState+useEffect组合) - 类型定义的归属(业务实体类型统一放
types/order.ts,禁止在各页面内联定义同名结构)
这看起来像文档工程,但它的真正作用不是给人看,而是给AI当上下文。团队里每个人在让AI生成代码前,都把这份文档贴进Prompt或者加入AI工具的"项目知识库",生成出来的代码默认就会引用项目内已有的组件。实测下来,组件复用率从不到30%提升到70%以上。
4.2 让AI学会读你的组件库
如果你用的是Cursor、Copilot、通义灵码这类带有项目上下文的工具,可以直接在生成代码前让AI先阅读你的组件目录和组件源码。
我常用的做法是在Prompt里写:
请先阅读 src/components 目录下所有以 index.tsx 结尾的文件,了解项目中已有的组件 名称、props 接口和用法。然后在实现需求时优先引用这些组件,如果需要新建组件, 请先说明原因。AI读完一遍再写,等于带着你自己的组件规范在答题。它的输出风格会立刻向你的组件库靠拢,而不是向着GitHub上那些热门开源项目的写法靠拢。
4.3 Prompt里强制声明:先列目录,再写代码
让AI一上来就写代码,十有八九会写出一个巨型组件。我的办法是分两步:第一步让AI先规划组件结构,第二步再细化每个文件。
比如这样的Prompt:
假设要把订单列表页写成可维护的代码,你需要先输出完整的目录结构,说明每个文件的 职责。注意:列表数据流必须使用 @/hooks/useTableList,表格必须使用 @/components/DataTable, 状态标签必须使用 @/components/OrderStatusTag。目录结构确认后再开始写文件。这一步看似只是把思考过程延后了,实际上效果非常显著。AI先在结构层面规划了接口,后续写代码时就不得不遵守自己规划出来的边界,而不是随手内联。
4.4 把"复用审查"写进AI生成的代码评审流程
AI代码不能直接合进主干,至少得过一遍复用审查。H35后期我定了一条团队红线:合并AI生成代码前,必须回答三个问题:
- 这段代码里有没有项目内已有的组件或Hook可以替代的内联实现?
- 有没有出现至少两处的重复结构?如果有,为什么不抽成组件?
- 新组件是否遵循了component-contract.md里的命名和边界约定?
这三个问题不满足就不予合并。坚持了两周后,团队里AI生成代码的质量肉眼可见提升了。说到底,AI只负责快,质量红线还是要靠人盯。
5. 踩坑记录与排查技巧实录
这部分写写我在这套方法论落地过程中踩过的真实的坑,希望你别再踩一遍。
5.1 过度抽象:组件化不等于越细越好
H35改造到一半,有次我为了追求"完美复用",把搜索表单、表格、分页、状态标签全拆了,甚至把表格里"金额列的数字格式化"也单独做了个AmountCell组件。结果团队成员用的时候怨声载道,说"找个字段要在组件间跳来跳去,还不如直接用原生table写"。
后来我把AmountCell这种"不值得复用的复用"撤掉了。判断标准很简单:如果这个逻辑只有一种业务形态在使用,或者它短到一眼能看完,就不要单独开文件。组件复用的收益来自"多个场景收敛到一个实现",不是为了拆而拆。
5.2 魔法数字:AI复用了样式值,但没复用变量
有次审查AI生成的页面,发现状态标签的颜色值,一半来自STATUS_MAP里的标准色,一半直接写死在JSX里。抽查下来发现是AI在生成某个操作列时,没有找到已有的状态映射,就"合理推测"出了一个红色给退款状态。
这种问题靠人肉眼查很累,我的排查办法是:全局搜索十六进制颜色值和rgba()写法,凡是出现在组件文件之外的颜色值,一律视为需要收敛的魔法数字。在H35里,我用这个办法一次性找回了七处散落的颜色值,全部归位到设计变量里。
5.3 三层组件健康度检查表
经过几次折腾,我整理了一份组件健康度检查表,适合在每次迭代后对核心组件做体检:
| 检查项 | 说明 | 不健康的信号 |
|---|---|---|
| 职责单一性 | 组件是否只干一件事 | 一个组件里同时处理数据拉取和UI渲染 |
| 接口收敛 | props是否有稳定的语义 | props超过8个或出现config这种万能对象 |
| 变化点外置 | 业务差异是否通过插槽/事件注入 | 组件内部用switch/if处理不同业务分支 |
| 命名一致性 | 组件名是否清晰表达职责 | 出现Card1、Part2这类无效命名 |
| 样式变量化 | 颜色间距是否引用设计变量 | 组件内存在#fff、10px等魔法值 |
这张表每两周过一遍,体检结果不达标就进重构池。
5.4 排查方法:Anchor-Compare-Trace
最后分享一个我在AI生成代码里排查"复用失效"问题时的实操方法,叫Anchor-Compare-Trace,简称ACT。
第一步Anchor,先找一个基准锚点:选一个你觉得质量最高的页面组件,把它作为对比基准。第二步Compare,拿其他相似页面和锚点做结构对比,把多出来的部分、缺失的部分、改了名的部分逐项列出来。第三步Trace,沿着列出来的差异往回追溯,找到每一处差异对应的业务来源——是业务本来就不同,还是AI没理解清楚导致的实现偏差。
这一套流程走下来,你会得到一份"项目组件复用偏差清单",哪些地方该抽组件、哪些地方该改Prompt、哪些地方该补规范,一目了然。比起靠感觉东翻西找,这种系统化排查能省出大半天时间。
6. 重新理解组件复用这件事
H35这个项目做完,我对"组件复用"的认知其实被刷新了一层。以前我觉得组件复用是个技术问题,多抽几个组件、多写几个Hook就完事了。现在我觉得它更像是一个"信息对齐"问题:团队里每个人和AI之间,到底共享了多少关于项目结构的知识。
代码层面的复用只是最表层的东西。真正有价值的是把"哪些东西必须统一、哪些东西可以变化、统一到什么粒度、变化通过什么机制注入"这套决策本身沉淀下来。当这套决策形成了文档、Checklist、Prompt模板,AI生成的代码就会自动长在已有的组件体系上。
我也越来越觉得,AI生成代码时代,前端工程师的核心竞争力不是写代码的速度,而是"定义边界"的能力。边界定义得越清晰,AI的代码就越规范;边界模糊,AI就会在每个文件里给你生成一套自己的世界。H35只是我们项目里不起眼的一个需求,但因为它,我们整个团队往前端工程化的方向又迈了一步。所以如果你也在收拾AI生成的烂摊子,别急着骂它,先看看能不能从里面找到你自己的规律。