后台管理系统里"相同值合并单元格"这个需求,做过的都懂:产品经理一句话"把相同的合并一下,跟 Excel 一样",前端要忙活半天。Element Plus 的el-table默认情况下只是老老实实把数据渲染成行,它不知道哪些格子该合并、跨几行跨几列,这些全都得靠span-method这个钩子自己算。更麻烦的是,业务里"哪些数据相同"往往是运行期才确定的,合并规则不能写死,得有一套动态计算的算法兜底。这篇文章把我从需求拆解、算法设计、完整落地到踩坑复盘的整个过程串一遍,给正在跟 Element Plus 表格动态合并死磕的朋友一份可以直接抄作业的参考,尤其适合 Vue 3 + Element Plus 技术栈、正在做报表或台账类功能的前端同学。
1. 需求拆解:静态合并和动态合并根本不是一回事
1.1 官方能力给的边界到底在哪
先看最直观的需求形态。比如库存台账里,同属"电子产品"分类的商品有三行,"电子产品"这个字段值连续出现了 3 次,期望的效果是第一行显示分类并跨 3 行,后面两行不再重复显示。Element Plus 官方文档提供了span-method,它允许你自定义"每个单元格跨几行几列"。
最简单的静态写法长这样:
function staticSpan({ rowIndex, columnIndex }) { // 第 0 行第 0 列:跨 2 行 if (rowIndex === 0 && columnIndex === 0) { return { rowspan: 2, colspan: 1 } } // 第 1 行第 0 列:被上一行合并覆盖,隐藏自己 if (rowIndex === 1 && columnIndex === 0) { return { rowspan: 0, colspan: 0 } } }这段代码能跑,但问题一眼就能看出来:所有判断都是硬编码的。行数一变、数据顺序一变、加一列减一列,这条规则立刻失效。而真实业务里,你根本不知道运行时哪几行是相同的——"哪些数据相同"这件事,要等数据到达浏览器之后才算得出来。
所以核心矛盾摆在面前:span-method是一个"逐单元格回调"机制,它只告诉你"当前格子在第几行第几列",然后把合并决策权全权交给你。我们在回调里要做的事,是尽早把"哪些行属于一个合并组、组有多大"算好,回调执行时只需要查表。把这个认知放在第一位,后面的实现才不至于跑偏。
1.2 什么业务场景会触发动态合并
我做过的项目里,最常见的几类需求分别是:
- 月度/季度统计报表:同一个月的多条记录,月份列跨行合并显示;
- 商品/类目台账:一个分类下挂多个子商品,分类单元格纵向合并;
- 数据字典/配置列表:同一种类型下的多条配置项,类型列合并;
- 科目对账明细:同一条一级科目下的多笔流水,科目列合并。
这类需求有几个共性特征,我理了一下:
- 数据已经按某个合并维度排序,相同值的行是连续出现的;
- 合并的是"相同且相邻"的行,不是全局去重;
- 合并组的大小和起始位置在设计阶段完全未知;
- 数据变化(增删改、排序、筛选)后,合并结果需要跟着变。
"相同值连续出现"这个前提特别关键。算法层面,动态合并做的是"区间标记",只处理相邻的相同值。如果数据是乱序的,第一行是"电子产品"、第三行又冒出一个"电子产品",中间隔了其他分类,按常规业务逻辑不应该硬合并。大多数产品经理心里的预期其实是"全局合并",但全局合并会破坏原始行数据顺序,交互上更容易出问题。我的原则是:合并字段的排序责任交给数据源头,谁给的数据谁负责排好,前端不在渲染层默默重排。原因我后面在避坑章节展开。
下面用一个典型数据举例:
| 分类 | 商品名称 | 库存数量 |
|---|---|---|
| 电子产品 | 手机 | 120 |
| 电子产品 | 电脑 | 80 |
| 电子产品 | 平板 | 45 |
| 家居用品 | 沙发 | 30 |
| 家居用品 | 桌椅 | 67 |
期望的展示效果是:"电子产品"跨 3 行,"家居用品"跨 2 行,组内后续行不再显示分类字段。这个效果,就是接下去所有代码要实现的目标。
2. span-method 核心机制深挖
2.1 回调参数里藏着哪些细节
用官方文档的写法,span-method接收的回调函数参数是一个对象。以 Element Plus 2.x 为例,对象里包含row、column、rowIndex、columnIndex四个字段。实际开发中,几个容易被忽略的点先讲清楚。
第一,columnIndex是列的渲染索引,不是列的prop。如果你的表格第一列是"序号"列(type="index"),它的 columnIndex 是 0,真正要合并的数据列是 1。写合并判断时,列的位置映射关系要心里有数。为避免这类问题,我通常用column.property来判断"当前列是否参与合并",而不是靠索引,这样即使列顺序调整、中间插入新列,合并逻辑依然稳定。
第二,rowIndex是行的原始索引。这一点非常关键:虽然视觉上合并组的第二、第三行被隐藏了,但rowIndex依然是它们在原始数据数组里的真实下标。这意味着我们可以放心用spanMap[rowIndex]这样的方式做查询,不需要担心索引被压缩后的错位。
第三,回调里能拿到row和column对象本身。row是当前行的完整数据,column除了property,还有label、colIndex等属性。理论上你可以直接在回调里根据row的具体字段值实时计算合并,但我强烈不建议这么做——每次单元格渲染都跑一遍遍历和比较,性能扛不住。预计算 + 查表才是正确姿势。
2.2 返回值的几种写法与真正的坑
span-method返回值有三种常见形式:
// 写法一:返回对象 return { rowspan: 2, colspan: 1 } // 写法二:返回数组,两个元素分别是 rowspan 和 colspan return [2, 1] // 写法三:不返回任何值,等价于返回 { rowspan: 1, colspan: 1 } // 适用于不需要合并的单元格 return这里最绕的一个细节:当一行被上一行的rowspan覆盖时,必须返回{ rowspan: 0, colspan: 0 }。如果错误地返回{ rowspan: 1, colspan: 1 },那么被覆盖行的内容不会隐藏,视觉上出现"分类一行一行重复显示"的乱象,合并效果直接失效。
关于{ rowspan: 0, colspan: 0 }的行为,不同版本的元素组件实现略有差异,但总体语义一致:该单元格不占视觉空间。在我常用的版本里,返回 0 后被覆盖的单元格不会产生额外的高度,放心使用。
3. 动态合并的核心算法:预先计算合并区间
3.1 单列合并:一套干净利落的计算逻辑
理解span-method的机制之后,核心算法其实一句话就能说清:遍历数据,把字段值相同的连续行归成一组,用一个 Map 记录每组起始行号到组大小。
function computeRowSpans(data, field) { const spanMap = {} if (!data.length) return spanMap let start = 0 let count = 1 for (let i = 1; i <= data.length; i++) { const same = i < data.length && data[i][field] === data[start][field] if (same) { count++ } else { spanMap[start] = count start = i count = 1 } } return spanMap }这里有两个设计细节值得说道。第一,比较用的是data[i][field] === data[start][field],不是和data[i-1]比较。虽然结果基本一致,但用"组内起始元素"做锚点,语义更清晰,后面扩展到多列联合分组时也更自然。第二,循环条件是i <= data.length,多跑一轮就为把最后一组也写进spanMap——这是最容易遗漏的边界,漏掉的话合并结果的最后一段永远算不出来,表现就是"中间的合并都正常,最后一组数据孤零零地逐行显示"。
spanMap里为什么连大小是 1 的组也要记录?这就必须结合span-method的查询逻辑来看:
function spanMethod({ rowIndex, columnIndex }) { if (columnIndex !== 0) { return { rowspan: 1, colspan: 1 } } const rowspan = spanMap[rowIndex] if (rowspan) { return { rowspan, colspan: 1 } } return { rowspan: 0, colspan: 0 } }spanMap[rowIndex]有值,说明该行是一个组的起始行,rowspan就是组大小(至少 1);spanMap[rowIndex]取不到值,说明该行是被其他起始行覆盖的非起始行,返回 0 隐藏它。如果只记录大小大于 1 的组,那么"独立出现的行"在spanMap里也取不到值,会被误判成被覆盖行隐藏掉——整排数据莫名其妙消失。所以我选择把所有组都记录下来,独立行的rowspan是 1,正常展示。
3.2 多列合并:层级边界必须死守
单列合并往往不够。再拿库存台账举例,分类下面还有品牌,品牌列同样需要合并,但必须保证只在同一个分类内部合并。分类 A 下有个"品牌X",分类 B 下也有个"品牌X",这两个 "品牌X" 绝不能合并成一条。
这个约束用"各列单独计算连续区间"的方法是做不到的。两级分组的数据结构不是两个平行数组,而是树状的:先按分类分组,每个分类里面再按品牌分组。正确的计算方式是逐层递归:
function computeMergeMap(data, fields) { const mergeMap = {} function walk(list, baseIndex, depth) { if (depth >= fields.length) return const field = fields[depth] if (!mergeMap[field]) mergeMap[field] = {} let i = 0 while (i < list.length) { let j = i + 1 while (j < list.length && list[j][field] === list[i][field]) { j++ } // 每一组都记录,大小为 1 的组正常展示,大于 1 的组触发合并 mergeMap[field][baseIndex + i] = j - i // 只在当前分组的元素范围内继续递归下一维度 walk(list.slice(i, j), baseIndex + i, depth + 1) i = j } } walk(data, 0, 0) return mergeMap }递归的巧妙之处在于walk(list.slice(i, j))——每次往下一层处理时,传入的是当前分组的子集,这样下一层字段的分组天然被限制在上一层分组的边界内,不可能跨分类错误合并。baseIndex则保证子分组的行号映射回原始数据时依然准确。
配合使用时,需要注意fields的顺序与数据排序优先级保持一致:先按分类排序,再按品牌排序,那么传入顺序必须是['category', 'brand']。顺序反了,递归分组的边界就是错的,出来的合并结果一塌糊涂。
4. 完整的落地实现:Vue 3 + Element Plus
4.1 一套可复制的完整组件示例
完整跑通一个贴合实际项目的示例。场景就是上面的库存台账:category和brand两列按层级合并,其余列保持默认渲染。
<template> <el-table :data="tableData" :span-method="spanMethod" border> <el-table-column prop="category" label="分类" width="120" /> <el-table-column prop="brand" label="品牌" width="120" /> <el-table-column prop="name" label="商品名称" /> <el-table-column prop="stock" label="库存" width="100" /> </el-table> </template> <script setup> import { computed, ref } from 'vue' const tableData = ref([ { category: '电子产品', brand: '品牌A', name: '手机', stock: 120 }, { category: '电子产品', brand: '品牌A', name: '平板', stock: 45 }, { category: '电子产品', brand: '品牌B', name: '电脑', stock: 80 }, { category: '家居用品', brand: '品牌C', name: '沙发', stock: 30 }, { category: '家居用品', brand: '品牌C', name: '桌椅', stock: 67 }, ]) const mergeColumns = ['category', 'brand'] const mergeMap = computed(() => computeMergeMap(tableData.value, mergeColumns)) function spanMethod({ row, column, rowIndex }) { const prop = column.property if (!mergeColumns.includes(prop)) { return { rowspan: 1, colspan: 1 } } const rowspan = mergeMap.value[prop]?.[rowIndex] if (rowspan) { return { rowspan, colspan: 1 } } return { rowspan: 0, colspan: 0 } } function computeMergeMap(data, fields) { const mergeMap = {} function walk(list, baseIndex, depth) { if (depth >= fields.length) return const field = fields[depth] if (!mergeMap[field]) mergeMap[field] = {} let i = 0 while (i < list.length) { let j = i + 1 while (j < list.length && list[j][field] === list[i][field]) { j++ } mergeMap[field][baseIndex + i] = j - i walk(list.slice(i, j), baseIndex + i, depth + 1) i = j } } walk(data, 0, 0) return mergeMap } </script>这段代码放进 Vue 3 单文件组件里就能直接跑。几个设计决策说明一下。
第一,mergeMap用computed包装,依赖tableData。每次数据增删改、重新赋值,合并信息自动重算,span-method拿到的永远是最新结果——这就是"动态合并"的灵魂所在。千万不要把计算逻辑直接塞进spanMethod里,那样数据的响应式更新会和合并逻辑脱节。
第二,列判断用column.property,与列索引解耦。即使表格第一列是序号列、或者在中间插入了操作列,代码都不需要改。
第三,mergeColumns里没有name和stock,这两列直接走默认返回值。显式写{ rowspan: 1, colspan: 1 }是明示"这列不参与合并",团队协作时别人读代码更容易理解意图。
4.2 异步数据下的表现
表格数据通常来自接口,组件初始化时tableData是空数组,异步返回后才填充数据。这个过程中computed会经历两个阶段:空数组时mergeMap是空对象,理论上span-method查询任何rowIndex都取不到值。但这里有个实际经验:空数组时表格根本没有行,span-method压根不会被调用,所以不会出现"整表单元格全部消失"的灾难。
真正需要注意的是另一件事:别用占位行初始化表格。有些同学喜欢在数据没返回时先塞几行占位数据(比如{ category: '', name: '', stock: 0 }),避免页面跳动。但空格子也会被当作一个分组参与合并计算,数据返回后旧的分组信息可能干扰新数据的合并结果。我的建议是:数据没到位就保持空数组,等接口返回后再一次性赋值。
4.3 跨列合并的场景简单说一句
动态合并的需求大部分集中在 rowspan(纵向合并),但偶尔也有 colspan(横向合并)。一个典型场景是:某些列在不同条件下显示相同的汇总信息,产品希望横向合并成一个宽单元格。
colspan 的实现逻辑与 rowspan 完全镜像:按行遍历,在每一行内找相邻且值相同的列,做区间记录。算法思路一样,只是"行"和"列"的概念对调。不过说实话,横向合并的需求我碰到的概率极低——表格列数在设计阶段就定了,"多列显示相同数据"往往意味着数据结构设计不合理。这里不展开细讲,避免把文章重心带偏。
5. 常见问题与排查技巧实录
5.1 合并后表格边框断裂
这是合并之后最容易冒出来的视觉问题。el-table开启border后,合并单元格的连接处常常出现多余的竖线,或者横线断裂,看起来像表格破了口。
原因在于:Element Plus 的边框样式是基于每个 td 渲染的,合并后部分 td 被rowspan: 0隐藏,它的边框自然消失,但相邻列的边框照常绘制,于是出现视觉断口。
处理思路分两个层次。如果项目对像素级美观要求不高(后台报表大多如此),接受轻微瑕疵即可,别在这种地方消耗太多时间。如果视觉稿抠得细,就得给合并区域单独处理样式——在span-method返回 rowspan 的时候,顺带记录合并起始单元格的行列位置,渲染完成后用 DOM 补样式。另一个更干净的做法是给合并列写#default插槽,在起始单元格上统一管理内容区域和边框 class。实测下来,插槽方案在多数报表场景里已经够干净。
5.2 排序和筛选把合并搞崩了
这是动态合并最容易翻车的场景。el-table 内置的sortable排序和filters筛选,会直接改变行的顺序或减少行的数量。如果mergeMap还是基于排序前、筛选前的数据算出来的,那渲染出来就是灾难:某个 rowspan 为 3 的起始行,排序后原索引对应的行已经换成了别人,合并范围就串了。
踩过几次坑之后,我得出的结论是:动态合并和 el-table 内置排序、筛选最好别同时用。业务确实需要排序和筛选时,走数据层面自己处理——排序、过滤都作用于同一个tableData,改的是数据源本身,mergeMap基于computed依赖tableData,自然同步,不会出乱子。这也是我在前面反复强调"数据源头负责排序"的原因:让表格只剩渲染,逻辑越简单越不容易出错。
5.3 null 和 undefined 参与合并的坑
数据里某个合并字段是null时,null === null结果成立,所以算法不会报错,但行为不一定符合业务预期。比如六行数据里,有两行的category字段都是null且相邻,它们会被合并成一个空白大单元格。用户看到的效果是"这段数据凭空消失了",容易引起误解。
我的处理方式是在计算合并信息之前先做字段值归一化:
function normalizeFieldValue(value) { if (value === null || value === undefined || value === '') { return '__EMPTY__' } return value }算法内部比较时统一走normalizeFieldValue(data[i][field]),空值之间是否合并、空值与非空值是否相邻,都变成可控的规则。想合并就合并,想分开就在这一层拦一下,完全由业务说了算。
5.4 性能优化原则:span-method 里只做查询
el-table 渲染时,span-method会对每个单元格调用一次。500 行数据、6 列,调用次数就是 3000 次。如果在回调里现算合并规则(比如每次遍历数据找相同值),数据量一大页面立刻卡成 PPT。
正确原则是:重计算放到computed里,span-method只做 O(1) 查表。所有示例代码都遵循这个原则——computeMergeMap遍历一次数据构建 Map,之后每个单元格的合并决策只是一个对象取值操作。数据量到了几千上万行,瓶颈也不在合并算法上,而是 el-table 本身渲染这么多行的开销。那种场景正确解法是分页或虚拟滚动,而不是在合并算法上抠性能。
5.5 问题速查表
| 问题现象 | 根本原因 | 处理方式 |
|---|---|---|
| 相同值跨组被错误合并 | 数据未排序,相同值不相邻 | 在数据源头按合并字段排序 |
| 数据更新后合并结果不刷新 | 合并信息是普通变量而非 computed | 用 computed 包装合并计算逻辑 |
| 排序/筛选后合并全部错乱 | 内置排序筛选未纳入合并计算 | 在数据层面自行完成排序筛选 |
| 合并区边框断裂或有冗余线 | rowspan=0 隐藏 td 后边框样式断裂 | 插槽统一管理样式或接受轻微瑕疵 |
| 空白值合并成大空白格 | null/undefined 参与相等比较 | 归一化字段值后再参与合并计算 |
| 独立行数据消失 | 合并 Map 未记录大小为 1 的组 | 每一组都记录 rowspan,独立行返回 1 |
6. 封装一个可复用的合并 Hook
6.1 Hook 的设计与实现思路
动态合并这套逻辑在项目里出现的频率不低,建议直接封装成一个 Composition API Hook,沉淀成团队公共模块。
// useTableMerge.js import { computed } from 'vue' export function useTableMerge(data, mergeColumns) { const mergeMap = computed(() => { const map = {} function walk(list, baseIndex, depth) { if (depth >= mergeColumns.length) return const field = mergeColumns[depth] if (!map[field]) map[field] = {} let i = 0 while (i < list.length) { let j = i + 1 while (j < list.length && list[j][field] === list[i][field]) { j++ } map[field][baseIndex + i] = j - i walk(list.slice(i, j), baseIndex + i, depth + 1) i = j } } walk(data.value, 0, 0) return map }) function spanMethod({ row, column, rowIndex }) { const prop = column.property if (!mergeColumns.includes(prop)) { return { rowspan: 1, colspan: 1 } } const rowspan = mergeMap.value[prop]?.[rowIndex] if (rowspan) { return { rowspan, colspan: 1 } } return { rowspan: 0, colspan: 0 } } return { spanMethod } }页面里使用时极其简洁:
const { spanMethod } = useTableMerge(tableData, ['category', 'brand'])<el-table :data="tableData" :span-method="spanMethod" border>这个 Hook 的两个设计细节值得说明。第一,data参数接收的是一个ref,Hook 内部用data.value读取,computed 会自动追踪依赖;使用方只需传入已有的tableDataref 即可。第二,对外只暴露spanMethod,不暴露mergeMap——调用方不需要关心合并信息的内部结构,拿到函数直接绑给组件,使用成本降到了最低。
6.2 后续可以怎么扩展
封装完成后,扩展空间主要在三块。一是给不同列配置不同的合并策略,比如有些列合并连续相同值,有些列要跳过特定值不参与合并,可以在mergeColumns之外再传一个配置对象。二是暴露合并起始单元格的信息列表,外部拿到后可以为合并区域定制背景色、加粗等样式。三是兼容"部分列不参与响应式计算"的场景,比如某些列的合并规则固定不变,就没必要每次都重算整个 Map。
我在自己负责的两个项目里分别用这套 Hook 做过库存台账和月度销售统计。后来另一个同事接手其中一个项目,翻到这段代码时反馈是"不用看文档就会用"——只需要知道mergeColumns传什么字段,就能完成合并需求。这算是我对封装设计比较满意的一次:把复杂度收敛在函数内部,外部接口维持简单直观。
最后分享一个小经验。表格动态合并这个需求,技术难度不高,但"想明白"比"写出来"重要得多。核心就是三步:数据有序是前提、预计算合并区间是核心、span-method 里只做查询是原则。把这三条记牢,不管数据多复杂、列数多长、业务规则怎么变,都能用同一套思路快速落地。至于那些合并区间的边界、空值的坑、排序筛选的冲突,都属于第一次踩了、第二次就会绕开的常规操作,没什么可怕的。