ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Vue表格合并单元格实战:rowspan计算、交互修复与动态重算

Vue表格合并单元格实战:rowspan计算、交互修复与动态重算

1. 表格合并这件事,难点从来不在"合并"

先说我遇到的真实场景:后台系统里一张订单明细表,同一个订单号下挂五条商品记录,产品经理要求订单号、下单时间、客户名称这几列"看起来是一体的"——只显示一次,纵向跨越五行,边框连成一块。第一次做的时候我觉得这不就是rowspan嘛,算一下数量塞进去完事,结果改完需求一变动,行数据变了、排序变了、筛选变了,表格立刻错位,单元格边框像被狗啃过一样。后来才明白,这个功能的真正难点不是"合并"这个动作本身,而是合并之后怎么保持稳定。

所以这篇内容主要解决三件事:怎么在 Vue 里把rowspan算对;合并之后斑马纹、hover、固定列这些交互怎么不崩;数据动态变化时怎么让合并关系跟着自动重算。适合已经会写 Vue 表格、但被合并单元格坑过或者即将要踩坑的人看。文中用的是 Vue 3 组合式 API,Vue 2 的写法我也会在关键处点一句差异,因为不少老项目还在跑。

先给个前提:这里讲的方案不依赖具体 UI 库的合并 API。Element Plus、Ant Design Vue、Naive UI 这些库大多提供span-method之类的钩子,用法五花八门,但底层逻辑都是同一个——给每个单元格返回它应该跨多少行、跨多少列。把底层算清楚了,换个库只是换个函数签名的事。

2. span-method 的本质:一次"行优先"的扫描

2.1 从数据结构反推合并规则

合并单元格的第一件事不是写代码,是盯住数据问自己:哪几行算"同一组"。上面那个订单例子,数据大概是这个形状:

const tableData = [ { orderId: 'A001', createTime: '2024-05-01', customer: '某客户甲', product: '商品一', amount: 100 }, { orderId: 'A001', createTime: '2024-05-01', customer: '某客户甲', product: '商品二', amount: 200 }, { orderId: 'A001', createTime: '2024-05-01', customer: '某客户甲', product: '商品三', amount: 150 }, { orderId: 'A002', createTime: '2024-05-02', customer: '某客户乙', product: '商品四', amount: 300 }, { orderId: 'A002', createTime: '2024-05-02', customer: '某客户乙', product: '商品五', amount: 250 } ]

判断"同一组"的依据就是orderId相同,而createTime和customer因为跟订单号一一对应,可以顺带一起合并。这里有个容易忽略的细节:不要用整行数据做相等比较,因为商品名和金额是不同的,整行永远不相等。必须显式指定"分组键",也就是orderId。

我的习惯是先写一个抽象的合并方法,把"哪些列参与合并"和"按什么字段分组"做成配置:

// 合并配置:列索引 -> 分组字段 const mergeConfig = { 0: 'orderId', // 第 0 列按 orderId 分组 1: 'orderId', // 第 1 列跟着 orderId 一起合并 2: 'orderId' }

用"列索引映射到分组字段"而不是直接写死逻辑,好处是后面加列、删列只改配置,不用动算法。

2.2 span-method 的返回值到底怎么给

大部分表格库的 span-method 签名长这样:({ row, column, rowIndex, columnIndex }) => [rowspan, colspan]。返回[2, 1]表示这个单元格占两行一列,返回[0, 0]表示这个单元格被合并掉、不渲染。这三个返回值的含义必须刻在脑子里,因为后面所有的错位问题几乎都源于对它理解偏差:

返回值含义后续该行该列的表现
[n, 1]且 n > 1纵向合并 n 行本次渲染,占 n 行高度
[1, 1]不合并正常显示
[1, n]且 n > 1横向合并 n 列本次渲染,占 n 列宽度
[0, 0]被上面的单元格吃掉不渲染,位置留给上面的合并格

