资讯动态

Element Plus 表格动态合并单元格:span-method 原理与完整实现

发布时间:2026/10/18 7:59:27 来源:尧图企业网站定制
后台管理系统里相同值合并单元格这个需求做过的都懂产品经理一句话把相同的合并一下跟 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。如果你的表格第一列是序号列typeindex它的 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就是组大小至少 1spanMap[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 Plus4.1 一套可复制的完整组件示例完整跑通一个贴合实际项目的示例。场景就是上面的库存台账category和brand两列按层级合并其余列保持默认渲染。template el-table :datatableData :span-methodspanMethod border el-table-column propcategory label分类 width120 / el-table-column propbrand label品牌 width120 / el-table-column propname label商品名称 / el-table-column propstock label库存 width100 / /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 包装合并计算逻辑排序/筛选后合并全部错乱内置排序筛选未纳入合并计算在数据层面自行完成排序筛选合并区边框断裂或有冗余线rowspan0 隐藏 td 后边框样式断裂插槽统一管理样式或接受轻微瑕疵空白值合并成大空白格null/undefined 参与相等比较归一化字段值后再参与合并计算独立行数据消失合并 Map 未记录大小为 1 的组每一组都记录 rowspan独立行返回 16. 封装一个可复用的合并 Hook6.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 :datatableData :span-methodspanMethod border这个 Hook 的两个设计细节值得说明。第一data参数接收的是一个refHook 内部用data.value读取computed 会自动追踪依赖使用方只需传入已有的tableDataref 即可。第二对外只暴露spanMethod不暴露mergeMap——调用方不需要关心合并信息的内部结构拿到函数直接绑给组件使用成本降到了最低。6.2 后续可以怎么扩展封装完成后扩展空间主要在三块。一是给不同列配置不同的合并策略比如有些列合并连续相同值有些列要跳过特定值不参与合并可以在mergeColumns之外再传一个配置对象。二是暴露合并起始单元格的信息列表外部拿到后可以为合并区域定制背景色、加粗等样式。三是兼容部分列不参与响应式计算的场景比如某些列的合并规则固定不变就没必要每次都重算整个 Map。我在自己负责的两个项目里分别用这套 Hook 做过库存台账和月度销售统计。后来另一个同事接手其中一个项目翻到这段代码时反馈是不用看文档就会用——只需要知道mergeColumns传什么字段就能完成合并需求。这算是我对封装设计比较满意的一次把复杂度收敛在函数内部外部接口维持简单直观。最后分享一个小经验。表格动态合并这个需求技术难度不高但想明白比写出来重要得多。核心就是三步数据有序是前提、预计算合并区间是核心、span-method 里只做查询是原则。把这三条记牢不管数据多复杂、列数多长、业务规则怎么变都能用同一套思路快速落地。至于那些合并区间的边界、空值的坑、排序筛选的冲突都属于第一次踩了、第二次就会绕开的常规操作没什么可怕的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