做后端管理系统时,只要涉及到报表、明细清单、分组统计这类页面,基本躲不开一个需求:把相同内容的单元格合并起来。用 View UI(大家更习惯叫 iView)的 Table 组件时,合并行和列的核心就是 span-method 这个方法。这篇文章我把自己在实际项目里用 View UI 做行列合并的完整思路、代码和踩过的坑整理出来,给正在被这个功能折磨的同学一个可以直接抄的作业。
先说明一点,我下面的例子基于 View UI 3.x(Vue 2 版本,包名是 view-design)。如果你用的是 View UI 4.x 或 Vue 3 版本,API 基本一致,span-method 的用法没有任何变化,稍微改下 import 路径就能跑起来。为了避免版本问题影响理解,我尽量不写和安装有关的废话,重点全部放在合并逻辑本身。
1. 环境准备与组件基础
1.1 先确认你用的哪个版本
View UI 这名字其实走过一段曲折路线。最早大家认识的叫 iView,后来 3.x 版本改名为 View UI,包名变成 view-design;再往后 4.x 版本又是基于 Vue 3 的 View UI Plus。很多老项目现在还在用 2.x 或 3.x,新项目可能已经用 Plus 了。好在 Table 组件的合并 API 一路沿用下来,核心方法没有破坏性变更,所以这篇教程覆盖上面所有版本。
引入方式大概是这个样子:
// Vue 2 + View UI 3.x import ViewUI from 'view-design'; import 'view-design/dist/styles/iview.css'; Vue.use(ViewUI); // Vue 3 + View UI Plus import { createApp } from 'vue'; import ViewUIPlus from 'view-ui-plus'; import 'view-ui-plus/dist/styles/viewuiplus.css'; const app = createApp(App); app.use(ViewUIPlus);1.2 表格的最小可用结构
在讲合并之前,先搭一个最基础的表格出来。Table 组件在 View UI 里用 columns 定义列配置,用 data 传入数据:
<template> <Table :columns="columns" :data="tableData" border /> </template> <script> export default { data() { return { columns: [ { title: '姓名', key: 'name' }, { title: '班级', key: 'className' }, { title: '科目', key: 'subject' }, { title: '成绩', key: 'score' } ], tableData: [ { name: '张三', className: '一班', subject: '语文', score: 90 }, { name: '张三', className: '一班', subject: '数学', score: 92 }, { name: '李四', className: '二班', subject: '语文', score: 88 } ] }; } }; </script>columns 里的每一项对应一列,title 是表头文字,key 是数据字段名。data 里的每个对象对应表格的一行,这个结构应该不陌生。所有合并操作,其实都是在这两层配置的渲染过程中插入“干涉逻辑”,也就是在 span-method 里告诉表格:某个单元格要占几行几列。
注意:span-method 是 Table 组件对外暴露的合并钩子,它不改变 columns 和 data 本身,只是在渲染阶段动态计算单元格的跨度。所以合并逻辑写得再复杂,原始数据始终是干净的,这很重要。
2. 合并玩法与 span-method 到底怎么工作
2.1 返回值就是合并的“地图”
span-method 是一个函数,组件在渲染每个单元格时会带着四个参数来调用它:row(当前行数据)、column(当前列配置)、rowIndex(行索引)、columnIndex(列索引)。函数需要返回一个包含 rowspan 和 colspan 两个属性的对象。
对不合并的单元格,默认就是{ rowspan: 1, colspan: 1 },表示这个格子只占一行一列。当你想让某个单元格跨多行,就把 rowspan 设成跨的行数;想跨多列,就把 colspan 设成跨的列数。
还有一个非常关键又容易忽略的返回值:{ rowspan: 0, colspan: 0 }。这个组合的意思是“这个格子被合并掉了,不要渲染”。比如 A 单元格跨两行,那第二行里原本 A 所在位置的那个格子就要被“吃掉”,必须返回 0,否则表格会出现错位。
2.2 先拿一个最简单的手写合并做测试
不依赖任何分组算法,直接写死返回值,先感受一下合并机制:
<template> <Table :columns="columns" :data="tableData" :span-method="handleSpan" border /> </template> <script> export default { data() { return { columns: [ { title: '姓名', key: 'name' }, { title: '班级', key: 'className' }, { title: '科目', key: 'subject' }, { title: '成绩', key: 'score' } ], tableData: [ { name: '张三', className: '一班', subject: '语文', score: 90 }, { name: '张三', className: '一班', subject: '数学', score: 92 }, { name: '李四', className: '二班', subject: '语文', score: 88 } ] }; }, methods: { handleSpan({ row, column, rowIndex, columnIndex }) { // 第 1 列(姓名列)第 0 行,跨 2 行占 1 列 if (columnIndex === 0 && rowIndex === 0) { return { rowspan: 2, colspan: 1 }; } // 第 1 列第 1 行,这个格子被合并掉 if (columnIndex === 0 && rowIndex === 1) { return { rowspan: 0, colspan: 0 }; } } } }; </script>这样写死之后,张三的名字在表格里就只会显示一次,延伸到第二行。虽然看起来很傻,但你已经能理解合并的本质了:你在用行列索引精确控制每个格子的占位方式。
2.3 列合并的简单写法
如果把 rowspan 换成 colspan,就变成横向合并。比如让某个单元格吃掉右边两个格子:
handleSpan({ columnIndex, rowIndex }) { if (rowIndex === 0 && columnIndex === 1) { return { rowspan: 1, colspan: 2 }; } if (rowIndex === 0 && columnIndex === 2) { return { rowspan: 0, colspan: 0 }; } }这是给第一行的第二列横向跨到第三列,第三列的那个格子就会被合并掉。列合并用的场景比行合并少,但偶尔做表头特殊展示、汇总行时也能派上用场。
3. 行合并完整实操:报表场景
3.1 合并前先想清楚分组规则
真实项目里不可能手动写死每个格子的跨度,数据是动态的,合并规则也应该是动态的。以最常见的“分组报表”为例:一个学生有多科成绩,需要把相同学生的姓名单元格合并成一个,让表格看起来更清爽。
第一步不是写代码,而是想清楚“按什么字段合并”。拿前面那个表来说,姓名列要跨的行数,取决于同一个 name 连续出现了多少次。这里注意“连续”两个字,这很重要。
数据源理论上应该已经按合并字段排好序,相同值排在一起。如果源数据是乱的,合并算法会把不相邻的同类数据也当成两组,导致出现两片孤立的合并区域,看起来非常难受。
3.2 计算每个单元格应该占几行
先写一个通用方法,传入数据数组和要合并的字段名,返回一个跟行数等长的数组。数组里每个值表示当前行这个位置应该渲染的跨度,0 表示被合并掉:
methods: { calculateSpan(data, key) { const spanArr = []; const mergeIndexArr = []; for (let i = 0; i < data.length; i++) { if (i === 0) { // 第一行肯定是一个分组的起点,先占 1 行 spanArr.push(1); mergeIndexArr.push(0); } else { // 和上一行同一个 key,说明还是在同一组里 if (data[i][key] === data[i - 1][key]) { // 分组起点那个格子需要再多占一行 spanArr[mergeIndexArr[mergeIndexArr.length - 1]]++; // 当前行是多余的,标记为 0 spanArr.push(0); // 当前行从属于前面的分组起点 mergeIndexArr.push(mergeIndexArr[mergeIndexArr.length - 1]); } else { // 新的一组来了,起点是当前行 spanArr.push(1); mergeIndexArr.push(i); } } } return spanArr; } }这里我解释下逻辑。spanArr 里存的是最终每个格子的行跨度,mergeIndexArr 里存的是“当前行归属的分组起点索引”。比如数据里有连续三行年级相同,第一次遇到这个年级时,spanArr 压入 1,后面每次再遇到相同年级,就把起点那个数字往上加,同时给当前行压入 0。这样到最后,起点行的跨度就是 3,后面两行的跨度都是 0。
这个方法可以反复调用,所以我在 mounted 里根据需要的字段把结果算好存起来:
mounted() { this.spanArr = this.calculateSpan(this.tableData, 'name'); }3.3 在 span-method 里消费合并数据
现在把算好的 spanArr 挂到渲染逻辑上。注意 span-method 里拿到的 rowIndex 就是 data 数组的下标,所以直接用相同下标去取 spanArr 里的值就行:
handleSpan({ columnIndex, rowIndex }) { // 只合并第 0 列(姓名列) if (columnIndex === 0) { const num = this.spanArr[rowIndex]; return { rowspan: num, colspan: num > 0 ? 1 : 0 }; } }当 num 大于 0 时,这个格子正常显示且跨 num 行;当 num 等于 0 时,rowspan 为 0、colspan 为 0,这个格子被完全吞掉。这样姓名列就实现了自动化的行合并。
3.4 多列同时合并时怎么办
很多时候不止一列要合并。比如除了姓名要跨行,班级也要按相同值跨行。这种场景只需要给每一个需要合并的列单独算一份 spanArr,然后在 span-method 里根据 columnIndex 分发即可。
为了演示完整,我把表格数据调整成更贴近真实的报表:
data() { return { columns: [ { title: '姓名', key: 'name', width: 120 }, { title: '班级', key: 'className', width: 120 }, { title: '科目', key: 'subject' }, { title: '成绩', key: 'score' }, { title: '年级', key: 'grade', width: 100 } ], tableData: [ { name: '张三', className: '一班', subject: '语文', score: 90, grade: '2023级' }, { name: '张三', className: '一班', subject: '数学', score: 92, grade: '2023级' }, { name: '张三', className: '一班', subject: '英语', score: 85, grade: '2023级' }, { name: '李四', className: '二班', subject: '语文', score: 88, grade: '2023级' }, { name: '李四', className: '二班', subject: '数学', score: 76, grade: '2023级' }, { name: '王五', className: '三班', subject: '语文', score: 95, grade: '2024级' } ] }; }, mounted() { this.nameSpanArr = this.calculateSpan(this.tableData, 'name'); this.classSpanArr = this.calculateSpan(this.tableData, 'className'); this.gradeSpanArr = this.calculateSpan(this.tableData, 'grade'); }, methods: { handleSpan({ columnIndex, rowIndex }) { if (columnIndex === 0) { const num = this.nameSpanArr[rowIndex]; return { rowspan: num, colspan: num > 0 ? 1 : 0 }; } if (columnIndex === 1) { const num = this.classSpanArr[rowIndex]; return { rowspan: num, colspan: num > 0 ? 1 : 0 }; } if (columnIndex === 4) { const num = this.gradeSpanArr[rowIndex]; return { rowspan: num, colspan: num > 0 ? 1 : 0 }; } } }这里有个小技巧:班级这一列不要单独写成嵌套的数组计算,直接共用同一个排序规则。只要数据本身按“姓名 + 班级”的整体顺序排好,姓名合并和班级合并的结果能够保持同步,不会出现某一列合并开始的位置和另一列错开的情况。
注意:多列同时合并时,每一列都要保证相同分组的取值范围一致,否则会出现跨行到一半被另一列的跨度顶开的情况。如果确实存在一对多的合并关系,要重新思考数据结构和展示规则,不要硬凑。
4. 列合并与行列混用场景
4.1 列合并的典型写法
列合并最常用的地方是“汇总行”“总计行”或者把多个信息列挤进一个单元格。假设在表格最后多加一行,希望这一行的“成绩”列后面没有数据,而是把总计结果横向铺开:
handleSpan({ rowIndex, columnIndex }) { // 最后一行是汇总行,前两列合并为一个大单元格 if (rowIndex === this.tableData.length - 1) { if (columnIndex === 0) { return { rowspan: 1, colspan: 2 }; } if (columnIndex === 1) { return { rowspan: 0, colspan: 0 }; } } }这样最后一行左侧两块区域就拼成一个大格子,你可以在数据里给最后一行单独放一个说明字段,关键是把要显示的文字放在被合并单元格对应的第一个位置,其他被吞掉的格子随便放个空字符串即可。
4.2 行列同时合并的复杂情况
当行合并和列合并同时出现在一个表格里,最容易翻车。因为 View UI 的 span-method 对每个单元格都会调用一次,行列交叉的格子可能同时满足两个合并条件,必须做好优先级判断。
我习惯的做法是:先处理行合并,再处理列合并,并且在行合并分支里做好 return 拦截。被行合并吞掉的格子直接返回 0,后面没必要再看列合并条件。
例如这样一个场景:姓名列跨 3 行,同时最右侧“操作”列在第一个格子跨 2 列显示一个按钮组。代码可以这样组织:
handleSpan({ columnIndex, rowIndex }) { // 先处理姓名列的行合并 if (columnIndex === 0) { const num = this.nameSpanArr[rowIndex]; if (num === 0) return { rowspan: 0, colspan: 0 }; return { rowspan: num, colspan: 1 }; } // 再处理操作列的列合并 if (columnIndex === 2 && rowIndex === 0) { return { rowspan: 1, colspan: 2 }; } if (columnIndex === 3 && rowIndex === 0) { return { rowspan: 0, colspan: 0 }; } }这种写法的好处是每个格子的处理逻辑分支清晰,后面排查问题也容易定位。如果把所有条件都揉在一起,改需求时会极度痛苦。
4.3 表头合并又是怎么回事
表头合并跟 body 行列合并不是一回事,但很多初学者会搞混。表头合并用的是 columns 里嵌套的 children 配置,让表头形成多级结构:
columns: [ { title: '学生信息', align: 'center', children: [ { title: '姓名', key: 'name' }, { title: '班级', key: 'className' } ] }, { title: '成绩', align: 'center', children: [ { title: '科目', key: 'subject' }, { title: '分数', key: 'score' } ] } ]这种写法自动完成表头合并,Table 会生成两行的表头结构,与 span-method 无关。如果你追求的是“表头 + 数据区”都能自由合并的复杂报表,那就需要两套逻辑一起上,表头用 children,数据区用 span-method。
5. 常见问题与排查技巧实录
5.1 合并后数据重复显示
很多人第一次写完合并逻辑,发现该合并的列还是每个格子都显示数据,或者有的格子没被吃掉,表格出现一堆多余的竖线和错位。
这种问题九成是因为 span-method 里 return 的值没写全。注意两点:第一,被合并的格子必须返回{ rowspan: 0, colspan: 0 },如果你返回{ rowspan: 0 }或{ colspan: 0 },组件不一定按你预想的方式处置;第二,不能在某个条件分支里没有 return,必须保证函数每个分支都有明确的返回值。
另一个隐蔽问题藏在异步数据里。如果表格数据是接口请求后赋值,而 spanArr 是在 mounted 里基于空数组算的,合并结果必然错乱。正确做法是在拿到数据之后同步计算 spanArr,比如放在接口 then 里,或者用 watch 监听数据变化再算。
5.2 排序、筛选之后合并错乱
View UI 的 Table 自带排序功能,只要在 columns 里配置 sortable: true,用户点击表头就能对数据排序。这时候问题来了:span-method 拿到的 rowIndex 是排序后的渲染顺序,而你的 spanArr 还是按排序前的顺序计算的,合并区域自然全乱。
这不是 bug,是机制。排序会改变表格的行顺序,而你之前算好的合并跨度是基于原始数组顺序的。解决办法有这样几个方向:
- 数据排序前先对合并字段做预排序,保证相同值始终相邻,再计算 spanArr。
- 自己接管排序逻辑,在 sort 事件里重新计算 spanArr。
- 干脆禁用需要合并列的排序,改动最小。
我实际项目里最常用的是最后一种。报表场景下,合并列往往是“姓名”“班级”这种分组维度,让用户针对这些列排序的需求本来就不强,直接禁掉排序,体验反而更直接。
5.3 固定列、分页和合并的冲突
固定列是另一个容易踩的坑。Table 的 fixed 列渲染方式比较特殊,会生成一个独立层来模拟固定效果。你如果对 fixed 列做 span-method 合并,View UI 官方文档没有明确禁止,但实际表现经常出现合并单元格背景色断层、固定列和普通列错位的情况。我的建议是:复杂合并页面尽量避免 fixed 列,或者把需要合并的列放在非固定区域。
分页遇到合并也很尴尬。如果表格开了分页,跨页的数据无法合并,这是正常的。处理方式有两种:一是合并只针对当前页数据计算,每页重新调 calculateSpan;二是干脆把分页关闭,用滚动加载代替。具体用哪种,取决于业务方到底想要“连续合并一整页”还是“所有数据合并到底”。
5.4 常见问题速查表
| 问题现象 | 大概率原因 | 解决办法 |
|---|---|---|
| 合并区域错位、表格变形 | spanArr 长度和渲染行数不一致 | 在数据拿到后同步计算,避免在 mounted 里算空数据 |
| 被合并的格子仍显示内容 | 返回值不完整或分支漏写 | 确保每个分支都返回完整{ rowspan, colspan } |
| 合并后数据重复显示 | span-method 未生效或被覆盖 | 检查方法是否绑定到 Table 的 span-method 属性 |
| 排序后合并乱套 | spanArr 基于旧顺序计算 | 禁掉合并列的排序,或在 sort 事件里重算 |
| 固定列合并背景断层 | fixed 列与普通列渲染机制不一致 | 避免对固定列做跨行合并 |
| 分页后合并不连续 | 跨页没有合并条件 | 对每页数据单独计算,或使用完整数据不分页 |
| 多个字段合并时相互顶开 | 字段分组跨度不一致 | 确保数据按所有合并字段的整体层级顺序排列 |
| 合计行横向合并后没有内容 | 数据放到被吞掉格子里了 | 把要显示的文字放在 colspan>0 的那个格子对应字段中 |
5.5 一个排查思路总结
每次合并出问题,我第一个动作是打印 rowIndex、columnIndex 对应的处理结果。在 span-method 里临时加一行 console.log,看边界条件上哪些格子返回了什么值。这个方法看着笨,但比盯着代码瞎猜快得多。
经验:千万不要在 span-method 里做复杂计算或调用接口,它会被组件在每个单元格渲染时反复调用,数据量大一点页面就卡得明显。合并数据要提前算好,渲染时只做取值和判断,这是性能底线。
另外,学会用“索引对齐”来思考。span-method 本质上是“渲染索引 -> 合并策略”的映射。前端拿到的是渲染层的 rowIndex/columnIndex,你提前算好任何数组、任何 map,都要保证索引能一一对上。只要索引对上了,逻辑再花哨也能稳定跑起来;对不上,就会产生各种诡异的错位。
最后聊点实际的
我在项目里最早做合并时,习惯性地把所有逻辑塞在一个方法里,结果改需求时改到怀疑人生。后来把所有合并参数对象化、算法函数化之后,整个流程清爽了不止一档。核心思路就是上面写的三步:先把数据按合并字段排好序,再用通用方法算跨度,最后在 span-method 里照着列索引分发。这套思路放到任何场景都不会过时。
如果你后续要处理的合并非常复杂,尤其是那种“上表头跨列 + 数据区跨行 + 汇总行跨列”的报表,建议先画一个单元格占位草图,标清每个格子应该返回什么值,再动手写代码。用我上面的计算函数可以完成大部分动态需求,特殊点位再手动兜底。祝顺利。