Vue3 + NaiveUI 表格合并 rowSpan 实战:从原理到完整实现
做后台管理系统这几年最常被测试和产品追着改的除了按钮样式就是表格。尤其是“这一列内容重复了能不能合并一下”“像 Excel 那样把相同项合并了”这类需求几乎每个项目都会碰到。如果你用的是 Vue 3 搭配 NaiveUI第一次接到这种需求大概率会愣一下——DataTable 组件文档里根本没有直接的rowSpan配置项翻一圈源码也只找到列配置里的colSpan对数据行的合并只字未提。别急这篇文章就是专门解决这个问题的。我先把结论放前面NaiveUI 表格的rowSpan不是不能实现而是要把“合并”这件事拆成两个步骤——第一步在数据层面上算出每一行该合并几行第二步通过列配置里的tdProps把合并信息告诉表格组件。这里面的细节和坑比想象中多。比如非合并行的单元格怎么“消失”合并后又怎么处理排序筛选多列同时合并时为什么要按组合键计算这些如果你没提前搞清楚写出来的表格大概率会错位、多边框、甚至有滚动条错乱。这篇文章我会从 HTML 的 rowSpan 基础讲起结合我实际项目里的案例把 NaiveUI 中纵向合并单元格的完整流程、算法、代码和避坑经验都过一遍。不管你是在做订单报表、排班表还是统计汇总看完应该都能直接照着落地。1. 先搞清楚 NaiveUI 表格的渲染机制1.1 rowSpan 在 HTML 里到底做了什么先说最基础的概念。HTML 中td标签有一个rowspan属性表示这个单元格要纵向跨越多少行。比如某一行第一列设置了rowspan3那么这个单元格就会向下伸展覆盖当前行和下面两行的位置而下面那两行原本应该出现在第一列的单元格就要被“拿掉”或隐藏否则表格会被挤出一列整个结构就错位了。在 NaiveUI 的 DataTable 中每个单元格都是根据 columns 数组自动生成的列数固定每行都会渲染出同样数量的td。它不会自动帮你“拿掉”多余的单元格所以你得自己在渲染层面处理。这也就是为什么单纯依赖组件自身配置很难实现合并——你必须告诉自己合并单元格的本质是在某一行扩展单元格占用的行数同时把后续行对应的原生单元格隐藏掉。用生活类比解释的话rowSpan 就像是食堂的打饭窗口原来一排有 5 个窗口某天因为菜品调整第一个窗口变成了“大锅饭专窗”一个人站在窗口前可以服务后面三个位置的人那么原本排在第 2、3、4 个位置的窗口就要临时关闭不然队伍就对不上了。关闭这些位置就是我们要在代码里做的事。1.2 为什么说 render 函数控制不了单元格属性很多刚接触 NaiveUI 的人看到列的render函数能自定义内容下意识会觉得直接在这里返回一个带 rowSpan 的td就行。我开始也试过结果页面渲染出来嵌套了一层 td语法上不报错但 DOM 结构变成了tdtd...整个表格像被塞了俄罗斯套娃浏览器自动把多余标签顶出表格之外样式直接崩掉。原因在于 NaiveUI 的render函数只负责生成单元格内部的内容它返回的 VNode 会被填入表格已经创建好的td容器里面。也就是说这个函数管的是“里面装什么”管不了“容器本身长什么样”。要修改td本身的属性必须用列配置里的另一个钩子——tdProps。tdProps接收两个参数分别是当前行的数据对象rowData和当前行的索引rowIndex返回一个对象这个对象会被展开成td上的属性。所以你需要设置rowspan或colspan的时候就通过它返回{ rowspan: 3 }这样的结构。这是 NaiveUI 实现单元格合并的最主要入口。// 一个简单的 tdProps 示例 const columns [ { title: 日期, key: date, tdProps: (rowData, rowIndex) { if (rowIndex 0) return { rowspan: 2 } return {} } } ]这个代码跑起来第一行日期列确实会跨两行显示但第二行原本应该被“拿掉”的日期单元格依然存在界面会变成一个奇怪的错位表格。所以光设置起始行的 rowspan 不够非起始行的单元格必须想办法隐藏掉这一步很多人会踩坑。2. 先写一个最简单的合并单列重复值合并2.1 数据准备与需求场景为了把原理讲清楚我先拿一个简单场景来做演示。假设后端返回了这样一组订单数据const orderList [ { date: 2024-05-01, orderNo: A001, amount: 100 }, { date: 2024-05-01, orderNo: A002, amount: 150 }, { date: 2024-05-01, orderNo: A003, amount: 80 }, { date: 2024-05-02, orderNo: B001, amount: 200 }, ]产品需求是日期相同的数据日期列只显示一次纵向合并成一个大单元格订单号和金额列保持原样。这就是非常典型的单列合并场景也就是rowSpan最基础的使用方式。2.2 核心预处理 rowSpan 的算法现在关键来了表格组件并不知道哪几行要合并它只会机械地按每一行的数据渲染单元格。所以我需要在渲染之前先遍历一遍数据找出“连续相同”的分组算出每个分组从第几行开始、覆盖几行然后把这个结果存起来供tdProps查询。为什么强调“连续相同”因为合并单元格只能合并相邻的行如果相同值在中间被其他值隔开了比如 A、B、A 这样的顺序两个 A 绝对不能跨过 B 合并到一起这一点和 SQL 的 GROUP BY 有本质区别。所以在计算时要注意判断条件不能只看值相等还要确认这两行在数组里是连续的。/** * 计算单列合并信息 * param {Array} data 表格数据 * param {String} key 要合并的列字段 * returns { Mapnumber, number } 起始行号 - 合并行数 */ function buildRowSpanMap(data, key) { const spanMap new Map() let startIndex 0 for (let i 1; i data.length; i) { const prevVal data[i - 1][key] const curVal i data.length ? data[i][key] : Symbol(end) if (i data.length curVal prevVal) { continue } const span i - startIndex if (span 1) { spanMap.set(startIndex, span) } startIndex i } return spanMap }这段代码的思路是用一个startIndex标记当前分组的起点然后从第二个元素开始往后扫描只要当前元素和上一个元素的值相同就继续向后走一旦遇到不同的值说明分组结束了此时记录起点到当前位置的行数。Symbol(end)是一个小技巧用来兜底遍历到数组末尾时也能正常触发分组关闭的逻辑。2.3 在 DataTable 列配置里落地拿到spanMap之后接下来就是在列配置里使用它。我们需要处理两种行如果是合并起始行给td设置rowspan如果是被合并掉的行要把单元格隐藏。import { computed, h } from vue import { NDataTable } from naive-ui const orderList ref([ { date: 2024-05-01, orderNo: A001, amount: 100 }, { date: 2024-05-01, orderNo: A002, amount: 150 }, { date: 2024-05-01, orderNo: A003, amount: 80 }, { date: 2024-05-02, orderNo: B001, amount: 200 }, ]) const spanMap computed(() buildRowSpanMap(orderList.value, date)) const columns computed(() [ { title: 日期, key: date, tdProps: (_, rowIndex) { if (spanMap.value.has(rowIndex)) { return { rowspan: spanMap.value.get(rowIndex) } } // 当前行不是起始行但它的上一行如果是合并起始行则当前行应该隐藏 const prevIndex rowIndex - 1 const prevSpan spanMap.value.get(prevIndex) if (prevSpan rowIndex prevIndex prevSpan) { return { style: display: none } } return {} } }, { title: 订单号, key: orderNo, width: 120 }, { title: 金额, key: amount, width: 100 }, ])这段代码里最关键的是“隐藏”的判断逻辑拿当前行的前一行索引去spanMap里查如果前一行是合并起始行而且当前行在它的覆盖范围之内说明当前行的这个单元格应该被藏起来。这里用display: none而不是直接不渲染这个td是为了保持每一行单元格数量一致保证表格行列结构不乱。隐藏之后后面的单元格会自然向左补位视觉上就形成了合并效果。3. 多列合并与更复杂的组合场景3.1 多列同时合并时不能每列独立计算单列合并跑通之后产品很快会提第二个需求“日期要合并地区也要合并而且必须是日期和地区都相同才合并。”很多同学第一反应是给两列分别各跑一遍buildRowSpanMap但这会出问题。举个例子2024-05-01 有三行数据前两行是华东地区第三行是华北地区。如果日期列独立计算它会认为三行日期相同合并成三行地区列独立计算的话前两行合并成两行第三行单独。最后出来的表格会是日期列合并了三行但地区列只合并了两行第二行到第三行之间出现一个隐形断档视觉上日期单元格会覆盖到地区完全不相关的数据这就是典型的错位事故。正确的思路是按组合键来分组。所谓组合键就是把多个需要合并的列的值拼成一个字符串用这个字符串来判断是否连续相同。只有组合键相同才认为这些行是同一组然后在这个组内对每一列分别算出起始行和覆盖行数。function buildMultiRowSpanMap(data, mergeKeys) { // 先为每个列建一个起始行 - 行数的Map const result {} mergeKeys.forEach(key { result[key] new Map() }) let startIndex 0 const getKey (item) mergeKeys.map(k item[k]).join(__) for (let i 1; i data.length; i) { const prevKey getKey(data[i - 1]) const curKey i data.length ? getKey(data[i]) : Symbol(end) if (i data.length curKey prevKey) { continue } const span i - startIndex if (span 1) { mergeKeys.forEach(key { result[key].set(startIndex, span) }) } startIndex i } return result }调用方式和单列几乎一样只是tdProps里查询的 Map 不同const columns [ { title: 日期, key: date, tdProps: (row, rowIndex) { const map mergeMaps.value.date if (map.has(rowIndex)) return { rowspan: map.get(rowIndex) } // 命中隐藏逻辑同上 } }, { title: 地区, key: region, tdProps: (row, rowIndex) { const map mergeMaps.value.region // 同样的判断逻辑 } }, ]用组合键统一算完起始行之后每一列其实用的是同一份“分组计划”所以即使两列都需要合并它们的合并范围和边界也完全一致不会再出现互相错位的问题。3.2 合并后数据如何展示与对齐合并起始行的单元格正常显示内容被合并掉的隐藏单元格即使display: none它的render函数依然会被调用这时候单元格内容如果还显示着就会出现“明明隐藏了但字符飘在那里”的诡异效果。所以还需要给被合并行的render返回一个空占位。这里顺手把render也加上{ title: 日期, key: date, render: (row, rowIndex) { const map mergeMaps.value.date if (map.has(rowIndex)) return row.date const prevIndex rowIndex - 1 const prevSpan map.get(prevIndex) if (prevSpan rowIndex prevIndex prevSpan) { return } return row.date }, tdProps: (_, rowIndex) { // 同上隐藏逻辑 } }这样合并单元格里的文字只会在起始行出现一次隐藏行即使还在 DOM 里也是空白且不可见的。另外要提醒一个视觉细节合并列最好放在表格靠左的位置。如果合并列在中间或者在右侧隐藏元素后剩余单元格会左右位移视觉效果会有一点点“跳”的感觉。虽然从逻辑上没错但对用户体验不够友好。遇到这种需求建议先和产品沟通调整列顺序。3.3 配合排序、筛选、分页时要注意什么buildMultiRowSpanMap是在当前数据快照上计算的所以只要数据源变了合并计划就必须跟着变。最稳妥的做法是用computed把合并信息和列定义都做成响应式数据让表格每当orderList变化时重新生成合并信息。排序和筛选时顺序变化会导致“连续相同”的语义完全不一样。比如按金额排序后日期列的数据不再连续那么原来的合并范围全部失效必须重新计算。由于代码里用的是computed这些场景基本能自动响应。分页是个很容易被忽略的地方。如果用的是前端分页表格当前页显示的是pageData而不是全量数据那么合并计算时必须传入当前页的数据而且合并不能跨页。如果当前页的最后一笔数据和第二页的第一笔数据日期相同也不能把它们合并起来这一点需要在计算前对分页后的数据单独取样。如果用的是后端分页后端返回什么前端合并什么相对简单但同样要注意合并信息不能在前端误缓存。4. 我在实际项目里踩过的坑与排查方案4.1 表格行列错乱都是 rowspan0 的锅我最初在另一个项目里做表格合并时看到网上有人说把后面行的rowspan设为 0 就能隐藏单元格结果在 NaiveUI 里一跑整个表格直接错位有的行多一列有的行少一列表头和表体完全对不上。后来查 HTML 标准才发现rowspan0的含义是“跨越到整个表格的最后一行”并不是“隐藏”不同浏览器对它的解释也不一致Chrome 和 Firefox 的表现都不同根本不能依赖。正确的做法就是我前面写的用display: none隐藏多余的 td。有些同学担心display: none会不会导致边框线消失或者影响列宽实测下来在大多数场景下表现稳定只要保持每行 td 数量一致表格布局不会乱。如果你在合并后遇到表格列宽被撑大、表头错位第一件事不是调 CSS而是用开发者工具看看 DOM 里该行的 td 数量是否和表现一致。错位的根源 90% 是某一行多渲染或者少渲染了一个 td。4.2 合并列千万别用 fixed 固定列NaiveUI 的表格支持fixed: left或fixed: right固定列这个功能很实用但它和单元格合并是不兼容的。固定列的原理是组件内部把表格拆成左右两个独立的 table用滚动同步来模拟固定效果这就导致固定列和主体列的 DOM 结构是分离的。一旦你在固定列上做 rowSpan两边表格的行数和单元格数对不上滚动条一拉就露馅。如果你确实需要首列固定同时又需要合并我的建议是换一种交互方案把需要合并的列做成普通列用minWidth配合表格外层容器横向滚动或者把固定列需求砍掉。鱼与熊掌在 DataTable 里真的很难兼得这不算产品缺陷但你需要提前和产品说明白限制。4.3 大数据量下合并计算会卡顿吗buildMultiRowSpanMap的时间复杂度是 O(nm)n 是行数m 是合并列数量在几百行的数据量下几乎没有性能压力。真正会卡的是在每一列的tdProps里再去遍历一遍全量数据来查找合并信息那样变成了 O(nm*n)数据一旦上千行页面明显会掉帧。所以我强烈建议把合并信息集中算一次存成Map所有列的tdProps只做 O(1) 的 Map 查询。如果表格行数上了万前端做合并已经不合适了更好的方案是后端在返回数据时直接通过一些空值标记来告诉前端哪些单元格该合并前端只做简单渲染不要在前端做复杂的分组计算。4.4 合计行怎么用 colSpan 快速实现除了纵向合并我还经常遇到“底部加一行合计左侧文字跨多列”的需求。这种属于横向合并用tdProps的colspan属性就能完成。做法是在数据源末尾追加一行标记行然后用tdProps根据当前行的类型做判断。{ title: 说明, key: summary, tdProps: (row) { if (row.isSummary) { return { colspan: 2 } // 跨两列 } return {} }, render: (row) { if (row.isSummary) { return 合计 } return row.orderNo } }这样合计行的“说明”单元格会横向占据 2 列其他列正常展示金额汇总。NaiveUI 也自带了summary配置项但它在自定义合并和样式控制上不如自己追加一行灵活。如果你的合计逻辑简单可以直接用官方 summary如果牵扯到复杂文案、动态列自己追加一行更省心。5. 更进一步把合并逻辑封装成通用工具5.1 一个极简的 useRowSpan 组合式函数如果项目里多个页面都有表格合并需求每次都复制粘贴buildMultiRowSpanMap和tdProps里的逻辑太啰嗦了。我习惯把它封装成一个组合式函数把合并列的配置和隐藏判断都收拢到一起业务页面只传数据源和需要合并的字段即可。import { computed } from vue export function useRowSpan(data, mergeKeys []) { const mergeMaps computed(() { const result {} mergeKeys.forEach((key) { result[key] new Map() }) if (!data.value.length) return result let startIndex 0 const getKey (item) mergeKeys.map((k) item[k]).join(__) for (let i 1; i data.value.length; i) { const prevKey getKey(data.value[i - 1]) const curKey i data.value.length ? getKey(data.value[i]) : Symbol(end) if (i data.value.length curKey prevKey) { continue } const span i - startIndex if (span 1) { mergeKeys.forEach((key) { result[key].set(startIndex, span) }) } startIndex i } return result }) const isHiddenCell (key, rowIndex) { const map mergeMaps.value[key] if (!map) return false if (map.has(rowIndex)) return false // 检查上一行是否在合并范围内 const prevIndex rowIndex - 1 const prevSpan map.get(prevIndex) if (prevSpan rowIndex prevIndex prevSpan) { return true } const beforePrevIndex rowIndex - 2 const beforePrevSpan map.get(beforePrevIndex) if (beforePrevSpan rowIndex beforePrevIndex beforePrevSpan) { return true } return false } const getRowSpan (key, rowIndex) { return mergeMaps.value[key]?.get(rowIndex) || 1 } return { mergeMaps, isHiddenCell, getRowSpan } }这里isHiddenCell判断当前行是否被合并掉时我只向前检查两行因为实际合并场景里跨度通常不会太长。如果你有跨多行的合并需求建议改成向上循环直到遇到非合并起始行或者到达数组头部这样更稳妥。5.2 完整接入示例封装好之后页面里的使用方式非常清爽。核心逻辑集中在 columns 定义里每个需要合并的列都按照“先查起始行 span、再查是否隐藏”的套路处理本质上只是把原来的手写逻辑抽成了可复用函数。const { isHiddenCell, getRowSpan } useRowSpan(orderList, [date, region]) const columns computed(() [ { title: 日期, key: date, render: (row, rowIndex) { if (isHiddenCell(date, rowIndex)) return return row.date }, tdProps: (_, rowIndex) { const span getRowSpan(date, rowIndex) if (span 1) return { rowspan: span } if (isHiddenCell(date, rowIndex)) return { style: display: none } return {} } }, // 其他列省略 ])用下来最大的体会是做表格合并最重要的不是把某个单元格的 rowspan 写出来而是想清楚数据分组的边界。数据分组规则没定清楚后续所有列都会跟着遭殃。6. 常见问题速查与避坑清单场景表现解决方案表格行列错位某几行少一列或多一列检查非合并行的 td 是否通过 display:none 隐藏不要用 rowspan0合并后表头对不齐表头和表体列宽不一致确认是否存在固定列固定列和行合并不兼容排序筛序后合并错乱相同内容没有合并或乱合并合并信息必须用 computed 依赖数据源排序筛选后重新计算分页跨页合并下一页第一行被错误的合并前端分页时只对当前页数据计算合并信息单元格被隐藏但文字还在页面出现残留文本render 函数返回空字符串同时 tdProps 设置 display:none大数据量卡顿滚动和渲染卡顿使用 Map 预计算合并信息避免在 render 里嵌套循环最后再分享一个实际项目里的小经验合并单元格的边界条件一定不要拍脑袋写最好把产品拉过来对着设计稿逐条确认。比如“日期相同就合并”和“日期相同且地区相同才合并”是两个完全不同的语义一旦理解错了返工成本特别高。我在一个库存报表项目里就因为没确认“连续相同”这个约束按全局分组逻辑写了一套结果中间插入了一行不同区域的数据整张表全部错位最后花了半天时间才排查出来。搞表格合并慢一点、稳一点绝对不亏。