一个公共表格组件,是怎么一步步失控的

本文是「前端工程现场」系列的第 5 篇。

下面使用一个假设的 Vue 3 后台项目说明问题,组件名称、页面数量和代码均为简化示例。

设想一个多人维护的后台系统里,有一个叫 BaseTable 的公共组件。

在最初的时候,它只负责三件事:

  • 展示表格;
  • 显示加载状态;
  • 处理分页。

用户列表、订单列表和操作日志都在使用它,业务页面只需要传入列配置和数据,复用效果也很直接。

后来订单页面提出一个新需求:勾选多行后批量导出。

开发者给 BaseTable 增加了 selectableselectedKeysshowExport。为了让用户列表也能使用,又顺手把导出按钮文案和权限标识做成了配置。

再后来,操作日志页面需要服务端排序,用户列表需要固定操作列,订单页面需要合并单元格。组件的 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",但它很难覆盖所有组合。remoteSortspanMethod、权限、插槽和分页继续加入后,类型定义会迅速变成另一份配置规则表。

旧项目配置最危险的地方在于,每一行看起来都有历史理由。删掉任何一个开关,都可能有某个页面在角落里等着报错。

排查组件是否已经过度配置化时,可以先搜索调用方:

bash 复制代码
grep -R "<BaseTable" src
grep -R "show-export\|remote-sort\|span-method" src

然后观察两个问题。

第一,同一个 Prop 是否只被一个页面使用。如果 spanMethod 只有订单页传入,它更接近订单页面的业务逻辑。

第二,调用方是否总以固定组合出现。如果 showExportpermissionCodeexportText 每次都一起传,当前抽象可能把一个完整的导出功能拆成了三个开关。

配置项存在的价值,应该落在多个调用方都需要的稳定差异上。只服务一个页面的选项留在公共组件中,会把该页面的变化成本分摊给所有使用者。

改造时先把业务能力搬回页面

这类组件不适合一次推倒重写。调用方较多时,整批替换会把一次边界调整变成大范围回归。

可以先从只服务少数页面的能力开始搬迁。

以导出功能为例,原来由公共组件决定按钮、权限和事件:

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 只负责展示分页器并抛出事件,这层复用比较稳定。

如果部分页面使用游标分页,部分页面使用页码分页,还有页面使用无限滚动,强行统一成 pagepageSizetotal 会产生越来越多兼容分支。此时可以把分页器拆成独立组件,由页面选择是否组合。

组件边界没有固定答案,可以从变化频率判断:

代码内容 更可能放置的位置 原因
加载状态、空状态、基础列渲染 公共表格组件 多个页面需要保持一致
订单导出、用户禁用、日志详情 业务页面或业务组件 由具体业务规则驱动
分页请求、查询重置、加载状态 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]])
})

这项测试只证明公共组件正确抛出了页码,不会证明订单页面重新请求成功。业务页面仍然要验证事件接收和接口调用。

如果项目没有组件测试基础,也可以建立一个专门的演示页面,覆盖空数据、加载中、多列、插槽、分页和选择状态。它比靠记忆打开十几个业务页面稳定一些,但演示页也需要维护,不能把已经删除的组合永远留在那里。

公共组件改造后的验证可以分成两层:

  1. 组件层确认稳定行为,例如插槽参数、分页事件和加载状态;
  2. 业务层抽查受影响页面,确认请求、权限和业务操作没有被改变。

CI 通过组件测试,不能说明所有页面表现正确。公共组件和业务页面之间的组合仍然可能出错,这部分需要页面测试或人工回归覆盖。

重复一点代码,有时比新增一个开关更便宜

公共组件失控后,团队常会走向另一个极端:所有页面都各写一份完整表格。

这样确实减少了公共组件分支,但统一样式、修复兼容问题和调整交互时,又要修改多份代码。

是否继续抽象,可以观察变化是否已经稳定。

假设订单页和对账页都有一段相似的金额列:

ts 复制代码
{
  prop: 'amount',
  label: '金额',
  formatter: formatCurrency
}

目前只有两处,业务规则还在变化。直接保留两份配置,通常比立刻设计一个支持币种、精度、空值、负数样式和权限脱敏的 MoneyColumn 更容易维护。

等到第三个、第四个页面出现相同规则,并且改动总要一起进行,再抽成公共列配置会更有依据。

重复代码产生的是同步修改成本。过早抽象产生的是理解、配置和兼容成本。两者都要付钱,只是账单来的时间不同。

最后说几句

这次假设问题表面上是一行分页显示条件导致另一个页面行为变化。继续往下看,组件已经同时处理表格展示、导出、权限、排序、选择和业务操作,修改范围自然难以控制。

处理方式不是把公共组件全部拆掉,而是把变化原因不同的代码分开。表格外壳保留稳定的视觉和交互约定,业务按钮和权限回到页面,请求流程可以进入组合函数,特殊单元格通过插槽或列配置完成。

以后再给公共组件增加 Prop,可以先搜索它会被几个页面使用,以及这个选项是否只服务某一类业务。如果新需求需要组件认识"订单""用户"或某个权限编码,先停一下。再加一个布尔值很快,解释十几个布尔值怎么组合就没那么快了。

下一篇会继续讨论前端项目目录。组件、组合函数、接口和类型被拆开后,应该按技术类型存放,还是跟随业务模块放在一起,目录结构会直接影响一次需求需要跨多少位置修改。

相关推荐
腻害兔1 小时前
【若依项目-产品经理视角】RuoYi-Vue-Pro 源码拆解:CRM 客户关系模块深度解析——从线索到回款,一套完整的 B2B 销售闭环是怎么搭的?
java·前端·javascript·vue.js·产品经理·ai编程
whyfail2 小时前
前端学 Spring Boot(2):一次点击,如何穿过整个后端?
前端·spring boot·后端
AI多Agent协作实战派2 小时前
AI多Agent协作系统实战(二十三):Agent读HEARTBEAT.md不读AGENTS.md——openclaw的文件加载之谜
前端·数据库·人工智能·uni-app
2601_955760073 小时前
如何用 Claude Opus 5 API 批量扩展长尾关键词和文章选题
前端·python·搜索引擎
KaMeidebaby3 小时前
卡梅德生物技术快报|bli亲和力检测gst:告别批量跑胶:BLI实时酶切监测技术加速GST融合蛋白下游流程优化
前端·网络·数据库·人工智能·算法
触底反弹4 小时前
🚀 删了数据刷新又回来?3 组件 × 4 回调 × 3 坑讲透 React 父子通信
前端·javascript·react.js
耳东小鹿5 小时前
对象常用方法
前端
程序员海军5 小时前
一个 AI 应用开发程序员的一天,都在屏幕前忙些什么?
前端·后端·程序员
程序员黑豆5 小时前
鸿蒙应用开发:Stack堆叠组件实战——实现微信消息角标效果
前端·harmonyos