我记得很清楚第一次接手这类需求是在做一个报表中心的时候后端直接把明细数据一股脑儿扔给前端同一家门店的十几条记录、同一个产品的连续多行表格长得又长又碎看起来特别费劲。当时第一反应是改后端让接口按维度分好组返回结果被一句“前端能处理就别动接口”给挡了回来。也是从那时候起我认认真真把Element Plus Table的合并单元格机制翻了个底朝天。这个组件本身提供的能力叫span-method本质上是一个回调函数它不关心你的业务数据长什么样只负责在你告诉它“第几行第几列要跨多少行、跨多少列”的时候把对应单元格的rowspan和colspan改掉渲染出来的效果就是视觉上的合并。这套机制用好了很多看起来复杂的报表需求都能稳稳落地。你可能一开始会觉得不就传个rowspan和colspan嘛有什么难的但实际用起来你会发现真正花时间的根本不是组件本身而是怎么把“业务上的重复数据”转换成“表格上需要合并的位置”也就是数据预处理这一步。如果这一步做不对表格要么合并得乱七八糟要么把不该合并的行也并到一块去了。这篇内容我打算从一个最简单的场景切入单列合并相同数据然后是通用的动态合并方案接着是多列联合判断的进阶处理最后把几个高频踩坑点一并列出来。每个环节都会附上能直接用的代码你拿去改一改就能落地。1. 先搞清楚Element Plus表格合并的工作机制1.1 span-method回调到底在做什么Element Plus的Table组件暴露了一个属性叫span-method类型是一个函数。这个函数会在表格渲染每个单元格的时候被调用组件内部会把当前单元格的位置信息作为参数传进来结构大致长这样{ row: 当前行的数据对象, column: 当前列对象, rowIndex: 当前行下标, columnIndex: 当前列下标 }我们需要在这个函数里返回一个数组比如[2, 1]含义是“当前单元格向下跨越2行向右跨越1列”。如果返回的是[1, 1]就表示正常显示不合并。你也可以返回一个对象形如{ rowspan: 2, colspan: 1 }效果完全一样只是写法不同。有一个细节特别容易忽略在Element Plus的实现逻辑里如果某个单元格的rowspan大于1那么它下面的那些被跨过的单元格实际上是不参与独立渲染的组件内部会给它们做一个隐藏处理。从用户视角来看看不出什么端倪但写代码的时候必须意识到span-method只会在真正需要渲染的单元格上被调用。也就是说你不能指望每次回调都拿到完整的行列信息来“按时计算”而是要提前把合并关系确定好回调里做的事更多是“查表”而不是“现场算”。1.2 合并相同数据的本质是“把连续分组翻译成坐标映射”现在回到业务场景。所谓合并相同数据通常是指某一列或者某几列的值相同的行在展示时合并成一个大单元格。那么一个非常自然的思路就出来了先扫描一次整个表格的数据把“哪些行属于同一个组”“这个组从第几行开始”“这个组一共占几行”这三个信息确定下来然后再把这份结果存成一个映射表span-method回调里只需要查表返回结果即可。这个思路不依赖组件内部的任何特殊行为数据结构也简单改起来不容易出错。对比一下很多人一开始会写的“在span-method里现场比较前后行数据”的做法就会发现查表法在数据量变大、合并规则变复杂的时候维护成本要低得多。我用一个具体例子来解释。假设我们有五条订单数据其中前两条的“城市”都是上海后三条都是北京1 上海 单号A 2 上海 单号B 3 北京 单号C 4 北京 单号D 5 北京 单号E如果要对“城市”列做合并那么上海那一列应该占2行北京那一列应该占3行。映射表可以设计成1号那一行城内列rowspan填2对照组第一行的列就是第二列2号那一行虽然是“上海”但因为上一行已经跨下来了这个单元格就不该单独渲染3号那样城内列rowspan填3城市轮盘的第二列4号、5号那两行同样不渲染城市列那映射表用行下标做键就能表达这个关系{ 0: [2, 1], 1: [0, 0], 2: [3, 1], 3: [0, 0], 4: [0, 0] }这里有一种习惯做样例[0, 0]表示当前格子被上面跨下来的大格子覆盖。不过在span-method回调里如果你明确返回[0, 0]实际渲染效果跟[1, 1]在某些版本的组件上看起来没区别——因为组件会把rowspan为0的格子直接设为隐藏。为了实现稳一点我习惯在代码里做显式判断只有我生成的“合并起点”行才返回实际的rowspan和colspan其他被覆盖的格子统一返回{ rowspan: 0, colspan: 0 }这样渲染出来就是正确的结果不会出现表格挤位的情况。2. 单列合并相同值的完整方案2.1 核心工具函数的实现先把最基础的工具函数写出来。它负责接收表格数据和需要合并的列名最后输出一个合并映射表。这里我以“城市”列为例function calcSpanMethod(tableData, mergeColumns, rowIndex, columnIndex) { const fieldName mergeColumns[columnIndex]; const row tableData[rowIndex]; if (!row || !fieldName) { return { rowspan: 1, colspan: 1 }; } if (spanMap[rowIndex] spanMap[rowIndex][columnIndex]) { return spanMap[rowIndex][columnIndex]; } return { rowspan: 1, colspan: 1 }; }但光有这个不行必须先把spanMap算出来。上文的表驱动思路具体实现是function getSpanMapByField(tableData, field) { const map {}; let mergeStartIndex 0; const firstValue tableData[0][field]; const record { start: 0, count: 1 }; for (let i 1; i tableData.length; i) { const currentValue tableData[i][field]; if (currentValue firstValue) { record.count; } else { mergeStartIndex i; firstValue currentValue; // 保存上一组的信息 const key record.start; map[key] record.count; record.start i; record.count 1; } } // 保存最后一组 if (record.start ! undefined) { map[record.start] record.count; } return map; }这个版本有点小瑕疵我实际使用时喜欢再清楚一点用一个遍历来统计连续相同值的组信息。下面是我推荐的写法function buildSpanMap(tableData, mergeField) { const spanMap {}; let startIndex 0; let count 1; let prevValue tableData.length 0 ? tableData[0][mergeField] : undefined; for (let i 1; i tableData.length; i) { const currentValue tableData[i][mergeField]; if (currentValue prevValue) { count; } else { spanMap[startIndex] count; startIndex i; count 1; prevValue currentValue; } } if (tableData.length 0) { spanMap[startIndex] count; } return spanMap; }这段逻辑非常经典从第一条数据开始依次和上一条比较相同就递增计数器不同就说明上一组已经结束把上一组起始行的下标和总行数记录到spanMap里然后重置计数和起始位置继续往后走。最后别忘了处理最后一组。2.2 在span-method中怎么配合使用有了spanMapspan-method回调就变得很清爽function spanMethod({ rowIndex, columnIndex }) { if (columnIndex 1) { // 假设“城市”列是表格的第二列下标为1 const rowspan spanMap[rowIndex]; if (rowspan) { return { rowspan, colspan: 1 }; } return { rowspan: 0, colspan: 0 }; } return { rowspan: 1, colspan: 1 }; }注意这里有一个容易写错的细节spanMap里记录的key是“每个分组的起始行下标”value由这组连续相同数据的行数组成。所以回调里拿到当前rowIndex后先去spanMap里找有没有这个key找到就说明当前行是分组的第一行必须返回rowspan找不到则说明当前行被上一行跨行覆盖了直接返回{ rowspan: 0, colspan: 0 }。如果你把合并的列固定写死成列下标上面这套完全够用。但是大多数真实项目里表格列是后端接口动态生成的或者用户勾选了哪些列展示哪些列这时候下标就不好用了必须用列名动态映射。更通用的实现我会在下一节给出你直接用在项目里比改来改去更省心。2.3 动态列名通用版通用的思路其实很简单在span-method回调里通过columnIndex去取当前列的property也就是绑定到数据的字段名然后判断这个字段名是否需要做合并。如果需要合并就去spanMap里查表如果不需要就正常返回。function spanMethodFactory(tableData, mergeFields) { const spanMaps {}; for (const field of mergeFields) { spanMaps[field] buildSpanMap(tableData, field); } return function({ rowIndex, columnIndex }) { const fieldName columns[columnIndex].property; if (mergeFields.includes(fieldName)) { const rowspan spanMaps[fieldName][rowIndex]; if (rowspan) { return { rowspan, colspan: 1 }; } return { rowspan: 0, colspan: 0 }; } return { rowspan: 1, colspan: 1 }; }; }这个工厂函数接收表格数据和需要合并的字段列表构建好每个字段对应的spanMap最后返回一个真正给 用的spanMethod函数。每次数据变化后重新执行一次工厂函数获取新的spanMethod表格就会按最新的数据重新计算合并关系。3. 多列联合合并怎么做才符合直觉3.1 多列合并和单列合并到底有什么不同单列合并只需要看某一列的值是否连续相同但实际业务里经常是“多列联合分组”。举个例子订单明细表里只看“门店”列甲门店连续出现十行你就把甲门店合并成一大格但仔细一看这十行里其实对应着两个不同的“商品”这时候用户往往会觉得不对劲——明明商品不一样门店却合并在一起看起来像是十行共用一个门店名但又分不清具体是哪几行共用。这里我想强调一下合并相同数据的前提是“分组字段值相同”而不是“长得一样就合并”。所以当需要同时按“门店”和“商品”两个维度分组时合并规则要从“独立字段各自合并”变成“两个字段联合判断是否属于同一组”。3.2 联合判断的正确打开方式多列联合分组其实就是把多个字段的值拼成一个复合key然后对这个复合key做连续相同判断。比如“门店-商品”组合A门店加商品X拼成“A门店|商品X”如果连续三行都是这个组合就说明这三行构成一组门店列和商品列在这三行里都要合并。下面给出一个联合分组的构建函数function buildMultiSpanMap(tableData, mergeFields) { const spanMap {}; let startIndex 0; let count 1; let prevKey getCompositeKey(tableData[0], mergeFields); for (let i 1; i tableData.length; i) { const currentKey getCompositeKey(tableData[i], mergeFields); if (currentKey prevKey) { count; } else { spanMap[startIndex] count; startIndex i; count 1; prevKey currentKey; } } spanMap[startIndex] count; return spanMap; } function getCompositeKey(row, mergeFields) { return mergeFields.map(field row[field]).join(||); }注意这段代码里spanMap只记录了一个复合key对应的连续行数那么span-method里该怎么处理多列呢其实逻辑上要清楚一个点对于合并的这些列它们每一列的合并起点和跨行数是一致的。也就是说在一个联合分组内所有参与合并的列都跟着同一个rowspan走。function multiSpanMethodFactory(tableData, mergeFields) { const spanMap buildMultiSpanMap(tableData, mergeFields); return function({ rowIndex, columnIndex }) { const fieldName columns[columnIndex].property; if (mergeFields.includes(fieldName)) { const rowspan spanMap[rowIndex]; if (rowspan) { return { rowspan, colspan: 1 }; } return { rowspan: 0, colspan: 0 }; } return { rowspan: 1, colspan: 1 }; }; }这样实现以后甲门店卖商品X、甲门店卖商品Y这两个分组会被拆开。门店列和商品列都会在各自分组的起点处合并视觉效果清晰得多。3.3 有一种情况要单独处理分组维度不完全参与合并有时候需求会更微妙表格里要按门店分组但只有门店列需要合并商品列不需要合并或者反过来商品列合并但门店列不合并。这时候就不是“所有mergeFields跟着同一个spanMap走”的事了。应该回到上一节单列方案的逻辑对每个字段单独构建spanMap然后分开判断。发生过一次很无语的情况一个客户要求“门店列合并但是门店下面的商品不要合并保留每个商品一行”结果有人直接用了多列联合合并函数把商品列也合并了整个表格看起来完全不对。所以动手前一定要跟产品确认清楚你要的是多列同步分组还是每一列独立合并。4. 动态更新数据后怎么避免合并失效4.1 表格数据一变spanMap必须跟着变Element Plus的Table是响应式的你用ref或reactive包住tableData数据一改表格就会重新渲染。但表格重新渲染的时候spanMethod函数本身不会自动重新计算——如果你在data里定义了一个已经算好的spanMap并且直接赋给spanMethod使用那么数据变了以后spanMap里记录的还是老数据的合并关系轻则合并错位重则直接报错或表格崩掉。解决手段是在数据变化的位置手动重新生成spanMethod。最常见的方式是封装一个计算属性或者用一个watch监听tableData的变化在回调里重新构建。const tableData ref([]); const spanMethod ref(() ({ rowspan: 1, colspan: 1 })); watch(tableData, (newData) { spanMethod.value spanMethodFactory(newData, [store, product]); }, { deep: true, immediate: true });然后表格上绑定el-table :datatableData :span-methodspanMethod这样数据只要一变spanMethod就会重新生成表格渲染时拿到的就是最新的合并映射不会出现失效的情况。4.2 排序和筛选之后怎么办表格自带的排序和筛选会改变表格内部渲染的数据顺序。这里要特别注意组件内部做排序和筛选时并不会重新触发你的watch——如果你把spanMap跟排序后展示顺序绑定了那就很容易出现错乱。处理思路有两种第一种不要依赖组件内置排序筛选后端排序后整体刷新数据前端重新构建spanMethod。这种方式最稳因为数据顺序由后端保证逻辑非常简单。第二种如果必须在纯前端排序建议监听表格的sort-change或filter-change事件在事件回调里基于当前排序后的数据手动重建spanMethod。但这样做你还需要拿到组件内部排序后的数据而这个数据组件不会直接暴露给外部实操起来很别扭。我一般不会这么干宁可让后端把排序结果返回完整数据前端拿到后统一重建。如果你非要在前端排序一个小技巧是强制使表格重新计算比如给表格加一个:keyrefreshKey每次排序后refreshKey加1让组件重新挂载一遍。不过这个方案会带来闪烁和性能损耗不建议常用。4.3 大数据量下的性能考量当表格数据量达到几千行、上千行时在watch里重新构建spanMethod的成本一般不高因为buildSpanMap只是O(n)遍历。真正要担心的是Element Plus全量渲染几千行单元格本身的性能消耗那属于虚拟滚动解决的问题不是合并逻辑能优化的。我用过的几个项目中两千行左右的数据纯前端渲染加合并交互还算流畅没有出现卡死。但如果超过五千行建议结合el-table-v2虚拟表格或者做分页。分页以后要注意当前页第一条数据和上一页最后一条数据如果值相同要不要继续合并这个需求就涉及“跨页合并”Element Plus并没有直接支持需要在分页接口层面做特殊处理比如把上一页的末行状态传给下一页让后端返回的当前页数据带着标记。这种场景相对少见但确实有人问过在这里先埋个提示。5. 常见问题与排查技巧实录5.1 合并后单元格出现边框不全或者背景色断裂Element Plus合并单元格以后样式上大概率会出现边框断裂的问题。原因在于每个单元格的边框是独立绘制的rowspan合并后被覆盖的格子不渲染边框自然也消失了。处理方式有两种一种是在单元格上使用el-table的自带边框样式实测合并后效果还行部分版本可能有残留线另一种是给合并后的单元格自定义样式比如加box-shadow模拟边框或者使用CSS伪元素补线。我个人的做法是把表格的边框属性打开border合并的情况大多数能自动适应如果发现底部或右侧有明显缺失就针对这一列的单元格写CSS.merged-cell { box-shadow: inset 0 -1px 0 var(--el-border-color); }这种方式比手动补线更好维护实测下来兼容性非常稳。5.2 合并的数据不是“相邻相同”而是“全局相同”标题写的是“动态合并相同数据”但大多数场景下说的其实是“连续相同数据合并”。如果业务要求是“相同值在整个表格范围内只要是相同的就要合并”那么单纯用连续扫描的方式就不对了需要先对数据排序让相同值排在一起再做连续合并。否则没有办法用rowspan实现非相邻的行合并因为行合并天然就是连续的。所以每次遇到这种需求先要问清楚相同值一定连续出现吗如果数据里同一个值分布在不同的地方你得先做排序预处理再送进合并逻辑。5.3 使用row和column对象时拿不到预期值span-method回调的参数里row是当前行的完整数据对象column是当前的列配置对象。很多新手会直接拿columnIndex去对比却忽略了columnIndex对应的是“当前显示列的下标”当表格使用了多级表头、列隐藏、列固定时columnIndex会变得不那么直观。强烈建议优先使用column.property来识别是哪一列而不是用下标。我在一个多级表头的项目里吃过亏同一列在下标上看着是3换个条件下标就变成4了排查了半天才发现是表头层级导致下标偏移。5.4 spanMap中存在脏数据有一种隐蔽的问题数据从接口返回后某些字段值本身带有不可见字符比如空格、换行符、全角空格等导致看起来值一样但实际字符串不相等合并就没有生效。遇到这种情况建议在构建spanMap之前做一次清洗const normalizeValue (value) String(value ?? ).trim();然后在getCompositeKey或单字段比较时都用normalizeValue处理。这个方法能解决很多莫名其妙的“明明值一样却不合并”的问题。5.5 动态列场景下span-method怎么适配如果表格列是动态生成的或者列的开启状态不定span-method的factory里就不能写死字段名必须读取当前列的property并判断是否在mergeFields内。这个在上文multiSpanMethodFactory里已经给出核心就是拿columns数组来做映射。如果你使用的是自定义render函数或更复杂的表头建议把columns定义成一个ref数组在需要重建span-method时同时传递生效。5.6 合并后点击事件获取不到跨行行的数据el-table的行点击事件在合并单元格场景下仍然会触发但如果是合并产生的“覆盖行”实际上那一行在事件触发时依然有自己的数据只是因为rowspan的存在不显示单元格。所以你不用太担心拿不到数据row参数还是真实的数据对象。要注意的是有时产品希望点击合并的单元格时弹出该组所有行的数据明细。这个时候可以提前在spanMap维护一个“组起始行到组内所有行数据索引”的映射点击时查表取出整组数据。我习惯把spanMap扩展一下让每个分组的value里除了count还保留start和rows组内所有行的数据或下标这样业务逻辑取数很方便。比如{ start: 0, count: 3, rows: tableData.slice(0, 3) }6. 直接撸一个可运行的完整示例为了让你少走弯路我把一个最简单但完整的示例代码整理出来。这里以“门店订单汇总表”为例表格里要按门店列合并并且当商品相同时商品列也要合并。template div el-button clickaddData模拟新增数据/el-button el-table :datatableData border :span-methodspanMethod el-table-column propstore label门店 / el-table-column propproduct label商品 / el-table-column propamount label金额 / /el-table /div /template script setup import { ref, watch } from vue; const tableData ref([ { store: A门店, product: 商品X, amount: 100 }, { store: A门店, product: 商品X, amount: 200 }, { store: A门店, product: 商品Y, amount: 150 }, { store: B门店, product: 商品X, amount: 300 }, { store: B门店, product: 商品Z, amount: 400 }, { store: B门店, product: 商品Z, amount: 500 }, ]); const spanMethod ref(() ({ rowspan: 1, colspan: 1 })); function buildSpanMap(data, mergeFields) { const map {}; let startIndex 0; let count 1; const keyOf (row) mergeFields.map(field String(row[field]).trim()).join(||); let prevKey data.length ? keyOf(data[0]) : ; for (let i 1; i data.length; i) { const currentKey keyOf(data[i]); if (currentKey prevKey) { count; } else { map[startIndex] count; startIndex i; count 1; prevKey currentKey; } } if (data.length) { map[startIndex] count; } return map; } function rebuildSpanMethod() { const mergeFields [store, product]; const spanMap buildSpanMap(tableData.value, mergeFields); spanMethod.value ({ rowIndex, columnIndex }) { // 假设表格只有三列列下标0是门店1是商品2是金额 if (columnIndex 0 || columnIndex 1) { const rowspan spanMap[rowIndex]; if (rowspan) { return { rowspan, colspan: 1 }; } return { rowspan: 0, colspan: 0 }; } return { rowspan: 1, colspan: 1 }; }; } watch(tableData, rebuildSpanMethod, { deep: true, immediate: true }); function addData() { tableData.value.push({ store: B门店, product: 商品Z, amount: 600 }); } /script这段代码你直接在某个Vue3工程里跑起来就能看到门店和商品列会按连续相同组合合并。新增数据后watch会触发重新构建合并关系立刻对齐。你可以把mergeFields改成只填[store]再看一眼效果感受下独立合并和多列联合合并的差别。这对理解核心逻辑非常有帮助。7. 我在实际项目里沉淀的几条经验最后说几句实在的。这套合并逻辑看着简单但要真正做得稳、做得准还是有几个点值得你特别留意。第一能后置排序就不要前端排序。凡是涉及合并的表格数据顺序非常重要。我接手的项目里凡是前端排序后又重新合并的多多少少都出现过边界问题。把排序放到接口层前端拿到排序好的数据直接构建spanMap整个链路干净利落排查问题时也少一个变量。第二合并字段的取值必须先清洗。空格、换行、整数和字符串的数字这些不一致都会导致合并失效。做一套统一的清洗函数在构建spanMap之前处理一遍后面很多莫名其妙的bug都不会出现。第三如果同一个页面里有多个表格都做合并最好把这个逻辑封装成一个Composable或者Hook不要每个组件都复制一遍buildSpanMap。我用一段useTableMerge来封装这个逻辑后很多业务组件只需要两行代码就接入省下来不少时间。// useTableMerge.js export function useTableMerge(sourceData, mergeFields, columns) { const spanMethod ref(() ({ rowspan: 1, colspan: 1 })); watch(sourceData, () { const spanMap buildSpanMap(sourceData.value, mergeFields); spanMethod.value ({ rowIndex, columnIndex }) { const field columns.value[columnIndex].property; if (mergeFields.includes(field)) { const rowspan spanMap[rowIndex]; return rowspan ? { rowspan, colspan: 1 } : { rowspan: 0, colspan: 0 }; } return { rowspan: 1, colspan: 1 }; }; }, { deep: true, immediate: true }); return { spanMethod }; }这样封装以后业务组件里只需要调用useTableMerge把tableData、需要合并的字段列表和列配置传进去拿到的spanMethod直接绑到表格上就行。第四别忽略合并单元格后的事件和数据获取。产品经常要在合并后的单元格上做跳转、弹窗、展示汇总等操作。提前把spanMap的value扩展成包含start、count、rows的结构后面哪哪都能用。这页内容写到这里核心的合并机制、单列合并、多列联合合并、动态更新、踩坑处理、完整示例都覆盖到了。如果你在实际开发里真的遇到那种怎么调都对不上的合并场景我的建议是先把数据和映射表打出来看一眼手动对比下每组的start和count对不对。大多数问题不在组件而在数据本身。
阅读完成 · 觉得有帮助?