关键在于[0, 0]:它不是一个"空单元格",而是"这个位置已经没你了"。如果该给[0, 0]的地方你给了[1, 1],就会出现重复内容;如果该给[1, 1]的地方你给了[0, 0],就会丢数据、错位。

2.3 最朴素的实现和它的两个硬伤

按照"向下数相同值"的思路,能写出这样一段:

const spanMethod = ({ row, column, rowIndex, columnIndex }) => { const key = mergeConfig[columnIndex] if (!key) return [1, 1] // 只处理每一组的第一个单元格 if (rowIndex > 0 && tableData[rowIndex - 1][key] === row[key]) { return [0, 0] } // 向下数有多少个相同值 let span = 1 for (let i = rowIndex + 1; i < tableData.length; i++) { if (tableData[i][key] === row[key]) span++ else break } return [span, 1] }

逻辑上是对的,跑起来也能出效果。但它有两个硬伤:

第一,时间复杂度是 O(n²)。表格 1000 行,span-method 会被调用 1000 × 列数次,每次还要往后面扫一遍,数据量一大渲染直接卡。表格库对 span-method 的调用非常频繁,不是每帧调用,但数据变化时会全表扫一遍,这个开销不能无视。

第二,它假设相同值一定连续。一旦用户点了排序,或者做了筛选,同组数据被打散,这个"向下数"的逻辑立刻失效——它只会数到第一个不同值就停,剩下的散落数据各自变成独立单元格,视觉效果就是"合并断断续续"。而现实中表格排序几乎是标配功能。

3. 把 O(n²) 压成 O(n):预计算缓存表

3.1 用一次遍历把所有 rowspan 提前算好

正确做法是把"算"和"用"分开:数据变化时一次性遍历出所有单元格的合并信息,存成一张缓存表,span-method 只负责查表。这样无论表格库调用多少次,计算都只发生一次。

import { computed } from 'vue' // 生成缓存:cache[rowIndex][columnIndex] = [rowspan, colspan] const spanCache = computed(() => { const cache = [] const data = tableData.value const total = data.length // 每一列的"当前组的起始行"和"累计行数" const cursors = {} Object.keys(mergeConfig).forEach(col => { cursors[col] = { start: 0, count: 1 } }) data.forEach((row, rowIndex) => { cache[rowIndex] = [] Object.keys(mergeConfig).forEach(colKey => { const colIndex = Number(colKey) const field = mergeConfig[colKey] const value = row[field] if (rowIndex === 0) { cursors[colKey] = { start: 0, count: 1 } cache[rowIndex][colIndex] = [1, 1] return } const prevValue = data[rowIndex - 1][field] if (value === prevValue) { // 与上一行同组:累加当前组行数 cursors[colKey].count++ // 当前单元格被上面的吃掉 cache[rowIndex][colIndex] = [0, 0] // 但组首单元格的 rowspan 要更新 const startRow = cursors[colKey].start cache[startRow][colIndex] = [cursors[colKey].count, 1] } else { // 新的一组 cursors[colKey].start = rowIndex cursors[colKey].count = 1 cache[rowIndex][colIndex] = [1, 1] } }) }) return cache })

思路是:维护"每一列当前组的起始行号和已累计行数",遍历到某行时,如果和上一行同组,就把组首单元格的 rowspan 更新成更大值、当前单元格标记为[0, 0];如果不同组,就开启新组。全程一遍遍历,O(n × 合并列数),1000 行也就几毫秒。

span-method 变成纯粹查表:

const spanMethod = ({ rowIndex, columnIndex }) => { const rowCache = spanCache.value[rowIndex] if (!rowCache) return [1, 1] return rowCache[columnIndex] || [1, 1] }

3.2 为什么"边遍历边回头改缓存"是安全的

刚接触这个写法的人通常会有一个疑问:我遍历到第 3 行的时候,去修改第 0 行的缓存,这不会出问题吗?答案是安全,因为修改的永远是已经处理完的、前面行的、组首位置的数据,而且修改的是 rowspan 这个只往上加不往下减的数值。不会出现"后面还要用这行数据"的情况——span-method 是在全部计算完成后才被调用的,缓存表已经定型了。

但这里有个顺序陷阱值得单独提:cache[rowIndex] = []必须在对该行的列做赋值之前。我就见过有同事写成在同一行里既初始化又读旧值,结果报cache[rowIndex][colIndex]为 undefined 的错。数组要先建出来,这是硬规矩。

3.3 横向合并的加法逻辑

纵向合并搞明白了,横向合并是同一套思路换个轴:判断"同一行的相邻列是否该合并"。比如"合计"行的金额跨两列显示,或者某些展示型表格的标题跨列。区别在于横向合并通常不需要按内容分组,而是按列配置写死:

// 配置:某一行某列开始跨几列 const colspanConfig = { 'summary-0': { start: 0, span: 2 } // 汇总行第 0 列起跨 2 列 }

实现上,span-method 里先查纵向,未命中再查横向,横向的[1, n]和后续列的[0, 0]成对出现。这里要提醒的是,同一个单元格同时跨行又跨列是表格逻辑的地狱,[2, 3]这种写法确实语法上存在,视觉上也能渲染,但只要行列数据稍有变化,边框和内容对齐就会出现诡异错位。我个人的经验是:要么纵向合并,要么横向合并,同一张表里不要试图让一个单元格两个方向都跨,除非表头那种静态结构。

4. 合并之后,斑马纹和 hover 全乱了的修复路径

4.1 斑马纹是按"数据行"算的,不是按"视觉行"算的

这是合并单元格后最扎眼的副作用:原本第 1、3、5 行带浅底色,合并之后因为第 2、3 行被[0, 0]吃掉了,视觉上只剩第 1 行有底色,然后第 4 行又有,看起来像斑马纹坏了。

本质原因是斑马纹的判定依据是rowIndex % 2,而 rowIndex 是数据行索引,被合并掉的行依然占着索引。有三种修法,按场景选:

第一种,接受它,改用"分组斑马纹"。既然合并的语义就是"这几行是一组",那斑马纹也应该按组着色。在计算 spanCache 的时候顺手算出每一组的分组序号:

// 在预计算时同时记录每行的"组序号" const groupIndexCache = computed(() => { const result = [] let group = -1 const data = tableData.value data.forEach((row, i) => { if (i === 0 || row[groupKey] !== data[i - 1][groupKey]) { group++ } result[i] = group }) return result }) // 行 class 回调 const rowClassName = ({ rowIndex }) => { return groupIndexCache.value[rowIndex] % 2 === 0 ? 'row-even' : 'row-odd' }

这样整组同色,视觉上反而比原来更清晰——一眼能看出分组边界。

第二种,去掉斑马纹,改用组间分隔线。合并单元格的表格本来就靠合并格来划分区域,斑马纹属于冗余信息。加一条组底部的边框线,效果更干净:

const rowClassName = ({ row, rowIndex }) => { const data = tableData.value const isGroupEnd = rowIndex === data.length - 1 || data[rowIndex + 1][groupKey] !== row[groupKey] return isGroupEnd ? 'group-end-row' : '' }
.group-end-row > td { border-bottom: 2px solid #e5e7eb; }

注意这个选择器要打到> td,因为大部分表格库的行 class 加在tr上,边框在td上。

第三种,只对未合并的列做斑马纹。严格来说这需要给每个 td 单独算 class,得用单元格 class 回调而不是行回调,实现复杂、收益一般,我实际项目中很少这么干。

4.2 hover 高亮整组,而不是高亮单行

默认 hover 效果是鼠标所在行变浅色背景,合并之后你 hover 在商品行上,订单号那一格因为是合并格、归属第一行,看起来毫无反应。用户的直觉是"我 hover 这一组,这一组应该整体高亮"。

思路很直接:hover 时拿到鼠标行的 rowIndex,反查出它的组序号,然后把同组所有行都加高亮 class。表格库一般提供cell-mouse-enter事件,配合一个响应式变量:

const hoverGroup = ref(-1) const onCellEnter = ({ rowIndex }) => { hoverGroup.value = groupIndexCache.value[rowIndex] } const onCellLeave = () => { hoverGroup.value = -1 } const rowClassName = ({ rowIndex }) => { const classes = [] if (groupIndexCache.value[rowIndex] % 2 === 0) classes.push('row-even') if (hoverGroup.value === groupIndexCache.value[rowIndex]) classes.push('row-hover') return classes.join(' ') }
.row-hover > td { background-color: #eff6ff; }

这里必须注意的坑:合并单元格本身不吃 hover。[0, 0]的格子压根不渲染,[n, 1]的格子虽然渲染了,但它的背景色由它自己的 td 决定,鼠标在其他行时它不会变。所以上面的.row-hover > td只能染到未合并的列和组首行的合并格。要连合并格一起变色,得接受"合并格跟随组首行的状态"这个事实——因为合并格本质上就是第一行的格子被拉长了。实际效果是:hover 组内任意行,合并列因为只在第一行有实体,只有第一行的合并格变色,纵向看会显得只变了上半截。

解决这个只有两条路:一是给合并格单独设置背景,让它不参与 hover 变色(看起来更稳,但失去了整体高亮感);二是放弃行高亮,改用左侧一条竖线指示当前组。我现在的项目用的是第二种,加一个::before伪元素竖条,视觉上更克制,也没有半截变色的违和感。

4.3 边框坍塌和固定列的连锁反应

合并单元格之后边框会重绘,这里有两个具体现象:合并格与非合并格交界处的边框可能变粗或消失;固定列(左侧固定、右侧固定)因为用了绝对定位,合并格的宽度计算可能对不齐。

边框问题的根因通常是表格库用border-collapse: collapse加伪元素画线,合并格会吃掉相邻的 border。稳妥的做法是显式给合并格补边框:

.el-table td[rowspan]:not([rowspan="1"]) { border-bottom: 1px solid #ebeef5; }

选择器里的[rowspan]:not([rowspan="1"])是抓"真正合并了的格子",不同库渲染出的属性不同,有的是rowspan属性,有的加 class,得用开发者工具确认。这一步没有通用答案,必须结合具体库调试,但排查思路是固定的:先在 Elements 面板里找到合并格,看它的边框来自哪个选择器,再决定覆盖谁。

固定列的问题更隐蔽。因为固定列是另一层 DOM,合并格的rowspan在固定层里会被重新计算,如果左右两侧的分组数据不一致(比如右边少了某列导致分组键不同),就会出现右边合并错位。我的处理经验是:让所有固定列参与合并的字段保持一致,也就是固定列和主体列用同一份 spanCache,别单独算。

5. 排序、筛选、分页来袭时,合并逻辑怎么不崩

5.1 排序后同组数据被打散,是必然会踩的坑

用户点了一下金额列降序,A001 的三条商品可能被拆到三个位置,orderId不再连续。此时按"相邻行比较"的合并算法直接失效,合并会碎成很多单个格子。

这个问题没有"算法层面"的完美解,因为它是个逻辑冲突:排序的语义是打散,合并的语义是聚合,两者天生打架。所以必须先做产品层面的决策,技术层面只有三种应对:

方案做法适用场景
禁用合并期间的排序排序时关闭合并,恢复后重开合并仅用于展示,排序是次要功能
排序键强制包含分组键排序时把分组键作为第一排序条件分组是业务主维度,组内排序才有意义
分组展示替代合并改用分组表头、树形展开数据层级复杂,合并已不够表达

实际项目里我用得最多的是第二种:把分组键加进排序参数的排序字段列表里,用户点金额降序,实际传给后端的排序是orderId asc, amount desc。这样组不会被打散,组内商品按金额排序,合并关系天然保持。前端本地排序时也是同一个思路,排序函数里先比分组键再比目标字段。

如果业务坚持"必须按金额全局排序、同时要合并订单号",那就只能退回第一种——排序时把 span 全部返回[1, 1],用一个标志位控制:

const sorting = ref(false) const spanMethod = ({ rowIndex, columnIndex }) => { if (sorting.value) return [1, 1] // 排序期间不合并 const rowCache = spanCache.value[rowIndex] if (!rowCache) return [1, 1] return rowCache[columnIndex] || [1, 1] }

体验上不算优雅,但它诚实——至少不会有错位的视觉 bug。

5.2 筛选和分页:合并范围是"当前页"还是"全量"

这是另一个决策点。分页每页 10 条,A001 有 12 条商品,跨了两页,那第一页末尾的 A001 应该显示"跨 10 行"还是"跨本页内的行数"?答案取决于你的合并意味着什么。

如果合并代表"这些行属于同一订单",那跨页应该断开,因为分页本身就是一种截断,用户在第二页看到 A001 重复出现是合理的。我的实现做法是:缓存计算只基于当前页数据,即spanCache的输入是currentPageData而不是allData。这样每页独立算,天然正确。

const currentPageData = computed(() => { const start = (page.value - 1) * pageSize.value return allData.value.slice(start, start + pageSize.value) }) const spanCache = computed(() => buildSpanCache(currentPageData.value, mergeConfig))

对应的渲染数据源也用currentPageData,保证 spanCache 的索引和渲染行索引对齐。这里最容易出的错是:缓存用全量数据算、渲染用分页数据,索引完全错位,合并位置全乱。缓存的数据源和渲染的数据源必须是同一个数组引用或至少同一份顺序。

筛选同理,筛选后的数组重新算缓存即可。因为缓存是computed,依赖currentPageData,数组一变它自动重算,不用手动触发。这也是我把缓存设计成 computed 而不是手动调用的原因——少一个容易忘的调用点。

5.3 用 watch 还是 computed,我踩过的选择失误

最初我为了"性能",用watch监听数据变化再手动重算,结果踩了一串问题:初始数据直接赋值不触发(没设 immediate)、异步请求回来顺序和 watch 时机错位、多处修改数据要记得多处触发重算。折腾了两天最后全改回 computed。

现在的判断标准很简单:只要计算是"从数据派生出来的纯函数",就用 computed。合并缓存完全符合——输入是当前页数据和合并配置,输出是缓存表,没有副作用,没有外部状态。computed 的缓存机制还能保证多次访问不重复计算,这一点恰好是表格渲染最需要的。唯一需要注意的坑是 computed 返回的是新数组,模板里如果用它做深度比较会永远不相等,别在 computed 结果上做===判断。

6. 几个非常具体、非常容易卡住的实现细节

6.1 空值、null 和"看起来一样"的字符串

合并判断用的是===,这就意味着null === null为 true,两个空值会被合并成一个大空格子。这在多数场景下是错的——"没有值"不等于"是同一组"。所以合并前要先过滤空值:

const canMerge = (a, b) => { if (a === null || a === undefined || a === '') return false if (b === null || b === undefined || b === '') return false return a === b }

另一个更隐蔽的坑:0和'0'、123和'123',接口返回的数据类型不统一时,===会判定不相等,导致本该合并的没合并。我的处理是在分组前对值做统一转换,数字类字段统一String(value)再比较。至于NaN,它和任何值都不相等(包括自己),如果某列可能出现NaN,得单独判断Number.isNaN。

还有一种恶心情况:字符串里带了不可见字符,界面上看完全一样,比较却不相等。这种情况调试时怎么看都正常,只能靠打日志把字符编码打出来才找得到。我遇到过一次,是后端拼接时多了个尾随空格。教训是:分组字段尽量用 ID 类值,别用展示用的文本值。

6.2 表格宽度的抖动

合并之后某些列的可视宽度会变化,尤其是用了min-width的列——本来每行内容撑开的宽度,合并后只剩一格内容,宽度塌下去,整表列宽跳动,还会影响固定列的定位。

稳的办法是给参与合并的列指定固定width或至少一个够用的min-width,不要依赖内容自适应。合并列的语义就是"这里展示一个汇总性的值",宽度固定反而更整齐。如果某些内容确实长,用text-overflow: ellipsis截断加 tooltip 提示:

.cell-ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

还有一个连锁问题:合并后行高会增加,如果表格设置了max-height出现纵向滚动,滚动到合并格位置时,虚拟滚动或者固定表头的对齐可能出偏差。用虚拟滚动的表格(比如大数据量场景)对 rowspan 支持普遍不好,甚至完全不支持。这一点必须提前确认,数据量上千行又要合并,优先考虑分组展示或者只合并表头,别硬上虚拟滚动。

6.3 合并格里的内容垂直居中

合并 5 行的单元格,内容默认是顶部对齐的,看起来像悬挂在半空。加个居中样式:

.el-table .cell { display: flex; align-items: center; justify-content: center; }

但注意这会影响到所有单元格,包括正常单元格和表头。更精准的做法是只对合并格生效,得用行 class 加列定位的组合选择器,或者让表格库的cell-class-name回调给合并格单独加类名。我一般是在 span-method 里判断这个格子是否是组首且 rowspan > 1,是的话通过cell-class-name回调给它加merged-cell类,然后只对.merged-cell做居中。这样不会污染其他单元格的排版。

需要提醒的是:display: flex会改变单元格内部的盒模型,如果格子里有<el-tooltip>这类需要参照定位的元素,flex 布局下可能出现定位错误。稳妥点用line-height或者 flex 的justify-content: center单独控制主轴,别一股脑全设。

6.4 表格为空和只有一行的边界

空数组时 spanCache 是[],span-method 里spanCache.value[rowIndex]得到 undefined,直接 return[1, 1]兜住,不会报错。但只有一行数据时,所有格子都是[1, 1],视觉上等于没合并,这个是对的,不用特殊处理。

真正要处理的是只有一组但行数很多的情况——比如所有数据都属同一订单,合并后整张表就一条大格子横跨 20 行,中间的商品明细全被吃掉了。这不是 bug,是需求理解偏差:订单号可以合并,商品列绝对不能参与合并。所以合并配置里列的白名单要精确,只列真正需要合并的列,别用"排除某些列"的反向写法,容易漏。

7. 我的落地顺序:从配置到验证

真到项目里落地,我一般不直接写 spanMethod,而是按这个顺序推进,能少走很多弯路。

第一步定分组键。跟需求方确认清楚"哪几行算一组",把它落到具体字段上,最好是有唯一性的 ID。这一步错了后面全白干。

第二步写 buildSpanCache 这个纯函数,输入数据数组和配置,输出缓存表。这个函数完全独立,可以先在控制台里用假数据验证,把它跑对了再接表格。

第三步接 span-method,只有十几行查表逻辑。这时如果视觉效果不对,问题一定在缓存或者返回值语义上,排查范围很小。

第四步处理交互副作用:斑马纹、hover、边框。这三块建议一次做完,因为它们互相影响,改一个可能带出另一个的新问题。

第五步做动态变化的验证,也就是排序、筛选、分页、增删行。这是最容易被跳过的环节,但恰恰是线上 bug 的高发区。我的验证清单是固定的:切换排序后合并是否正确、筛选后是否有孤立的合并格、翻页后跨页数据是否断开、删除中间的某一行后 rowspan 是否重算、新增一行同组数据后是否并入。这五个场景全过一遍,基本能挡住绝大多数问题。

最后说一个我自己的偏好:如果合并的列超过三列,我会重新考虑这个需求是否该用表格来表达。三列以上的纵向合并,表格的可读性其实是在下降的,用户很难快速对齐行与行的关系,反而分组卡片或者树形结构更清楚。合并单元格是个好工具,但它解决的问题是"视觉上的重复",不该被用来承载复杂的数据层级。这个判断我没在文档里见过,但做了几个项目下来,感觉挺准。

补充一句 Vue 2 的差异:span-method的签名、[0,0]语义都一样,主要区别在computed的写法和你用this还是ref。如果是 Options API,把 spanCache 放 computed 里,方法里用this.tableData访问即可,其他逻辑不用改。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进