本文是「前端工程现场」系列的第 5 篇。
下面使用一个假设的 Vue 3 后台项目说明问题,组件名称、页面数量和代码均为简化示例。
设想一个多人维护的后台系统里,有一个叫 BaseTable 的公共组件。
在最初的时候,它只负责三件事:
- 展示表格;
- 显示加载状态;
- 处理分页。
用户列表、订单列表和操作日志都在使用它,业务页面只需要传入列配置和数据,复用效果也很直接。
后来订单页面提出一个新需求:勾选多行后批量导出。
开发者给 BaseTable 增加了 selectable、selectedKeys 和 showExport。为了让用户列表也能使用,又顺手把导出按钮文案和权限标识做成了配置。
再后来,操作日志页面需要服务端排序,用户列表需要固定操作列,订单页面需要合并单元格。组件的 Props 逐渐变成:
ts
defineProps<{
columns: TableColumn[]
data: unknown[]
loading?: boolean
selectable?: boolean
selectedKeys?: string[]
showExport?: boolean
exportText?: string
permissionCode?: string
remoteSort?: boolean
fixedAction?: boolean
spanMethod?: Function
}>()
问题真正暴露在一次小改动里。
订单页面希望隐藏空数据时的分页器,于是有人加入了:
vue
<el-pagination
v-if="total > 0 && !hidePaginationWhenEmpty"
/>
提交后,订单页表现正常,操作日志页却无法在筛选结果为空时重置到第一页。它原本依赖分页器触发页码同步,公共组件中的一个显示条件改变了行为。改动本身只有一行,影响范围却覆盖了所有调用方。
最初的复用对象,其实只有表格外壳
回头看 BaseTable 的第一版,职责很清楚:
vue
<template>
<el-table
v-loading="loading"
:data="data"
>
<el-table-column
v-for="column in columns"
:key="column.prop"
v-bind="column"
/>
</el-table>
<el-pagination
:current-page="page"
:page-size="pageSize"
:total="total"
@current-change="$emit('page-change', $event)"
/>
</template>
这个组件复用的是稳定结构:表格、加载状态和分页器。
随着需求加入,复用对象发生了变化。组件开始处理导出、权限、批量选择、排序、合并单元格和操作列。这些功能虽然都出现在表格附近,变化原因却不相同。
订单导出由订单业务决定,权限标识来自权限系统,服务端排序依赖接口约定,单元格合并又只服务于特定数据结构。它们被放进同一个组件后,每次需求变化都要先经过 BaseTable。
公共组件开始出现大量分支:
vue
<ExportButton
v-if="showExport"
:permission="permissionCode"
:text="exportText"
/>
<el-table
:row-key="selectable ? rowKey : undefined"
:span-method="spanMethod"
@sort-change="remoteSort && handleSort($event)"
/>
这些判断单独看都合理。问题出在它们共享同一份内部状态和渲染流程。
例如 remoteSort 开启后,组件需要把排序字段抛给页面;本地排序页面则希望 Element Plus 自己处理。selectable 开启后,组件要维护选中项;分页切换时是否清空选择,又取决于具体业务。
Props 数量增加只是外在表现。更直接的判断方式是看组件内部是否开始理解业务规则:
ts
if (permissionCode === 'order:export') {
// 订单导出处理
}
if (tableType === 'operation-log') {
// 日志页面特殊分页
}
公共组件一旦出现这类判断,业务页面和组件之间的边界已经变得模糊。后续新增第四个页面时,开发者很难判断应该继续加一个开关,还是重新写一套表格。
配置项越多,调用方越难知道哪些能够组合
Props 的数量并不会自动导致组件失控。一个底层输入组件可以拥有很多明确属性,浏览器原生元素本身也不简单。
更麻烦的是配置之间存在隐藏约束。
假设 BaseTable 支持这些选项:
vue
<BaseTable
selectable
remote-sort
fixed-action
show-export
:span-method="mergeCells"
/>
调用方从模板上看不出以下规则:
- 开启
selectable后必须提供稳定的rowKey; - 开启
remoteSort后页面必须监听sort-change; - 使用
spanMethod时,选择列不能参与合并; - 固定操作列后,需要为横向滚动预留宽度;
- 导出按钮是否可见,还受权限组件影响。
这些约束如果只存在于组件内部,调用页面很容易组合出一个"类型正确、运行不对"的配置。
TypeScript 可以限制部分输入:
ts
type SelectableConfig =
| {
selectable: true
rowKey: string
}
| {
selectable?: false
rowKey?: never
}
这种联合类型能阻止"开启选择却没传 rowKey",但它很难覆盖所有组合。remoteSort、spanMethod、权限、插槽和分页继续加入后,类型定义会迅速变成另一份配置规则表。
旧项目配置最危险的地方在于,每一行看起来都有历史理由。删掉任何一个开关,都可能有某个页面在角落里等着报错。
排查组件是否已经过度配置化时,可以先搜索调用方:
bash
grep -R "<BaseTable" src
grep -R "show-export\|remote-sort\|span-method" src
然后观察两个问题。
第一,同一个 Prop 是否只被一个页面使用。如果 spanMethod 只有订单页传入,它更接近订单页面的业务逻辑。
第二,调用方是否总以固定组合出现。如果 showExport、permissionCode 和 exportText 每次都一起传,当前抽象可能把一个完整的导出功能拆成了三个开关。
配置项存在的价值,应该落在多个调用方都需要的稳定差异上。只服务一个页面的选项留在公共组件中,会把该页面的变化成本分摊给所有使用者。
改造时先把业务能力搬回页面
这类组件不适合一次推倒重写。调用方较多时,整批替换会把一次边界调整变成大范围回归。
可以先从只服务少数页面的能力开始搬迁。
以导出功能为例,原来由公共组件决定按钮、权限和事件:
vue
<BaseTable
show-export
export-text="导出订单"
permission-code="order:export"
@export="exportOrders"
/>
调整后,页面自己组合工具栏和表格:
vue
<OrderToolbar
v-if="canExport"
@export="exportOrders"
/>
<BaseTable
:columns="columns"
:data="rows"
:loading="loading"
/>
BaseTable 不再认识导出订单或权限编码。订单页面负责业务动作,公共组件继续处理表格结构。
操作列也可以交给插槽:
vue
<BaseTable
:columns="columns"
:data="rows"
>
<template #action="{ row }">
<OrderActions :order="row" />
</template>
</BaseTable>
公共组件负责把行数据和插槽位置交给调用方,不负责决定有哪些按钮。用户页可以使用 UserActions,日志页甚至不提供操作列。
分页请求同样不必全部塞进组件。页面可以通过组合函数维护查询参数和请求状态:
ts
const {
rows,
loading,
page,
pageSize,
total,
reload
} = useTable(fetchOrders)
useTable 负责分页状态、请求和重置,BaseTable 只接收结果并抛出分页事件。
这一步没有减少所有代码。业务页面会多出工具栏、插槽和组合函数调用,目录中的文件数量也可能增加。换来的变化是每段代码有了更明确的修改范围。
订单导出变化时,优先修改订单模块;表格加载样式变化时,修改 BaseTable;请求重试策略变化时,再检查 useTable。它们仍然协作,只是不再被包装成一个同时理解全部规则的组件。
拆分前后的差别不在文件数量,而在一次需求会穿过哪些边界。下面这张图把 BaseTable 失控前后的职责放在一起对比。

