做后台管理系统这些年,只要和“报表”“台账”沾边的需求,几乎都绕不开ant-design-vue 1.x的Table表格组件合并单元格。尤其是老项目,v1.x用得还非常多,网上的教程要么是React版,要么是0.x时期的远古写法,要么直接甩一个2.x才有API,抄过来要么不生效,要么表格直接错位。这篇文章我就把1.x里合并单元格这件事讲透,从customRender的返回值格式、rowSpan和colSpan的坐标系原理,到数据驱动的动态合并算法,再到项目里真实踩过的坑,一次性写完,方便你直接抄去用。
1. 从业务场景到合并方案:先搞清楚要解决什么问题
1.1 后台表格里最常见的三类合并需求
先说业务。我见过的合并单元格需求基本可以归成三类。
第一类是“分组标题重复”型。最常见,比如销售数据表,同一省份下面有多个城市,每行都写“广东省”显得很冗余,产品就会说“能不能把省份合起来,只显示一个”。这类合并的规律是:某列的值连续出现N次,就把这N个单元格合并成1个,也就是跨行合并,对应API里的rowSpan。
第二类是“跨列汇总”型。比如一个表格里“第一季度”和“第二季度”是两个独立列,但某个统计行需要在下方跨过这两列写一个合计值,或者某一行要在几个字段列上合并展示一段说明文字。这类要横向合并,对应API里的colSpan。
第三类是“行列混合”型。比如上面的省份按行合并之后,某一行的备注还要横跨两列,行列同时生效。这是rowSpan和colSpan同时设置,也是最终写法和暴露问题最多的场景。
无论哪种类型,在1.x里最终都要落到同一个API上:columns自定义单元格渲染的customRender。1.x没有2.x那种独立的customCell钩子,所以理解customRender的返回值规则就是整个方案的地基。
1.2 为什么1.x的合并必须靠customRender
在ant-design-vue 1.x的Table实现里,单元格渲染是通知columns配置来驱动的。当某一列配置了customRender,渲染时就会调用这个函数,并且完全采用它的返回值作为这个td的“一切内容”。
这个“一切内容”不是你随便返回一个字符串就够了。如果你返回的是字符串或数字,Table底层会把它当作文本渲染,不会有任何合并效果。要想真正控制td的标签属性,必须返回一个固定格式的对象:
{ children: '单元格内容', props: { rowSpan: 2, colSpan: 1 } }children是td里装的内容,>可以是字符串、VNode、组件实例;props会被直接合并到最终的td标签上。Table源码在渲染td时会把props展开成rowspan、colspan这样的HTML属性,这就是合并单元格能生效的根本原因。
所以1.x合并单元格的完整思路就是:在需要合并的列上配一个customRender,计算出当前单元格应该占几行几列,然后返回这种结构化对象。计算逻辑写在customRender里面或者外部函数里都可以,关键是返回值格式不能错。
1.3 rowSpan和colSpan到底怎么计算位置
很多新手以为合并就是把rowSpan设大一点就完事了,实际上一合并就会错位,原因是没有理解表格栅格坐标系。
每个单元格在表格中都有一个“网格坐标”,由行和列共同决定。rowSpan就是当前单元格从自己所在的行开始,向下纵向占据多少个格子;colSpan则是从自己所在列开始,向右横向占据多少个格子。
这里最容易忽略的是:你让某个单元格向下合并了N行,它下面的N-1个同列单元格并没有消失,而是必须显式设置rowSpan为0。设置为0表示这个单元格“不占用高度”,被上方合并的格子覆盖。如果下面的单元格rowSpan不是0而是默认的1,表格实际宽度就会超出预期,视觉上所有列都会错位。
这也是我见过最多的问题:第一行设置rowSpan: 3,后面两行没设置,结果整张表全乱了。所以算合并的时候一定要把“发起合并的格子”和“被合并覆盖的格子”一起考虑进去。
2. 最小实现:手工合并一段静态表格
2.1 单列行合并的最小可运行代码
先给一套最简单、最容易理解的demo。假设有这样一个数据源:5行记录,前3行属于广东省,后2行属于浙江省。我想让省份列只显示一次“广东省”和一次“浙江省”,并且垂直居中。
<template> <a-table :columns="columns" :data-source="dataSource" :pagination="false" bordered /> </template> <script> export default { data() { return { dataSource: [ { key: '1', province: '广东省', city: '深圳市', sales: 100 }, { key: '2', province: '广东省', city: '广州市', sales: 200 }, { key: '3', province: '广东省', city: '珠海市', sales: 150 }, { key: '4', province: '浙江省', city: '杭州市', sales: 180 }, { key: '5', province: '浙江省', city: '宁波市', sales: 120 } ], columns: [ { title: '省份', dataIndex: 'province', customRender: (text, record, index) => { // 手工写死:第一行合并3行,第二、三行隐藏;第四行合并2行,第五行隐藏 const rowSpanMap = { 0: 3, 1: 0, 2: 0, 3: 2, 4: 0 }; return { children: text, props: { rowSpan: rowSpanMap[index] } }; } }, { title: '城市', dataIndex: 'city' }, { title: '销售额', dataIndex: 'sales' } ] }; } }; </script>这段代码效果就是省份列三行合一、两行合一。注意里面rowSpanMap的映射关系:0行是3,下面两行是0。如果你把1和2也写成默认1,表格就会错位。
这套手工写法适合数据完全固定的场景,但真实业务里数据基本是异步接口返回的,summarize不了,必须换成动态计算。
2.2 children和props缺一不可:返回值格式拆解
这里我多说几句返回值格式,因为1.x的customRender和0.x有一些细节差异,很多老教程的写法放到1.x会失效。
正确格式我已经写了:
customRender: (text, record, index) => { return { children: text, props: { rowSpan: 2 } }; }children如果不传,这个格子就是空的;props如果不带rowSpan或colSpan,和没写合并完全一样。这两者缺了任何一个都不行。
children还可以更灵活。比如你想在合并后的单元格里放一个按钮或者自定义样式内容,可以传一个VNode。在Vue 2里最常见写法是这样:
customRender: (text, record, index) => { const mergedText = this.$createElement('span', { style: { color: '#f50' } }, text); return { children: mergedText, props: { rowSpan: 2 } }; }不过这里有个细节要注意:如果customRender是普通函数,里面的this指向的是当前组件实例吗?在1.x中,columns配置在data里时,customRender的this不一定可靠。我的习惯是用箭头函数把它包在methods或computed里,或者直接在customRender外面预先准备好要渲染的数据和样式,避免依赖this。
还有一种写法是把children设成一个render函数,但实际测试下来VNode方式最稳,我推荐直接用this.$createElement或JSX。
2.3 跨列合并与“被隐藏单元格”的处理
跨列合并的套路和跨行合并是对称的。还是拿customRender来说,比如某一行需要把“城市”和“销售额”两列合并成一个大单元格,那么“城市”列的单元格设置colSpan: 2,而“销售额”列对应位置的单元格必须设置colSpan: 0。
const columns = [ { title: '省份', dataIndex: 'province' }, { title: '城市', dataIndex: 'city', customRender: (text, record, index) => { if (index === 3) { return { children: '杭州+180', props: { colSpan: 2 } }; } return { children: text, props: { colSpan: 1 } }; } }, { title: '销售额', dataIndex: 'sales', customRender: (text, record, index) => { if (index === 3) { return { children: '', props: { colSpan: 0 } }; } return { children: text, props: { colSpan: 1 } }; } } ];这样第4行就变成“浙江省 | 杭州+180”这样两个格子,而不是三个格子。这里特别容易踩坑:如果你的被覆盖单元格没有返回colSpan: 0,Table会继续渲染一列,合并区域会凭空多出一块空白格子,整个表格的边框和列宽都会乱。
而且要注意的是,跨列合并时被隐藏列的数据还在,只是因为colSpan: 0被覆盖了。你在选型和设计时要想清楚哪些列参与合并,哪些列被隐藏,不要等表格渲染完再调。
3. 数据驱动的动态合并:把算法封装成工具函数
3.1 相同值连续合并的核心算法
实际项目中,表格数据来自接口,相同值的行数不固定。比如今天广东省8条,明天可能5条。你需要一个算法自动算出每一行的rowSpan。
我的做法是写一个通用的分组函数,它接收数据源和需要合并的字段名,返回一个rowSpanMap:一个以行索引为key、rowSpan值为value的对象。
/** * 根据数据源和字段名,计算连续相同值的行合并映射 * 例如数据是 ['a','a','b','b','b'],返回 {0:2, 1:0, 2:3, 3:0, 4:0} */ function getRowSpanMap(data, field) { const rowSpanMap = {}; let start = 0; for (let i = 0; i < data.length; i++) { const current = data[i][field]; const next = i === data.length - 1 ? undefined : data[i + 1][field]; if (i === data.length - 1 || current !== next) { // 从 start 到 i 是一组相同值 for (let j = start; j <= i; j++) { rowSpanMap[j] = j === start ? i - start + 1 : 0; } start = i + 1; } } return rowSpanMap; }这个函数的时间复杂度是O(n),核心思路是扫描数据时找“边界”:当前行和下一行的字段值不同,说明一段连续相同值结束了,这一段中第一行设置合并行数,其余行全部设置0。
它的前提是相同值必须连续出现。如果数据是“广东省、浙江省、广东省”,三段之间相隔,算法会把两个广东省当成两次独立合并,不会把它们合成一个。实际业务如果允许相同值不连续,要么先排序,要么修改算法按唯一值合并,不过后者会导致内容上移,交互上其实并不自然,我建议还是保持这种“连续合并”的直觉。
3.2 多列同时合并与通用封装
有了rowSpanMap,接下来就是把它接进columns。我习惯把columns写成一个computed,因为这样它和数据源dataSource之间就有响应式依赖,数据一变,合并配置自动重算。
比如表格同时要对“省份”和“城市”两列做行合并:
computed: { columns() { const provinceRowSpanMap = getRowSpanMap(this.dataSource, 'province'); const cityRowSpanMap = getRowSpanMap(this.dataSource, 'city'); return [ { title: '省份', dataIndex: 'province', customRender: (text, record, index) => ({ children: text, props: { rowSpan: provinceRowSpanMap[index] === undefined ? 1 : provinceRowSpanMap[index] } }) }, { title: '城市', dataIndex: 'city', customRender: (text, record, index) => ({ children: text, props: { rowSpan: cityRowSpanMap[index] === undefined ? 1 : cityRowSpanMap[index] } }) }, { title: '销售额', dataIndex: 'sales' } ]; } }如果项目里很多页面都有合并需求,可以进一步把“给columns注入合并逻辑”抽成一个工具函数:
function createMergeColumns(columns, dataSource, mergeFields) { const rowSpanMaps = {}; mergeFields.forEach((field) => { rowSpanMaps[field] = getRowSpanMap(dataSource, field); }); return columns.map((col) => { if (mergeFields.includes(col.dataIndex)) { const field = col.dataIndex; return { ...col, customRender: (text, record, index) => { const rowSpan = rowSpanMaps[field][index]; return { children: text, props: { rowSpan: rowSpan === undefined ? 1 : rowSpan } }; } }; } return col; }); }调用的时候就非常清爽:
this.tableColumns = createMergeColumns(baseColumns, this.tableData, ['province', 'city']);这个封装我觉得是最值得抄的部分。它把列配置和合并算法解耦了,业务里只要维护一份基础列定义,再声明哪些字段要合并就行。后面要临时加一列“部门”合并,直接往mergeFields里塞一个字段名就够了。
3.3 分页、排序、筛选场景下的合并边界
动态合并有一个非常容易被忽略的边界条件——合并只对当前传给Table的dataSource有效。
如果你的表格是前端分页,比如每页10条,Table内部拿到的数据其实只是当前页的10条。你直接用这10条数据去算rowSpanMap,完全正确。但如果合并字段的值刚好跨页了(比如第10条和第11条是同一个省份),分页后它们会被拆成两页,分别显示两个合并单元格,这一点需要和产品说清楚,不是bug,而是分页的天然逻辑。
如果是后端分页,数据源每次只来一页,合并逻辑同样基于当前页数据,没有问题。但后端返回的数据如果已经做了分组或者排序,算法会自动生成正确的合并结果。
排序和筛选要特别留意。排序会改变行顺序,相同值的连续性可能被打破;筛选同理。所以在做排序、筛选时,一定要让columns重新基于当前dataSource计算。用computed是最稳的,因为computed会随着dataSource变化重新执行。如果你把columns写死成一个静态数组,数据变合并不变,就会出现两个“广东省”分开显示的诡异效果。
4. 实战中绕不开的坑:样式、响应式与性能
4.1 合并后边框错位:样式修复方案
合并单元格之后,最直接的视觉问题就是边框。
ant-design-vue 1.x的Table如果加bordered属性,会给每个td都画边框。但合并之后,被覆盖的td实际没有被渲染出来,某些边界线就会缺失或者变粗。比如rowSpan合并后,右边界或底边界的线经常断掉,看起来像表格被切了一刀。
我的处理方式有几个。
第一,合并的列尽量不要用bordered,自己通过className控制边框。比如:
.merge-table td { border: 1px solid #e8e8e8; border-right: none; } .merge-table td:last-child { border-right: 1px solid #e8e8e8; }这种完全手绘边框的方式最稳定,缺点是需要自己照顾表头和其他列的边框线。
第二,如果你不想放弃bordered自带的效果,可以在customRender里给被合并的第一行单元格加一个class,用它来补线:
customRender: (text, record, index) => { const rowSpan = rowSpanMap[index] || 1; return { children: text, props: { rowSpan, className: rowSpan > 1 ? 'merged-cell' : '' } }; }然后写样式把缺口补上:
.merged-cell { border-right: 1px solid #e8e8e8; border-bottom: 1px solid #e8e8e8; }其实不同项目的UI风格不一样,框线样式很难一套代码通吃。我的建议是,在做合并需求时提前和UI确认好边框规范,然后把它写进全局样式文件,别每次都在组件里临时补。
4.2 columns更新不生效:响应式陷阱
我在实际项目里遇到最多的问题是:数据变了,合并也变了,但是表格死活不刷新。
原因大多数是columns被定义成了静态的data属性,里面闭包的rowSpanMap是旧值。即使dataSource更新了,customRender拿到的还是上一次执行时的老映射。
解决这个问题的标准姿势就是前面写的:把columns做成computed。computed依赖this.dataSource,dataSource一变,getRowSpanMap重新执行,columns生成新数组,Table响应式更新。
但有的时候,因为v1.x的Table内部对columns做了缓存,computed返回的新数组也不一定100%被识别。这时候可以用watch兜底,强制触发一次更新:
watch: { dataSource: { handler() { this.$nextTick(() => { this.forceUpdateTable(); }); }, deep: true } } methods: { forceUpdateTable() { this.tableKey += 1; } }模板里给Table加一个关键的key:
<a-table :key="tableKey" :columns="columns" :data-source="dataSource" />key一变,整个Table重新创建,虽然听起来有点粗暴,但在1.x老版本里这是最保险的强制刷新手段,适用于线上排查实在找不到原因的时候。
4.3 固定列和合并的兼容性处理
固定列是另一个经典冲突。在1.x的Table里,fixed: 'left'或're'的列,渲染机制是把整个表格切成“固定区”和“滚动区”,两个区域各自渲染一份单元格。如果你在固定列上又做了rowSpan合并,渲染不冲突,但视觉上会出现合并单元格对不齐的问题,尤其滚动时更明显。
我的经验是三层递进处理。
第一层:能不把固定列和合并列重叠就别重叠。左列固定“省份”,而“省份”又要合并,就很容易错位。这时可以把合并这个动作放到非固定列上,或者干脆把固定列改成普通列。
第二层:如果业务实在要求固定列上合并,可以关闭固定列的fixed效果,只保留表格整体scroll.x滚动,虽然表头和内容会跟随水平滚动,但至少合并展示是正常的。
第三层:如果必须固定且必须合并,可以用多个Table嵌套组合来实现,比如左边用一个独立Table画固定列,右边再用另一个Table画滚动区域,本质上就是手动拆分表格,复杂度很高,我一般不推荐。
4.4 性能优化:避免每次render都重复计算
customRender在表格每次渲染时都会被调用。如果表格行数几百上千,且每行都调用getRowSpanMap做一次全量遍历,性能会很糟糕。
优化方向是把rowSpanMap的计算结果缓存起来。用computed天然就有缓存:
computed: { rowSpanMap() { return getRowSpanMap(this.dataSource, 'province'); }, columns() { return [ { title: '省份', dataIndex: 'province', customRender: (text, record, index) => ({ children: text, props: { rowSpan: this.rowSpanMap[index] } }) } // ... ]; } }computed依赖dataSource,只有dataSource变化时才会重新计算rowSpanMap。平时render只是读取map[index],开销极小。
如果数据量真的很大(比如超过2000行),前端表格本身就该考虑虚拟滚动或者后端聚合了。不要把上千行的合并计算全部丢给前端,否则即使算法是O(n),Table重新渲染的DOM数量也会把页面拖垮。我个人经验是超过500行就建议后端先做分组和合并处理,前端只负责渲染后端返回的合并信息,比如后端给每个单元格返回rowspan值,前端直接透传。
5. 一些想分享的沉淀
在做这个功能过程中,我最大的收获是理解了1.x这套设计的取舍。它和2.x的customCell相比确实绕,一旦弄懂customRender返回值,合并就只是一个算法问题,而不是API查找问题。
最后分享两个实用小技巧。
第一个:给合并单元格做点击事件时,不要直接监听td的点击,因为td可能被rowSpan覆盖或者colSpan隐藏,点击区域不可控。我的做法是在children里塞一个自定义组件或者span,把事件绑在内容节点上,这样即使单元格合并,事件触发依然在预期区域。
第二个:如果合并后的内容很长,需要给合并单元格设置垂直居中或者固定宽度的样式,试试直接在children里包一层div,并设置min-height或line-height。有的人喜欢在props里塞style,但1.x对props里的style处理有时候不稳定,包裹div反而是最省心的。
这套方案我在多个老项目里已经稳定跑了一阵子,从最初的静态写死到后来的工具函数封装,直接提升了不少开发效率。如果你也是在维护ant-design-vue 1.x的老系统,建议把合并逻辑收敛成一个独立工具模块,不要再在页面里复制粘贴customRender代码。后面遇到跨页汇总、混合合并这些复杂场景,改动成本都会小很多。