在前后台管理系统里,表格数据合并是个很常见又容易被低估的需求。我最早接触 NaiveUI 的 rowSpan 时,以为就是一个简单的属性,真正上手做报表才发现里面藏着不少细节。这篇文章就把 rowSpan 从概念、配置到实战、踩坑完整讲透,尤其是处理“按相同字段自动合并”这种高频场景,非常适合正在做数据表格、报表模块、管理后台的同学参考。
1. rowSpan 的使用场景与表格合并机制
1.1 从需求出发:为什么报表里缺不了跨行合并
先抛一个很典型的场景:销售报表里,同一个“分类”下面有多条商品记录,比如“数码”下有手机、耳机、充电器,每行都要显示分类名称。如果每一行都写上“数码”,表格会显得特别重复,也不利于视觉分组。产品经理大概率会要求你做成这样:分类列只在第一行出现,下面的几行合并成一个整块,居中展示。这本质就是跨行合并。
类似的需求还有课程表里同一个教室跨节次合并、订单明细里同一个订单号跨多行合并、项目排期表格里部门跨周合并。它们都有一个共同点:某列的值在多行内是相同或连续的,需要把连续区域的首行单元格纵向拉通,隐藏后续行里该单元格的独立呈现。
在原生 HTML 表格里,直接对<td>设置rowspan属性就能实现。但在 NaiveUI 的DataTable组件里,表格是数据驱动的,我们不能手动去操作 DOM,而是要在列配置里告诉组件:这个列什么时候需要合并、合并多少行。
1.2 NaiveUI 表格中的 rowSpan / colSpan 是怎么工作的
NaiveUI 的DataTable列配置对象里,提供了rowSpan和colSpan两个属性。很多初学的人误以为这是写在<n-data-table>标签上的,实际上它是columns数组里某个列的配置项。
以rowSpan为例,在某个列的配置中写:
{ title: '分类', key: 'category', rowSpan: (rowData) => rowData.categorySpan }当这一列的rowSpan函数返回一个大于 1 的数字 N 时,代表当前单元格向下跨越 N 行。与此同时,被跨掉的后续行里,这一列的位置会被“占住”,不再重复渲染内容。关键点来了:rowSpan的值不是 NaiveUI 自动帮你按内容算出来的,你必须自己计算好每个数据行该返回多少,尤其是后续行要返回 0,表示“这一行的这一列不显示了”,这样才能形成从第一个单元格往下合并一整块的效果。
colSpan的工作原理类似,只是方向从纵向变成横向,用于跨列合并表头或者多列单元格。两者可以组合使用,但组合时要特别注意边界条件,这一点后面我会专门讲。
1.3 rowSpan 返回值“0 / 1 / N”分别是什么含义
我见过不少同学在rowSpan函数里返回true、返回字符串'2',结果完全不生效。这里先统一概念:rowSpan的返回值必须是数字。
| 返回值 | 含义 | 典型使用场景 |
|---|---|---|
| 0 | 当前行的该单元格被隐藏,不参与展示 | 作为合并区域内非第一行的标记 |
| 1 | 不发生合并,正常显示一个单元格 | 默认状态的写法 |
| N(N > 1) | 当前单元格向下合并 N 行 | 一组连续相同数据的首行 |
也就是说,一个完整的合并组里,第一行的rowSpan要等于组内行数,后续每行的rowSpan要等于 0。这也是最常见的数据预处理思路。
2. 配置 rowSpan 的两种方式与踩坑点
2.1 静态写法:列配置里直接写死 rowSpan
如果你要合并的行数相对固定,或者不需要根据数据计算,可以直接把rowSpan写成一个常量数字。
{ title: '备注', key: 'remark', rowSpan: 2 }这种写法意味着表格每一行的“备注”列都会向下合并 2 行。但说实话,实际业务中这种完全固定的合并非常少见。它更适合用在表头或者某些特殊固定结构里,比如第一列始终每两行合并一次。更多情况下,我们还是需要让组件根据每一行的数据动态计算合并数。
2.2 动态写法:通过函数按当前行数据返回
更通用的方式是让rowSpan成为一个函数,函数签名大概长这样:
rowSpan: (rowData, rowIndex) => { // rowData 是当前行的数据对象 // rowIndex 是当前行在 data 数组中的索引 return rowData.span ?? 1 }函数的作用很直接:组件渲染某一行的这一列时,去问你“这里要合并吗?合并几行?”你根据rowData里的某个字段返回数字即可。
为了让数据清洗和视图逻辑分离,我习惯先对原始数据做一次预处理,在数据里标记出每个合并组的首行和尾行,然后列配置里只做一层“取值转发”。
{ title: '分类', key: 'category', rowSpan: (rowData) => rowData.categorySpan ?? 1 }这样columns配置非常稳定,将来数据变化时只需要重新跑一下预处理函数,不需要动列结构。
2.3 写 rowSpan 函数时最容易忽略的三个细节
第一个细节:一定要给后续行也设置span = 0,哪怕它们的数据里仍然有 category 字段。如果不设置 0,你可能会看到合并列里重复出现文字,或者出现错位,因为组件认为每一行都该正常显示这个单元格。
第二个细节:rowSpan函数里返回的值一旦大于当前行到末尾的剩余行数,组件不会报错,但实际合并效果会变得不可控。比如后面只有 2 行数据,你却返回了 5,表现就可能异常。所以预处理时,合并组不能跨越数组尾部。
第三个细节:如果你同时用rowIndex做了逻辑判断,请务必确认rowIndex是当前渲染数据里的一维索引,而不是全局数据集的索引。尤其是在分页、排序之后,rowIndex会基于当前页的数据重新编号。把rowIndex和绝对数据的下标混用,是排查合并错位时最让人头疼的原因之一。
3. 实战:按照重复字段自动合并跨行单元格
3.1 数据准备与期望效果
我现在模拟一个商品销量表,原始数据是这样的:
const rawData = [ { id: 1, category: '数码', product: '手机', amount: 3200 }, { id: 2, category: '数码', product: '耳机', amount: 800 }, { id: 3, category: '数码', product: '充电器', amount: 120 }, { id: 4, category: '家电', product: '冰箱', amount: 2600 }, { id: 5, category: '家电', product: '洗衣机', amount: 1900 }, { id: 6, category: '家居', product: '沙发', amount: 4300 } ]期望的效果是:分类列中,“数码”占 3 行,“家电”占 2 行,“家居”占 1 行,每一个分类的值只显示在对应合并区域的第一行,并且垂直居中。
要做到这一点,核心是先把数据中相同且相邻的category归成组,然后给组内首行设置categorySpan = 组内行数,组内其他行设置categorySpan = 0。
3.2 写一个预处理函数,给数据打上行 span 标记
这里有一个隐藏背景:默认情况下category相同但不相邻的行不应该被合并。比如两条“数码”中间夹了一条“家电”,如果强行把首尾两条“数码”并在一起,会跨过其他数据,这在表格合并里是错误行为。所以正确处理方式是“连续值分组”,而不是“全局相同值分组”。
下面这个markRowSpan函数只处理“相邻相同项”的分组,是基础但足够稳的方案:
function markRowSpan(data, field, spanField) { const result = data.map((item) => ({ ...item })) let i = 0 while (i < result.length) { let j = i + 1 while (j < result.length && result[j][field] === result[i][field]) { j++ } const count = j - i result[i][spanField] = count for (let k = i + 1; k < j; k++) { result[k][spanField] = 0 } i = j } return result }调用方式很简单:
const tableData = markRowSpan(rawData, 'category', 'categorySpan')如果原始数据的字段顺序和你想分组的不一致,建议先排序再调用,或者在预处理前做一次排序。不然相邻判断会得到和你预期完全不同的分组。
如果同一张表里有多个列需要合并,可以继续扩展这个函数,传入多个字段和多个 span 字段。比如:
function markRowSpans(data, configs) { // configs: [{ fromField: 'category', toField: 'categorySpan' }, ...] // 逐个调用 markRowSpan }不过要注意:多个列同时合并时,如果它们的合并边界不重合,表格渲染会非常复杂。真实项目中遇到多列各自独立分组的情况,我一般建议拆分表格或者先交互确认需求,不要一股脑全塞给表格去硬撑。
3.3 在 columns 中接入 rowSpan,并处理居中展示
预处理完成后,columns配置就很简单了:
const columns = [ { title: '分类', key: 'category', rowSpan: (rowData) => rowData.categorySpan, render(rowData) { return h('div', { style: 'display: flex; align-items: center; justify-content: center; height: 100%;' }, rowData.category) } }, { title: '商品', key: 'product' }, { title: '销量', key: 'amount' } ]这里用render包一层 div 是为了让合并后的文字视觉上居中。如果不包,默认的位置通常在单元格顶部,合并区域一大片时文字会显得很靠上,观感不好。
3.4 完整代码与运行效果说明
完整组件代码长这样:
<template> <n-data-table :columns="columns" :data="tableData" :bordered="true" /> </template> <script setup> import { h } from 'vue' import { NDataTable } from 'naive-ui' const rawData = [ { id: 1, category: '数码', product: '手机', amount: 3200 }, { id: 2, category: '数码', product: '耳机', amount: 800 }, { id: 3, category: '数码', product: '充电器', amount: 120 }, { id: 4, category: '家电', product: '冰箱', amount: 2600 }, { id: 5, category: '家电', product: '洗衣机', amount: 1900 }, { id: 6, category: '家居', product: '沙发', amount: 4300 } ] function markRowSpan(data, field, spanField) { const result = data.map((item) => ({ ...item })) let i = 0 while (i < result.length) { let j = i + 1 while (j < result.length && result[j][field] === result[i][field]) { j++ } const count = j - i result[i][spanField] = count for (let k = i + 1; k < j; k++) { result[k][spanField] = 0 } i = j } return result } const tableData = markRowSpan(rawData, 'category', 'categorySpan') const columns = [ { title: '分类', key: 'category', rowSpan: (rowData) => rowData.categorySpan, render(rowData) { return h( 'div', { style: 'display: flex; align-items: center; justify-content: center; height: 100%;' }, rowData.category ) } }, { title: '商品', key: 'product' }, { title: '销量', key: 'amount' } ] </script>运行之后,分类列会正确显示三块合并区域:“数码”跨 3 行、“家电”跨 2 行、“家居”跨 1 行。其他列保持原有布局,不受影响。
4. rowSpan 疑难杂症排查与解决方案
4.1 rowSpan 明明配置了却不生效,先检查这几个点
遇到 rowSpan 没生效,先不要怀疑组件有 bug,按顺序自查:
第一,检查rowSpan是不是写在了列配置对象里,而不是表格组件标签上。我把这个错误见过不只一次。
第二,检查函数返回值类型。一定要是数字,'2'和2看似差不多,但组件内部不一定做隐式转换。返回undefined时会当做 1 处理,看起来就像没合并一样。
第三,检查当前列是否同时设置了render,render 返回的内容如果是一个原生<td>结构,可能会覆盖掉合并逻辑。普通场景下render只负责内部内容,不要返回整个 td。
第四,确认数据连续。如果一组相同值中间夹了其他值,那么这一组不会自动跨越中间行合并。除非你的预处理函数有意合并不相邻分组,否则这不是 bug,是数据结构问题。
4.2 合并后单元格错位、多合并或漏合并
错位是最常见的现象之一。我遇到过一种典型情况:数据里有两个字段都是数字,其中一个在提取时因为map操作变成了字符串,而另一个是数字,导致===判断失效,本应连续的分组被切开,表格出现“多合并”或“漏合并”。
解决这个问题的思路是:把markRowSpan这类纯函数单独抽出来,统一做字段归一化。调试的时候先在控制台打印预处理结果,确认每个categorySpan是否符合预期,再去看表格渲染效果。
如果只有部分列错位,另一个怀疑点是多个列同时配置了 rowSpan 且合并组边界交叉。比如 A 列按分类合并,B 列按品牌合并,分类和品牌的层级不一致,合并边界相交叉,表格很难渲染正确。遇到这种需求,先调整数据模型,把交叉结构拍平。
4.3 固定列、排序、筛选、分页和 rowSpan 的冲突
先说固定列。理论上fixed: 'left'可以和rowSpan同时配置,但在实际项目中我发现固定列本身会生成一层额外的表格结构,合并单元格的尺寸计算偶尔会出偏差。如果你遇到固定列下合并区域高度不对、出现空白或者遮挡,建议把要合并的列放在非固定区域,或者给固定列单独做样式补偿。
排序对 rowSpan 的影响更直接。NaiveUI 的本地排序会改变数据顺序,但数据里已经算好的categorySpan不会跟着变。也就是说排序前“数码”跨 2 行,排序后这 2 行被拆散了,表格还按旧的 span 值去合并,必然出错。
解决方案有两个:
- 不使用本地排序,排序逻辑放到后端接口,接口返回新的数据后再重跑
markRowSpan。 - 如果坚持用本地排序,监听
update:sorter事件,在排序回调里拿到排序后的数据,重新执行预处理。
<template> <n-data-table :columns="columns" :data="tableData" row-key="id" @update:sorter="handleSorter" /> </template>function handleSorter(sorter) { // 假设 sortedData 是根据 sorter 对 rawData 排序后的新数组 tableData.value = markRowSpan(sortedData, 'category', 'categorySpan') }筛选的原理也类似,筛选后的数据如果是新数组,同样要重新计算 span。有一种省心但需要注意的做法是:把排序和筛选后的数据再做一次 findIndex 映射,但这本质上还是要保证 span 字段和当前渲染数据同步。
分页的问题更麻烦。跨页合并天然不可行,因为不同页面的数据是独立渲染的。如果某个合并组跨了页,第一页的最后一条和第二页的第一条其实是同一组,但表格分页后无法感知这一点。结果可能是:第一页正确显示了一个跨到本页末尾的合并区域,第二页对应位置却因为 span 是 0 而显示空白,用户看到数据缺失。
处理方式通常有两种:
- 让后端在分页时按页返回已计算好的 span,保证当前页内的每组数据正确。
- 在前端分页场景下,合并组只允许上页尾部断开,不跨页合并。也就是说分页后的每一页需要独立预处理,而不是拿全量数据算一次再切片。
function computePageData(allData, page, pageSize) { const pageData = allData.slice((page - 1) * pageSize, page * pageSize) return markRowSpan(pageData, 'category', 'categorySpan') }这种按页预处理的写法,虽然可能让某个合并组在上一页是完整的、下一页第一行又重新开始,但至少每一页显示都不会出现空白或错位。产品上如果无法接受,那就改用服务端分页方案。
4.4 行选择、展开行与合并单元格如何共存
如果表格同时使用type: 'selection'的行选择列,会出现一个现象:选择列是独立于数据列的,每一行都有 checkbox,合并过的数据行也被视为普通行,可以单独勾选。
这个行为本身不算 bug,但从交互上看,一个被合并成单一“分类”的区域内,用户可能以为只会选中一条数据,实际却能选中多行。如果你希望合并组内的行在选择时只算一条,要么在拿到选择结果后按合并组过滤,要么在 rowSpan 计算时把合并组作为一个整体记录,提交时统一处理。
展开行也类似。展开行功能会给每一行增加一个额外的展开按钮列,它和 rowSpan 没有直接冲突,但如果你展开的内容高度不固定,会让合并单元格的高度估算出问题。建议合并和展开行不要放在同一个表格里,除非你已经对动态高度做了充分测试。
5. 扩展玩法:colSpan、复杂表头与样式微调
5.1 跨行与跨列组合的实现思路
除了纵向的rowSpan,NaiveUI 还支持横向的colSpan。比如某个单元格需要横跨两列,就可以在列配置里写:
{ title: '合计', key: 'total', colSpan: (rowData) => rowData.colSpanValue }实际业务里,跨列合并通常出现在统计表格的“合计”行:表格前面的几列都是空单元格,最后一列横跨多个单元格显示汇总内容。这种结构用原生 HTML 不难,但在 NaiveUI 数据表格里需要精细计算每一列的 span。
跨行和跨列组合时,比如某个汇总行既跨多行又跨多列,强烈建议先把合并模型画出来,标注出每个单元格的rowSpan和colSpan,再写预处理逻辑。否则在表格渲染阶段发现问题,排查成本会很高。
5.2 合并单元格文本垂直居中的 CSS 技巧
合并之后,单元格里的文字位置往往不是最理想的。最直接的办法是给这个列配置render,在内容外层包一个 flex 容器:
render(rowData) { return h( 'div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '36px', height: '100%' } }, rowData.category ) }如果希望应用到所有单元格,可以通过td-style设置:
{ tdStyle: 'text-align: center; vertical-align: middle;' }但要留意td-style会影响该列所有 td,如果只有合并单元格需要居中,其他单元格需要左对齐,那就用 render 精准控制,不要一刀切。
5.3 一些实用经验:大数据量性能优化、响应式布局取舍
rowSpan的合并计算本质上是 O(n) 的遍历,数据量几千行也没问题。真正影响体验的是渲染大量合并单元格时的重排成本。如果表格行数比较多,建议在预处理时直接把 span 字段挂到数据对象上,不要每次都临时计算。同时可以加上一层按field的缓存,只有原始数据真正变化时才重算。
响应式布局是另一个容易翻车的地方。表格在窄屏下如果出现横向滚动,合并单元格的宽度会跟着整体表格宽度走,但固定列和普通列之间的缝隙偶尔会有 1px 偏差,导致合并区域边框对不齐。这种问题通常出现在多列合并且宽度差异大的场景,肉眼很难察觉,截图时却很显眼。我的建议是:优先保证桌面端体验,移动端可以考虑把需要合并的表格切换成卡片列表,而不是硬塞进横向滚动容器。
最后分享一个小经验:凡是涉及 rowSpan 需求,我都习惯在开发前先在纸上画一个 3 行 3 列的示意表格,把哪一行哪一列要合并、合并后谁隐藏、谁保留,全部标出来,再开始写代码。这个习惯帮我少踩了很多坑,尤其是多个列同时合并的时候,提前画出合并矩阵比讨论半天实现方案效率高得多。