不是所有逻辑都要从公共组件中移走
把业务逻辑搬回页面,并不要求 BaseTable 退化成一层没有作用的标签转发。
下列能力通常仍适合保留:
- 统一的空状态和加载样式;
- 表格列的基础渲染;
- 分页器布局;
- 通用插槽转发;
- 多个页面都遵循的尺寸和交互约定。
这些能力的共同点是变化原因接近。设计规范调整时,它们往往一起变化,多个页面也需要保持一致。
分页是否保留在组件中,要看项目实际使用方式。如果所有列表页使用同一套页码结构,BaseTable 只负责展示分页器并抛出事件,这层复用比较稳定。
如果部分页面使用游标分页,部分页面使用页码分页,还有页面使用无限滚动,强行统一成 page、pageSize 和 total 会产生越来越多兼容分支。此时可以把分页器拆成独立组件,由页面选择是否组合。
组件边界没有固定答案,可以从变化频率判断:
| 代码内容 | 更可能放置的位置 | 原因 |
|---|---|---|
| 加载状态、空状态、基础列渲染 | 公共表格组件 | 多个页面需要保持一致 |
| 订单导出、用户禁用、日志详情 | 业务页面或业务组件 | 由具体业务规则驱动 |
| 分页请求、查询重置、加载状态 | useTable 等组合函数 |
多个页面流程相似,但不属于视觉组件 |
| 权限判断 | 页面或统一权限组件 | 权限编码不应由表格理解 |
| 特殊单元格合并 | 页面列配置或插槽 | 通常与特定数据结构绑定 |
表格中保留插槽,并不等于把所有业务重新塞回组件。插槽提供的是扩展位置,具体内容仍由调用方拥有。
修改公共组件前,先知道它影响了哪些页面
公共组件难改,还有一个原因是影响范围不透明。
开发者看到 BaseTable.vue 中的一行修改,无法立刻知道哪些页面依赖这个行为。等到测试阶段逐个点页面,时间已经花在了人工搜索上。
最低成本的做法,是先建立调用方清单:
bash
grep -R "BaseTable" src/modules src/views
修改分页、插槽或选择行为前,至少确认这些调用方分别开启了哪些配置。项目使用 IDE 引用查找或 TypeScript Language Service 时,也可以直接查看组件引用。
对高频公共组件,可以补一组覆盖稳定行为的组件测试。例如分页事件:
ts
it('emits page change', async () => {
const wrapper = mount(BaseTable, {
props: {
data: [],
total: 20,
page: 1,
pageSize: 10
}
})
await wrapper
.findComponent(ElPagination)
.vm.$emit('current-change', 2)
expect(wrapper.emitted('page-change'))
.toEqual([[2]])
})
这项测试只证明公共组件正确抛出了页码,不会证明订单页面重新请求成功。业务页面仍然要验证事件接收和接口调用。
如果项目没有组件测试基础,也可以建立一个专门的演示页面,覆盖空数据、加载中、多列、插槽、分页和选择状态。它比靠记忆打开十几个业务页面稳定一些,但演示页也需要维护,不能把已经删除的组合永远留在那里。
公共组件改造后的验证可以分成两层:
- 组件层确认稳定行为,例如插槽参数、分页事件和加载状态;
- 业务层抽查受影响页面,确认请求、权限和业务操作没有被改变。
CI 通过组件测试,不能说明所有页面表现正确。公共组件和业务页面之间的组合仍然可能出错,这部分需要页面测试或人工回归覆盖。
重复一点代码,有时比新增一个开关更便宜
公共组件失控后,团队常会走向另一个极端:所有页面都各写一份完整表格。
这样确实减少了公共组件分支,但统一样式、修复兼容问题和调整交互时,又要修改多份代码。
是否继续抽象,可以观察变化是否已经稳定。
假设订单页和对账页都有一段相似的金额列:
ts
{
prop: 'amount',
label: '金额',
formatter: formatCurrency
}
目前只有两处,业务规则还在变化。直接保留两份配置,通常比立刻设计一个支持币种、精度、空值、负数样式和权限脱敏的 MoneyColumn 更容易维护。
等到第三个、第四个页面出现相同规则,并且改动总要一起进行,再抽成公共列配置会更有依据。
重复代码产生的是同步修改成本。过早抽象产生的是理解、配置和兼容成本。两者都要付钱,只是账单来的时间不同。
最后说几句
这次假设问题表面上是一行分页显示条件导致另一个页面行为变化。继续往下看,组件已经同时处理表格展示、导出、权限、排序、选择和业务操作,修改范围自然难以控制。
处理方式不是把公共组件全部拆掉,而是把变化原因不同的代码分开。表格外壳保留稳定的视觉和交互约定,业务按钮和权限回到页面,请求流程可以进入组合函数,特殊单元格通过插槽或列配置完成。
以后再给公共组件增加 Prop,可以先搜索它会被几个页面使用,以及这个选项是否只服务某一类业务。如果新需求需要组件认识"订单""用户"或某个权限编码,先停一下。再加一个布尔值很快,解释十几个布尔值怎么组合就没那么快了。
下一篇会继续讨论前端项目目录。组件、组合函数、接口和类型被拆开后,应该按技术类型存放,还是跟随业务模块放在一起,目录结构会直接影响一次需求需要跨多少位置修改。