Vue 3 + Element Plus 二次封装 ProTable:columns 配置化,一个组件吃掉所有列表页
一、从第 8 个列表页说起
不知道你有没有算过一笔账。
一个中后台系统,列表页能有多少个?用户管理、角色管理、部门管理、订单管理、商品管理、优惠券管理、操作日志、登录日志……稍微像样点的系统,30 个列表页起步。而每个列表页的结构几乎是复制粘贴的关系:上面一个搜索区,中间一个表格,下面一个分页条。
我接手过一个做到一半的项目,仓库里已经堆了 11 个列表页。我把两个"长得最像"的页面拿出来 diff 了一下,用户管理和角色管理两个页面,模板部分 280 行和 271 行,重合度超过 85%。重合的都是什么?搜索表单布局、表格骨架、分页条、loading 绑定、空数据提示、多选处理、刷新按钮……真正属于"这个页面自己"的东西,其实只有列定义和几个业务按钮。
<!-- 用户管理页面,280 行里的 240 行都是这种"通用骨架" -->
<el-form :inline="true" :model="queryParams" class="search-form">
<el-form-item label="用户名">
<el-input v-model="queryParams.userName" placeholder="请输入用户名" clearable />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="queryParams.phone" placeholder="请输入手机号" clearable />
</el-form-item>
<!-- 中间省略 6 个几乎一样的 el-form-item -->
<el-form-item>
<el-button type="primary" @click="handleQuery">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="tableData" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="50" />
<el-table-column prop="userName" label="用户名" />
<el-table-column prop="phone" label="手机号" />
<!-- 中间省略 8 个几乎一样的 el-table-column -->
<el-table-column label="操作" fixed="right" width="200">
<template #default="scope">
<el-button link type="primary" @click="handleEdit(scope.row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="queryParams.pageNum"
v-model:page-size="queryParams.pageSize"
:total="total"
@current-change="getList"
@size-change="getList"
/>
这种写法的问题不只是"代码多"。真正难受的是改一处要改 30 处:产品说"分页条统一加一个每页 50 条的选项",你得挨个页面去改;说"表格空数据统一换成自定义插画",又得挨个页面去改;后来想把分页从"前端记住查询条件"改成"查询后回到第一页",30 个页面每个的 handleQuery 都要动。改漏一个就是一次线上 bug。
所以这篇就干一件事:把列表页的通用骨架抽成一个 ProTable 组件,让每个列表页只剩下"列定义 + 请求函数 + 几个业务动作"。最后的效果是,新写一个列表页,模板部分 15 行以内。我们团队用这套组件之后,一个中等复杂度的列表页(含调试)从半天压到 40 分钟左右。
先说清楚边界:这篇讲的是"怎么设计和落地",不是让你直接抄。因为 ProTable 这种组件,不同团队的搜索区交互、分页约定、权限模型都不一样,直接抄别人的往往水土不服。真正值钱的是设计过程中那几个决策点。
二、动手前先想清楚:接口约定和类型定义
二次封装最忌讳一上来就写模板。先花十分钟把两件事定下来:组件跟谁说话(数据从哪来)、列配置长什么样(类型怎么定义)。这两个定了,组件的实现就是顺着写。
2.1 请求函数的约定
ProTable 不应该关心你的接口是 axios 还是 fetch,也不应该关心后端返回的结构长什么样。所以数据获取这块,我只约定一个函数签名:
// ProTable 只认识这个签名:传分页和查询参数,返回列表数据和总数
type TableRequest<T> = (params: PageParams & Record<string, unknown>) => Promise<{
list: T[]
total: number
}>
注意一个关键决策:请求函数由页面传入,而不是组件内部根据 url 自动发请求。有些开源实现会把 api、method、params 都传进组件让组件自己发请求,看起来更"智能",但实际用下来你会发现:真实项目的请求前置处理五花八门——有的列表要先调字典接口、有的要拼动态查询条件、有的返回结构是 { result: { records, total } } 包了三层。把这些都塞进组件的 props 里,props 会爆炸,组件会变成一个怪物。
函数是最灵活的边界。组件只管"什么时候调、调完拿到什么",怎么调是页面自己的事。这个决策我们当时纠结过要不要更彻底一点(把分页状态也交给页面管),后来否了——分页状态 100% 的页面逻辑完全一样,没有个性化的空间,交给组件管才是真正的减负。
2.2 列配置的类型定义
列配置是 ProTable 的灵魂。定义得太简单,覆盖不了场景;定义得太复杂,用的人记不住。我们最终沉淀下来的是这个版本:
// 通用列配置:el-table-column 的属性基本都透传,再加几个便捷能力
interface ProColumn<T = any> {
// ===== el-table-column 原生属性,直接透传 =====
prop: string
label: string
width?: number | string
minWidth?: number | string
fixed?: 'left' | 'right' | boolean
align?: 'left' | 'center' | 'right'
showOverflowTooltip?: boolean
sortable?: boolean | 'custom'
// ===== ProTable 扩展能力 =====
/** 枚举列:传字典 code,自动渲染成 el-tag */
dictCode?: string
/** 时间列:自动把时间戳/ISO 字符串格式化后展示 */
format?: 'datetime' | 'date' | 'money' | 'percent'
/** 插槽列:这一列的内容用页面传进来的具名插槽渲染 */
slot?: string
/** 当前列在表格里隐藏(配置还在,方便切来切去) */
hide?: boolean
}
这里有个值得单独说的设计:slot 字段。它解决的是"80% 的列是纯展示,20% 的列需要自定义渲染"的问题。纯展示的列(用户名、手机号、时间)靠 prop + format + dictCode 就够了;需要自定义的(头像、状态标签、操作列),配置里写 slot: 'avatar',页面里写 <template #avatar="{ row }">,组件内部把插槽转发出去。这样 80% 的场景零成本,20% 的场景完全自由,比"所有列都写 template"和"所有列都靠 render 函数"都舒服。
三、核心实现:把骨架代码全部收进来
类型定了,组件实现就是水到渠成。完整的组件代码在下面,我按功能块拆开讲。
3.1 Props 定义
<!-- ProTable.vue -->
<script setup lang="ts" generic="T">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import type { ProColumn } from './types'
const props = withDefaults(
defineProps<{
/** 列配置 */
columns: ProColumn<T>[]
/** 请求函数:组件负责调,页面负责提供 */
request: TableRequest<T>
/** 查询参数:页面维护,组件原样透传给 request */
queryParams?: Record<string, unknown>
/** 是否显示多选列 */
selection?: boolean
/** 是否显示序号列 */
index?: boolean
/** 分页条数选项 */
pageSizes?: number[]
/** 请求前置 hook:返回 false 则中断本次请求 */
beforeRequest?: (params: Record<string, unknown>) => boolean | void
}>(),
{
queryParams: () => ({}),
selection: false,
index: false,
pageSizes: () => [10, 20, 50, 100],
},
)
const emit = defineEmits<{
selectionChange: [rows: T[]]
}>()
</script>
generic="T" 是 Vue 3.3 之后的写法,让 ProColumn<T> 的 T 跟着页面传入的 request 返回类型走,页面上 row.userName 能拿到完整的类型提示。这个后面细说。
3.2 数据加载:最核心也最容易踩坑的 30 行
// 加载状态与数据
const loading = ref(false)
const tableData = ref<T[]>([]) as Ref<T[]>
const total = ref(0)
const pagination = reactive({ pageNum: 1, pageSize: 10 })
// 请求序号:解决快速翻页时"旧响应覆盖新响应"的竞态问题
let requestSeq = 0
async function loadData() {
// 前置 hook 拦截
if (props.beforeRequest) {
const pass = props.beforeRequest({ ...pagination, ...props.queryParams })
if (pass === false) return
}
const seq = ++requestSeq
loading.value = true
try {
const res = await props.request({ ...pagination, ...props.queryParams })
// 序号对不上,说明这次请求已经"过期"了,丢弃
if (seq !== requestSeq) return
tableData.value = res.list
total.value = res.total
} finally {
if (seq === requestSeq) loading.value = false
}
}
/** 刷新:保持当前页,删除/编辑后用 */
function refresh() {
loadData()
}
/** 重置:回第一页,查询按钮用 */
function reload() {
pagination.pageNum = 1
loadData()
}
defineExpose({ refresh, reload })
这 30 行里有三个坑,都是我们真金白银换来的:
第一个坑:竞态。 用户快速点"下一页",第 1 页的响应还没回来,第 2 页的响应先到了,然后第 1 页的响应慢悠悠回来把数据覆盖了——页面显示的是第 2 页的数据,分页条却停在第 2 页,数据其实是第 1 页的,用户刷新好几遍才恢复。解法就是上面的请求序号 requestSeq:每次发请求自增,回来的时候对不上号就直接丢弃。一开始我们用 AbortController 做取消,后来发现在 axios 拦截器里统一取消会误伤别的请求,就换成了这种序号比对,简单粗暴但可靠。
第二个坑:refresh 和 reload 不分。 大多数实现只暴露一个 search 方法。但"查询按钮"和"编辑完保存刷新"是两个语义:前者应该回第一页(条件变了,当前页可能不存在了),后者应该留在当前页(用户在第 3 页改了一条数据,刷新完跳回第 1 页会被骂)。分成 reload(回第一页)和 refresh(保持当前页)两个方法,语义清晰,页面调用也不会犹豫。
第三个坑:删除最后一页的唯一一条数据。 用户在第 5 页(最后一页)删掉了这一页唯一一条记录,刷新后 pageNum=5 查出来空列表,total 还有 40 条。处理方式是在 loadData 拿到结果后补一个修正:
// 当前页空了但 total 还有数据,回退一页重查
if (res.list.length === 0 && res.total > 0 && pagination.pageNum > 1) {
pagination.pageNum = Math.ceil(res.total / pagination.pageSize)
return loadData()
}
这个细节用户根本感知不到"修复了",但不修的话投诉来得很快。
3.3 模板部分:透传 el-table-column
<template>
<div class="pro-table">
<el-table v-loading="loading" :data="tableData" @selection-change="onSelectionChange">
<el-table-column v-if="selection" type="selection" width="50" fixed="left" />
<el-table-column
v-if="index"
type="index"
label="序号"
width="60"
:index="(i: number) => (pagination.pageNum - 1) * pagination.pageSize + i + 1"
/>
<template v-for="col in visibleColumns" :key="col.prop">
<!-- 插槽列:转发给页面 -->
<el-table-column v-if="col.slot" v-bind="pickNativeProps(col)">
<template #default="scope">
<slot :name="col.slot" :row="scope.row" :index="scope.$index" />
</template>
</el-table-column>
<!-- 字典列:code 自动转 tag -->
<el-table-column v-else-if="col.dictCode" v-bind="pickNativeProps(col)">
<template #default="{ row }">
<DictTag :code="row[col.prop]" :dict-code="col.dictCode" />
</template>
</el-table-column>
<!-- 格式化列:时间/金额 -->
<el-table-column v-else-if="col.format" v-bind="pickNativeProps(col)">
<template #default="{ row }">
{{ formatCell(row[col.prop], col.format) }}
</template>
</el-table-column>
<!-- 普通列:直接透传 -->
<el-table-column v-else v-bind="pickNativeProps(col)" />
</template>
<!-- 操作列不配置,全部交给页面插槽,因为操作按钮跟权限耦合太深 -->
<el-table-column v-if="$slots.operation" label="操作" fixed="right" :width="operationWidth">
<template #default="scope">
<slot name="operation" :row="scope.row" :index="scope.$index" />
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pagination.pageNum"
v-model:page-size="pagination.pageSize"
:total="total"
:page-sizes="pageSizes"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadData"
@size-change="onSizeChange"
/>
</div>
</template>
模板里有三个设计决策值得展开:
操作列为什么不做进 columns 配置? 早期版本我们把操作按钮也做成了配置项(actions: [{ label: '编辑', event: 'edit', permission: 'user:edit' }]),用了一阵子发现是败笔:操作按钮的形态太多了——有的要 v-permission、有的要按行状态禁用、有的要下拉收纳、有的编辑按钮在不同状态下文案不同。配置表达不了这种复杂度,最后配置项膨胀到七八个字段,还不如直接写插槽。所以最终版操作列就是一个固定的具名插槽 #operation,页面自己写按钮,组件只负责布局。通用组件要学会认怂:不该管的别管。
序号列的跨页连续。 用 el-table 自带的 type="index",每一页都从 1 开始。但业务方(尤其是财务类系统)经常要"全表连续序号",所以上面用 :index 函数手动算了偏移量。一行代码的事,但没有的话每次都要被问一遍。
字典列和格式化列直接吃掉两个高频场景。 状态列和时间列是列表页出现频率最高的两类"需要渲染逻辑"的列。dictCode 接入字典系统后 code 自动转 label + tag 颜色,format 处理时间戳和分转元。这两项做完,真正需要写插槽的列从"每页五六个"降到"每页一两个"。
3.4 查询参数变化时要不要自动查?
一个容易被忽略的细节:queryParams 是响应式的,页面改了查询条件(比如重置按钮把参数清空了),ProTable 要不要自动感知并重新加载?
// 监听查询参数变化,防抖后自动查询
watch(
() => props.queryParams,
() => {
debounce(reload, 300)
},
{ deep: true },
)
我们的答案是"要,但要防抖"。理由:搜索表单组件在"重置"时会逐个字段恢复默认值,深拷贝的对象会触发好几次 watch,没有防抖就会连发好几个请求。加上 300ms 防抖后,一次重置只发一次请求。
不过这里有个取舍要提醒:如果你的页面查询参数里包含"翻页时也保持不变"的字段,auto-watch 没问题;但如果有人在 queryParams 里放了会被表格自身修改的字段(极少见但出现过),就会死循环。所以更稳妥的姿势是 auto-watch 做成可关闭的 prop,默认开。
四、页面侧的使用效果
组件写完,看看页面侧变成什么样。下面是一个真实的用户管理列表页,模板部分:
<template>
<ProTable
ref="tableRef"
:columns="columns"
:request="getUserPage"
:query-params="queryParams"
selection
index
@selection-change="onSelectionChange"
>
<template #status="{ row }">
<el-switch v-model="row.status" @change="toggleStatus(row)" />
</template>
<template #operation="{ row }">
<el-button v-permission="'system:user:edit'" link type="primary" @click="openEdit(row)">
编辑
</el-button>
<el-button v-permission="'system:user:resetPwd'" link type="warning" @click="resetPwd(row)">
重置密码
</el-button>
<el-button v-permission="'system:user:remove'" link type="danger" @click="remove(row)">
删除
</el-button>
</template>
</ProTable>
</template>
<script setup lang="ts">
const queryParams = reactive({ userName: '', phone: '', status: undefined })
const columns: ProColumn<SysUser>[] = [
{ prop: 'userName', label: '用户名', minWidth: 120 },
{ prop: 'nickName', label: '昵称', minWidth: 120 },
{ prop: 'phone', label: '手机号', minWidth: 130 },
{ prop: 'deptName', label: '部门', minWidth: 140 },
{ prop: 'status', label: '状态', slot: 'status', width: 90 },
{ prop: 'roleName', label: '角色', dictCode: 'role_type', width: 110 },
{ prop: 'createTime', label: '创建时间', format: 'datetime', width: 170 },
]
// 请求函数:页面自己处理接口细节
async function getUserPage(params: any) {
const res = await api.user.page(params)
return { list: res.records, total: res.total }
}
// 删除成功后保持当前页刷新
async function remove(row: SysUser) {
await ElMessageBox.confirm(`确认删除用户「${row.userName}」?`)
await api.user.remove(row.id)
tableRef.value?.refresh()
ElMessage.success('删除成功')
}
</script>
数一下模板的行数:16 行。跟开头那个 280 行的页面比,列定义是纯数据(可以直接由后端"列配置接口"下发,做动态列),业务逻辑集中在三个操作函数里。新同事上手这种页面,需要理解的"框架代码"趋近于零。
五、TypeScript 泛型:让 row 不再是 any
<script setup generic="T"> 这个特性值得单独拎出来讲,因为它是"封装组件体验"的分水岭。
不使用泛型时,插槽里的 row 是 any,页面里 row.userName 拼错了(比如写成 row.userNam)不会有任何提示,直到运行时渲染出空白才被发现。而 ProTable 的 request 函数返回 Promise<{ list: T[] }>,Vue 的类型推导会把 T 和页面传入的 getUserPage 关联起来:
<script setup lang="ts">
// 页面声明了请求返回类型
interface SysUser {
id: number
userName: string
phone: string
status: 0 | 1
createTime: string
}
// request 的返回类型是 { list: SysUser[]; total: number }
// ProTable 的泛型 T 被推导为 SysUser
const getUserPage: TableRequest<SysUser> = async (params) => { ... }
</script>
<template>
<!-- 这里 scope.row 是 SysUser 类型,拼错字段名直接红线 -->
<template #status="{ row }">
<el-switch v-model="row.status" />
</template>
</template>
有个前置条件容易卡住人:泛型推导依赖 IDE 的 Volar(Vue Language Features)版本,老版本的 volar 对 generic 支持不完整,会出现"类型推导不出来但不报错"的静默失效。如果发现插槽里 row 还是 any,先检查 Volar 版本,别急着怀疑代码。
另外注意一个细节:const tableData = ref<T[]>([]) as Ref<T[]> 这个看起来很怪的写法。因为 ref<T[]>([]) 在 TS 里会被收窄成 Ref<never[]>,后续赋值普通数组会报错,所以要用 Ref<T[]> 断言 widen 回去。这是 Vue 3 泛型组件里的已知小坑,第一次遇到基本都会懵一下。
六、再加一块拼图:跟搜索区联动
ProTable 只管表格,但列表页的另一半是搜索区。两个组件怎么配合?我们的做法是搜索区组件不直接操纵 ProTable,而是通过 queryParams 这个响应式对象间接联动:
┌──────────────────────────────────────────────┐
│ SearchForm(配置化搜索区) │
│ 双向绑定 queryParams 对象 │
└──────────────┬───────────────────────────────┘
│ queryParams 变化(300ms 防抖)
▼
┌──────────────────────────────────────────────┐
│ ProTable │
│ watch queryParams → reload()(回第一页) │
│ 分页条变化 → loadData()(保持条件) │
└──────────────────────────────────────────────┘
数据流是单向的:SearchForm 改对象 → ProTable 监听变化重新加载。ProTable 内部不会反向改 queryParams(分页参数在组件内部的 pagination 里,不混进 queryParams)。这个单向数据流让调试变得非常简单——出问题的时候,只需要在 watch 里打个断点看 queryParams 变成了什么,就能判断是搜索区的锅还是表格的锅。
有些实现会把搜索区和表格做成一个大组件(全家桶式),我们试过,最后拆开了。原因很实际:有些列表页的搜索区不是纯表单——比如订单列表的搜索区里嵌了一个"高级筛选"抽屉,还有导出按钮。搜索区一旦复杂起来,全家桶组件的插槽就不够用了。分成两个组件、用 queryParams 对象做桥梁,各自的复杂度互不传染。
七、上线之后又踩的两个坑
组件稳定运行了两个月,新问题是从测试同学的 bug 单里冒出来的。
坑一:内存里的选中项跨页丢失。 用户在第 1 页勾选了 3 条,翻到第 2 页又勾了 2 条,回头一看第 1 页的勾选没了——因为翻页后 tableData 换了一批新对象,el-table 的 selection 跟着重置。解法是开启 el-table 的 reserve-selection,并给 row-key 指定唯一键:
<el-table
:data="tableData"
row-key="id"
@selection-change="onSelectionChange"
@select-all="onSelectAll"
>
<el-table-column v-if="selection" type="selection" width="50" reserve-selection />
</el-table>
开了 reserve-selection 之后又衍生一个小问题:表格换查询条件时,之前跨页勾的选项还保留着,但业务上"换个查询条件,旧勾选应该清空"。所以 ProTable 在 reload 的时候要主动调 tableRef.clearSelection()。这两个 API 是配套的,只做一半必出 bug。
坑二:分页参数被页面"顺手"改了。 有个页面同事为了实现"导出全部",直接把 queryParams.pageSize 改成 9999 想一次性拿全量数据——但他改的是页面自己的 queryParams,而 ProTable 内部的 pagination 是独立的,根本不生效,导出还是只有 10 条。这个案例说明分页状态内聚在组件里是双刃剑:防住了页面乱改,但也确实没有给"临时改分页"留口子。最后我们给 ProTable 加了一个不进文档的 fetchAll 方法(内部临时用 pageSize=大值请求一次,不影响界面状态),才把这需求接住。教训是:内聚的状态要预留逃生门,哪怕一开始觉得用不上。
在收尾之前,还有两个容易被忽略的工程层面细节,我觉得值得单独说。
一个是表格大数据量的渲染。这套 ProTable 内部的 el-table 本身是不带虚拟滚动能力的,上千行数据一次排到 DOM 上,滚动起来能看到明显卡顿。遇到日志、流水这类动辄几万行的页面,多数时候靠把分页做好就够了;真的是万级以上的全量展示需求,通常得换 el-table-v2 这类基于虚拟滚动的表格来做。好在这时候我们这套 columns 配置的抽象不用推倒重来——从使用方视角看,配置化表格和虚拟化表格的差异主要在组件内部的渲染层,对外的列配置模型基本一致。这其实是配置化抽象的一个隐性红利:渲染实现可以替换,接口保持稳定,换内核不至于伤筋动骨。
另一个是透传带来的类型静默丢失。用 v-bind="pickNativeProps(col)" 把列配置展开到 el-table-column 上,写起来是痛快,但 TypeScript 对运行时展开的属性做不了精确检查——label 传错了拼写、prop 写成不存在的字段、把 format 塞给了根本不需要格式化的列,编译期都不会报错,要等渲染出来才发现。这是配置化路线的固有代价,灵活性和静态类型总在此消彼长的两端。我的处理是不追求完美类型,靠列配置的类型注释和 review 把这种风险压到低位,别为了一两个边缘例把配置类型复杂到没人看得懂。
八、收个尾
把这套 ProTable 的能力清单列一下,方便对照评估要不要自己做一套:
| 能力 | 实现方式 | 一句话说明 |
|---|---|---|
| columns 配置化 | ProColumn 类型 + v-bind 透传 | 列定义纯数据,可由后端下发 |
| 插槽扩展 | col.slot 转发具名插槽 | 20% 的自定义列保留完全自由 |
| 字典列 | dictCode + DictTag | 状态列零代码渲染 |
| 格式化列 | format 枚举 | 时间、金额高频场景内置 |
| 竞态防护 | 请求序号比对 | 快速翻页不串数据 |
| 刷新语义 | refresh(保页)/ reload(回首页) | 两种刷新两种语义 |
| 末页删除修正 | 空页自动回退 | 用户无感的体验细节 |
| 跨页勾选 | reserve-selection + row-key | 配套 clearSelection 才完整 |
| 泛型推导 | script setup generic | row 拼错字段直接红线 |
最后说点实在的。ProTable 这类组件,开源界有 vue-element-plus-admin、PureAdmin 等现成实现,功能比这篇讲的丰富得多。那我为什么还建议你花两三天自己封一遍?因为自己封的价值不在组件本身,而在于团队对"列配置"这套 DSL 的所有权。后端要加"列配置接口"支持动态列、产品要求搜索区支持"保存筛选方案"、测试提了"导出要跟列表勾选联动"——这些需求落在别人的组件上,每次都要读源码找扩展点;落在自己的组件上,半小时就改完了。封装成本三天,换未来两年对列表页的绝对掌控,这笔账怎么算都划算。
写完这篇我特意翻了下团队这几年的提交记录,发现一个有意思的现象:ProTable 落地后的头三个月,大家往里塞了七八个新能力(列设置、合计行、列拖拽宽度),之后半年一个都没加——因为该有的都有了,剩下的需求全被插槽消化了。一个通用组件的终态大概就是这样:80% 的场景走配置,20% 走插槽,然后不再生长。你们项目里的列表页现在是什么状态?如果还在复制粘贴模板,不妨数一下重合度,欢迎在评论区报个数——我赌超过 80%。