中后台系统里最重复的劳动就是写列表页:搜索表单、表格、分页、操作列、权限按钮,每个页面都长一样又都不一样。摩杰斯 PMS5 的前端(Vben3 + Ant Design Vue + Vxe-Table)有几十个业务列表页,全部走 useVbenVxeGrid 一个封装。这篇文章讲两件事:怎么用 adapter 把表格配置全局收敛到一处,以及落地过程中踩过的七个坑------每一个都伴随白屏、静默失效或整批页面返工。代码均为示意,非生产代码。

一、全局收敛:配置只写在一个文件里
Vben3 的 setupVbenVxeTable 允许在 adapter 层给所有 vxe-grid 设默认值。我们的 adapter/vxe-table.ts 核心是这样一段:
ts
setupVbenVxeTable({
configVxeTable: (vxeUI) => {
vxeUI.setConfig({
grid: {
align: 'center',
size: 'small',
round: true,
minHeight: 180, // 全局最小高度,页面级禁止再设
showOverflow: true,
columnConfig: { resizable: true },
formConfig: { enabled: false }, // 关掉 vxe 自带表单,统一用 formOptions
proxyConfig: {
autoLoad: true,
response: {
result: 'items', // 后端分页结构 {items, total}
total: 'total',
list: 'items',
},
},
},
});
// 全局格式化器:列上直接写 formatter: 'date' / 'datetime' / 'currency'
vxeUI.formats.add('date', { cellFormatMethod({ cellValue }) { return formatDate(cellValue); } });
// currency 输出 ¥xx.xx;datetime 处理后端 UTC 时间戳到本地时区
},
useVbenForm,
});
收敛的价值在半年后才显出来:后端分页结构调整、时区格式修正、行高统一,都是改这一个文件全站生效。反过来,如果允许页面级写 height、写分页映射,几十个页面很快就会各长各样。所以我们有一条硬规矩:页面级禁止覆盖 minHeight/height 等全局配置,页面只写自己独有的东西------列定义、插槽、事件。
二、列表页基准样板
收敛之后,一个标准列表页的骨架固定成这样:
vue
<template>
<Page>
<Grid table-title="工单管理">
<template #toolbar-tools>
<a-button v-access:code="machining:work-order:create" type="primary">
<template #icon><PlusOutlined /></template>
新增
</a-button>
</template>
<template #actionSlot="{ row }">
<TableActions :row="row" :max-visible="3" />
</template>
</Grid>
</Page>
</template>
<script setup lang="ts">
defineOptions({ name: 'WorkOrderList' }); // 与路由名一致,KeepAlive 依赖它
const [Grid] = useVbenVxeGrid({
formOptions: { schema: searchSchema, submitOnChange: true, collapsed: true },
gridOptions: {
columns: [
{ type: 'checkbox', width: 50, fixed: 'left' }, // 复选框永远是第一列
{ field: 'code', title: '单号' },
{ field: 'status', title: '状态', slots: { default: 'statusSlot' } },
{ field: 'created_at', title: '创建时间', formatter: 'datetime' },
{ field: 'action', title: '操作', fixed: 'right', slots: { default: 'actionSlot' } },
],
},
});
</script>
样板定死后,新列表页的边际成本降到只写业务差异:搜索条件、列、操作项。操作列统一走 TableActions 组件------超过上限的操作收进「更多」下拉,hidden 函数里按状态加权限码控制显隐,危险操作标 danger。按钮显隐全部用 v-access:code 权限码,不按角色名判断。
三、坑 1:网格实例初始值是 {},渲染期调用必白屏
这是最疼的一个坑。Vben3 的 useVbenVxeGrid 返回的实例对象 api.grid,初始值是空对象 {},不是 null。
后果是:api.grid?.getCheckboxRecords() 里的可选链永远不会短路------{} 是真值。于是这段看似防御性十足的代码,会在组件挂载前(渲染阶段)真实调用一个不存在的方法,抛 TypeError,整页白屏:
ts
// ❌ 看似安全,实际必崩(computed 在渲染期求值,命中挂载前状态)
const checked = computed(() => detailGridApi.grid?.getCheckboxRecords() ?? []);
修法分两步。渲染期需要的状态,让事件回调写进普通 ref,computed 只基于 ref 计算;必须在事件回调里调网格方法时,用函数存在性做双重守卫:
ts
const grid = detailGridApi.grid as any;
if (typeof grid?.getCheckboxRecords === 'function') {
const records = grid.getCheckboxRecords();
}
另一个时序细节:异步加载数据后要恢复勾选,必须先 await grid.reloadData(rows) 再 setCheckboxRow。reloadData 是异步的,不等待就设勾选,行还没进去,勾选会静默丢失。
四、坑 2:网格事件只能走顶层 gridEvents
给表格绑勾选事件,直觉写法是在组件标签上 @checkbox-change:
vue
<!-- ❌ 静默失效:不报错,但回调永远不会执行 -->
<DetailGrid @checkbox-change="onChange" @checkbox-all="onChange" />
不报错、不触发,这种问题上线前很难发现。根源在 Vben3 内部用 v-on="events" 绑事件,events 只从 useVbenVxeGrid 的顶层 gridEvents 读取------与 gridOptions 平级的那个 key。塞进 gridOptions 内部同样无效。
ts
// ✅ 顶层 key,与 gridOptions 平级
const [DetailGrid, detailGridApi] = useVbenVxeGrid({
gridEvents: { checkboxChange: onChange, checkboxAll: onChange },
gridOptions: { checkboxConfig: { highlight: true, range: true }, columns: [...] },
});
排查这类「回调不执行」问题时别猜,在回调里临时 console.log 一个唯一标记,真机操作一遍看日志出不出来。
五、坑 3:DatePicker 缺 valueFormat,页面「假死」
后端返回的日期是字符串,Ant Design Vue 的 DatePicker 缺省却期望 dayjs 对象。字符串塞进去,watcher 里抛错,中断了 Vue 的调度刷新------表象很有迷惑性:URL 变了,页面内容却不刷新,返回按钮点了没反应,像「假死」。
修法一行:所有表单里的 DatePicker 显式声明格式。
ts
{ field: 'date_from', component: 'DatePicker',
componentProps: { valueFormat: 'YYYY-MM-DD' } }
这个坑的教训在于它的表象(路由变了内容不变)和病根(表单组件序列化格式)离得很远,按表象去查路由和 KeepAlive 会白耗半天。凡是「URL 变了内容不刷新」,先开 console 看有没有静默的 watcher 报错。
六、坑 4:布局层双层 overflow:hidden,列表页不可滚动
某次改版后所有列表页超高内容滚不下去,分页器直接被裁掉。排查发现是两层 overflow:hidden 叠加:业务壳层 .app-shell 设了 height:100vh; overflow:hidden,Vben 的布局组件主内容容器也硬编码了 overflow:hidden------整个应用不存在任何可滚动容器。
修复是全局一处 CSS,让主内容容器恢复页面级滚动,同时表格不再设内部固定高度:
css
/* layouts/basic.vue */
.app-body :deep(.flex-col.flex-1.overflow-hidden) {
overflow-y: auto;
}
这类问题属于「系统性根因」:一处布局错误,全站列表页一起出症状。逐页打补丁是错误方向,先找到共同的滚动容器链再动手。
七、坑 5 与坑 6:fixed 列的影子 DOM 与两种二次确认
坑 5:fixed 列渲染在独立容器里。 vxe-table 的左固定/右固定列渲染在 .vxe-table--fixed-left-wrapper / --fixed-right-wrapper 独立容器中,DOM 里同一行出现多份影子。写自动化测试按行序号取按钮,会取到影子行里的元素或干脆取不到------操作列在 fixed:'right' 里,定位就必须进对应的 fixed wrapper 容器。对人工开发影响不大,对 E2E 测试是必踩的坑。
坑 6:两种二次确认是不同组件。 行操作里的轻确认是 antd 的 Popconfirm(气泡),高危操作的重确认是 Modal.confirm(对话框)。自动化测试里用同一套「点确认按钮」逻辑会在其中一种上失效,因为两者的 DOM 结构和出现时机完全不同。做功能时同样要分清:删除类操作走 Modal,撤销、提交类走 Popconfirm,交互重量要和操作风险匹配。
八、坑 7:一次失败两条提示
表单提交失败时,HTTP 拦截器和页面 catch 各弹了一次错误 toast,用户连看两条一样的报错。这不是 Vben 的 bug,是业务代码和框架层对「谁负责提示」没有约定。约定只有一条:拦截器统一弹错误提示,业务代码 catch 后不再弹;反过来业务代码要自定义提示时,给请求标记「静默」,拦截器让路。重复提示这种小毛病不疼,但它暴露的问题是分层职责没有收口------和第一节说的表格配置收敛是同一件事。
九、收尾
回头看,这七个坑里三个(实例空对象、事件绑定位置、valueFormat)是 Vben3 封装层的隐式约定,两个(影子 DOM、二次确认组件)是 vxe/antd 的实现细节,两个(滚动、重复提示)是我们自己的分层问题。它们的共同点是:报错信息都指向不了病根------白屏的栈在渲染层,假死的表象在路由层。中后台封装做深了之后,调试能力比编码能力更决定效率,而「真机复现 + 看完整报错」始终比对着表象猜快得多。
这套 adapter 收敛加基准样板的打法,让几十个业务列表页保持在同一套交互与视觉基线上。Vben3 的隐式约定不止这七个,剩下的大多能从源码里读出来------封装层的源码不长,值得通读一遍,比踩坑便宜。
FAQ
Q1:为什么选 useVbenVxeGrid 而不是直接用 vxe-grid?
直接用 vxe-grid 意味着每个页面自己管分页代理、搜索表单、工具栏布局,页面间一致性靠自觉。useVbenVxeGrid 把 proxyConfig、formOptions、工具栏插槽收进一个入口,配合 adapter 全局默认值,页面只声明差异。业务列表页越多,这套收敛省下的越多。
Q2:页面级真的不能覆盖 minHeight 吗?有没有例外?
有例外场景(大屏看板类页面确实需要满屏高度),但例外要显式写出来并在代码评审里说明,而不是顺手覆盖。规矩的意义不是禁止调整,是让每一处偏离都有人知道。
Q3:怎么快速判断问题是封装层的还是自己代码的?
看报错能不能自洽。白屏的调用栈落在 packages/effects/plugins 里、且涉及 api.grid,基本就是坑 1 那类隐式约定;事件不触发先确认写的是不是顶层 gridEvents;都排除了再怀疑自己的逻辑。Vben3 的 use-vxe-grid.vue 和 api.ts 源码加起来几百行,通读一遍能排掉大半疑案。