news 2026/10/15 16:25:55

ant-design-vue 1.x Table合并单元格完全指南:customRender与rowSpan/colSpan实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ant-design-vue 1.x Table合并单元格完全指南:customRender与rowSpan/colSpan实战

做后台管理系统这些年,只要和“报表”“台账”沾边的需求,几乎都绕不开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代码。后面遇到跨页汇总、混合合并这些复杂场景,改动成本都会小很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 16:25:40

STM32F103C8T6模拟I2C驱动SSD1306 OLED:从原理到避坑实战

简介&#xff1a;这套针对 STM32F103C8T6 的 OLED 模拟 I2C 显示例程&#xff0c;主要面向嵌入式初学者和单片机开发者&#xff0c;演示如何使用 HAL 库通过 GPIO 引脚模拟 I2C 时序&#xff0c;驱动 0.96 英寸 OLED 显示屏。相比占用硬件 I2C 外设的方案&#xff0c;模拟方式更…

作者头像 李华
网站建设 2026/10/15 16:25:10

Pycopy:存储优先的Python方言,让嵌入式与云端部署更轻量

简介&#xff1a;Pycopy是一个极简、轻量且可高度扩展的Python方言实现&#xff0c;源自MicroPython&#xff0c;面向桌面端、云端与微控制器等各类受限环境。该源码压缩包适合嵌入式及物联网开发者&#xff0c;也适合想理解精简解释器设计的程序员。项目实现了完整的Python 3.…

作者头像 李华
网站建设 2026/10/15 16:25:10

RoboMaster硬件体系入门:供电、通信与执行器全链路解析

1. 从零理解RoboMaster硬件体系&#xff1a;这份讲义到底在讲什么如果你刚加入学校的机器人战队&#xff0c;或者正准备参加RoboMaster这类对抗性机器人竞赛&#xff0c;大概率会遇到一个很现实的问题&#xff1a;软件组的同学在群里催着要CAN通信协议&#xff0c;视觉组的同学…

作者头像 李华
网站建设 2026/10/15 16:24:41

金秋螃蟹上市季!看看国标GB28181视频平台EasyGBS是怎么管控塘口的

九月下旬到十一月&#xff0c;是蟹塘一年里最要紧的两个月&#xff1a;起捕、分拣、暂养、发货挤在一起&#xff0c;人手连轴转&#xff0c;塘口却比任何时候都更“空”——人都在包装车间&#xff0c;塘里没人看。本文按养殖现场的实际节奏&#xff0c;拆解国标GB28181视频平台…

作者头像 李华
网站建设 2026/10/15 16:23:58

ng-redux异步数据加载完全教程:redux-thunk与AngularJS $http实战指南

【免费下载链接】ng-redux Angular bindings for Redux 项目地址&#xff1a; https://gitcode.com/gh_mirrors/ng/ng-redux 点击查看 免费下载 在 AngularJS 项目里做 Redux 异步数据加载&#xff0c;ng-redux 是官方推荐的绑定方案。本教程基于仓库内置的 Reddit 新闻加载示…

作者头像 李华