EasyExcel合并单元格完全指南:从底层原理到动态合并实战
这周刚帮同事调了一个导出功能现象挺典型数据都写出来了但该合并的单元格一个都没合上整张表平铺得跟流水账一样。问了一圈基本都是同一个原因——只知道EasyExcel能一行代码写出 Excel却不知道合并单元格需要自己控制merge策略。今天就把这块彻底说清楚从底层机制到三种常见合并场景的完整代码再到那些容易踩的坑一次性聊透。1. 为什么导出表格要合并单元格需求背景与常见场景1.1 别等用户提需求了才动手合并单元格到底解决什么问题Excel 表格里合并单元格这件事做业务系统的人几乎天天会遇到。最典型的就是导出部门员工列表时同一个部门有七八个人用户希望部门名称只在第一行显示下面几行合并成一个格子这样打印出来、发给领导看的时候层级关系一眼就清楚。还有一种非常常见的是树形结构数据比如导出项目任务清单一级任务下面挂着好几个子任务一级任务那列也需要纵向合并。报表统计类需求就更多了。按月统计销售额的时候一月份下面可能有华东、华北、华南好几条记录月份这一列如果不合并同样的“2026-01”会重复出现一大堆数据量一大基本没法看。还有一种多级表头场景比如需要两行表头第一行写“费用明细”下面拆成“办公费”“差旅费”“招待费”三列这种横向合并也是导出功能里的常客。很多人第一反应是“这不是前端的事吗我用 el-table 的 span-method 就能做”。但你得想清楚用户要的是下载一个 Excel 文件不是只看网页上的展示表格。前端组件那些合并样式只在浏览器里生效根本不会带到导出的文件里。所以只要需求是“导出 Excel”合并单元格这件事就必须在后端生成文件的时候处理掉。1.2 合并单元格与 EasyExcel“天然冲突”的原因EasyExcel 的设计哲学是极简上手。它希望你把精力放在“数据长什么样”上而不是“单元格怎么排版”上。所以它默认的数据写出逻辑是非常线性的——一行数据就是一个 List一个单元格就是一个值完全平铺直叙。问题就出在这。一旦你告诉它“这里要合并”就得打破这种线性写入规则。EasyExcel 本身提供了一个WriteSheet.merge()方法也提供了一些内置的合并策略但它们解决的是“固定区域、固定行数”的简单合并。真正让很多人写崩的是动态合并——也就是根据数据内容值相同才合并行数不固定合并区域得运行时算。这时候你就不能靠 EasyExcel 给你现成的答案了必须自己动手写合并策略。但别慌EasyExcel 虽然没把所有功能都包装成傻瓜式接口但它留了很标准的扩展点。搞懂它底层那套WriteHandler机制之后你会发现自己写合并策略也就几十行代码的事。2. 先搞懂合并单元格的底层机制API与回调原理2.1 EasyExcel 合并单元格的本质是什么EasyExcel 底层依赖的是 Apache POI。虽然它做了大量内存优化但最终生成 xlsx 文件时合并单元格的操作还是要落到 POI 的addMergedRegion上。这个操作的本质就是告诉 Excel 文件格式解析器从第几行到第几行、从第几列到第几列这中间的所有单元格归属同一个区域区域左上角的单元格保留值其他单元格不参与独立显示。在 EasyExcel 的 API 里这个操作被封装成了WriteSheet.merge()。比如你写了一个两行表头想让第一行的前四列合并成一个总标题可以这样ExcelWriter writer EasyExcel.write(fileName).build(); WriteSheet sheet EasyExcel.writerSheet(数据).build(); sheet.merge(0, 0, 0, 3); // 第一行 0-3列合并这里要注意所有行列索引都是从 0 开始。第一个参数是起始行第二个参数是结束行第三个参数是起始列第四个参数是结束列。这个顺序特别容易记混我见过好几个同事第一次写的时候把行列参数传反了结果表头形状完全不对。但真正的关键是这个merge方法是在真正写数据之前还是之后生效它和数据的值有没有关联答案是不关联。它就是一个纯区域操作声明不会自动帮你判断“上下两个单元格的值相等就合并”。所以如果你只是调用merge它合并的区域是死的跟数据内容毫无关系。这也解释了为什么很多人用merge做动态合并做不到——因为它根本没有“判断值”这个内置逻辑。2.2 动态合并必须掌握的扩展点你要做动态合并就得自己接管合并决策。EasyExcel 给的标准扩展点是WriteHandler具体来说有两条路。第一条路是继承AbstractMergeStrategy实现merge方法。这个类的特点是每个单元格写出时都会回调你而你在这个回调里可以判断当前单元格的值、行号、列号然后决定要不要对这个区域执行merge。第二条路是实现CellWriteHandler接口重写afterCellDispose方法。这个方法在每个单元格写出完成后调用你可以在这里拿到当前单元格的CellData、行信息、列信息。实际项目中我的经验是简单合并用WriteSheet.merge()就够了动态合并必须走AbstractMergeStrategy。原因很简单AbstractMergeStrategy可以在一个策略类里维护当前合并区域的状态处理“连续相同值合并”这种需求非常自然。而CellWriteHandler更偏向于“每个单元格独立处理”的场景如果你在它里面做区域状态维护要小心回调时机的问题。2.3 合并单元格会清空非左上角格值吗这个问题很多人栽过。你按照网上搜到的代码合并了某个区域结果导出后发现区域左上角的格有值其他格都是空的。你会怀疑是自己数据没写全。其实不是。因为 Excel 的合并规则就是一个合并区域内只显示左上角单元格的值其他格子在物理层面也存在但属于被合并状态里面即使有值也不会显示。而 EasyExcel 写入时每一行数据是老老实实一个一个单元格写进去的。所以你如果用merge把第一行的前四列合并了但你又按普通四列写了四个值那么后三个值其实是存在的只是被合并区域的遮罩盖住了不会显示。所以正确的做法是合并区域左上角的单元格你要给它赋值其他合并区域内的格子要么不写要么写完之后设置setValue为空字符串。我在后面动态合并的章节里会专门讲怎么处理这种情况。3. 实战三种典型合并需求的完整实现3.1 静态表头合并两行表头与多级表头静态合并是最好写的。所谓静态就是表头结构在代码里是写死的不会有运行时变化。比如导出一张两个大类的统计表第一行上面写“经营指标”合并前两列再写“财务指标”合并后三列第二行再展开各个细项。完整代码是这样的// 1. 先构建表头数据结构 ListListString head new ArrayList(); head.add(Arrays.asList(经营指标, 经营指标, 收入)); head.add(Arrays.asList(经营指标, 经营指标, 支出)); head.add(Arrays.asList(财务指标, 资产, )); head.add(Arrays.asList(财务指标, 负债, )); // 2. 写入数据 String fileName test.xlsx; EasyExcel.write(fileName) .head(head) .sheet(报表) .doWrite(dataList);但你会发现这样写执行后表头不会合并。因为 EasyExcel 只会按照 List 逐行摆放表头不会因为你上下行值一样就自动合并。所以你必须手动声明合并区域。正确姿势是在构建WriteSheet之后再调用mergeExcelWriter writer EasyExcel.write(fileName).build(); WriteSheet sheet EasyExcel.writerSheet(报表).build(); // 第一行的“经营指标”占据第1行第1-2列注意索引从0开始 sheet.merge(0, 0, 0, 1); // 第一行的“财务指标”占据第1行第3-5列 sheet.merge(0, 0, 2, 4); // 第二行的“资产、负债”其实它们本身就是自己合并自己的 // 不需要额外处理 writer.write(dataList, sheet); writer.finish();静态表头合并这里有一个比较容易忽略的细节merge方法是有先后顺序的不能在一个WriteSheet对象上多次调用导致区域重叠。EasyExcel 底层会做校验一旦两个合并区域有重叠它会直接抛异常或者生成一个损坏的文件。所以写表头合并的时候最好先在纸上画一个行列表格把每个合并区域的起止行列标出来再写代码避免肉眼很难看出来的重叠。3.2 动态数据按列合并相同值、相同列合并这是实际项目里最刚需的场景。导出员工列表部门列值相同就合并。但部门下面的人数不固定有的部门 3 个人有的部门 10 个人合并区域的行数完全取决于数据。这时候就必须用自定义策略。我的实现思路是把需要合并的列号传进策略类然后遍历数据提前计算每列哪些行的值是连续的相同的。这个“提前计算”非常关键不要想着边写边判断、边判断边合并。因为合并操作会改变后续行的索引状态如果你在运行时逐行判断很容易把区域搞错。推荐先做一个“合并区域计算器”把每一列需要合并的区域全部算出来再一次性注册到WriteSheet上。示例代码如下public class DynamicMergeStrategy extends AbstractMergeStrategy { private MapInteger, Listint[] mergeRegions; // key: 列索引value: [startRow, endRow] public DynamicMergeStrategy(ExcelWriter writer, ListInteger mergeColumnIndexes, ListListObject dataRows) { this.mergeRegions new HashMap(); for (Integer col : mergeColumnIndexes) { Listint[] regions calculateRegions(dataRows, col); mergeRegions.put(col, regions); } } private Listint[] calculateRegions(ListListObject data, int col) { Listint[] regions new ArrayList(); int start 1; // 从第1行开始第0行是表头所以数据行从索引1开始 while (start data.size() 1) { int end start; Object currentVal data.get(start - 1).get(col); while (end data.size() Objects.equals(currentVal, data.get(end).get(col))) { end; } if (end - start 0) { regions.add(new int[]{start, end}); } start end; } return regions; } Override protected void merge(Sheet sheet, Cell cell, Head head, Integer relativeRowIndex) { // EasyExcel会为每个单元格调用merge但我们在拿到sheet后单独处理 // 所以这里不做任何事 } }然后在真正写数据前注册策略// 构建excelWriter ExcelWriter writer EasyExcel.write(fileName).build(); // 数据列表 ListListObject dataList buildData(); // 计算合并区域第2列索引1需要合并表头占了1行数据行从1开始 ListInteger mergeCols Collections.singletonList(1); MergeStrategy strategy new DynamicMergeStrategy(writer, mergeCols, dataList); WriteSheet sheet EasyExcel.writerSheet(数据) .registerWriteHandler(strategy) .build(); // 写数据 writer.write(dataList, sheet); writer.finish();这个实现的关键点是计算行号时要把表头行排除掉。如果你的表头不止一行比如有两行表头那么数据行索引要从 2 开始。这个偏移量最好作为参数传进去不要在策略里写死。我见过太多人只处理了单行表头结果遇到两行表头整个合并区域整体错位了一行数据对不上排查半天。3.3 模板导出中的合并注册合并处理器完成全局控制还有一种常见场景是“用户上传一个 Excel 模板系统往模板里填数据同时要合并某些区域”。这种场景下数据是动态的但不能破坏模板原有的结构和样式。比如运维巡检表模板第二行是“巡检项目”下面要按巡检项合并 B 列或者 C 列。模板导出的做法是使用ExcelWriter对象的write方法配合模板文件String templateFileName template.xlsx; String outputFileName result.xlsx; ExcelWriter writer EasyExcel.write(outputFileName) .withTemplate(templateFileName) .build(); WriteSheet sheet EasyExcel.writerSheet().build(); // 往模板填充数据第3行开始是数据区 writer.write(dataList, sheet); // 合并第2列数据从第3行开始 sheet.merge(2, 6, 1, 1); // 比如把第3-7行、第2列合并 writer.finish();但注意如果你要动态合并模板导出场景同样需要自定义策略。而且动态合并的区域计算要跟模板里数据的起始行对齐。这个起始行必须从模板的实际结构去判断没有捷径。这里分享一个我自己的血泪经验模板导出的动态合并千万不要只依赖merge方法后写因为模板里很可能已经有预置的合并单元格。如果你的代码再合并一个区域两个区域碰上了Excel 文件就会损坏。所以调用merge之前最好先读取模板里的existingMergedRegions判断一下当前要合并的区域有没有跟它重叠。EasyExcel 底层虽然会校验但有时候校验不彻底文件目录信息已经坏了一半。3.4 动态多级表头边写边合并的实现技巧动态多级表头比静态表头复杂一点。它的特点是列的个数不确定比如用户选择导出某些月份表头第二行月份列的数量跟着变第一行就要按月份数量合并对应区域。做法是配合head数据结构和merge一起使用。先根据用户选择的月份生成动态的head然后根据月份数量生成合并区域ListString monthList Arrays.asList(1月, 2月, 3月); int monthCount monthList.size(); ListListString head new ArrayList(); ListString row1 new ArrayList(); ListString row2 new ArrayList(); for (int i 0; i monthCount; i) { row1.add(销售额); row2.add(monthList.get(i)); } head.add(row1); head.add(row2); ExcelWriter writer EasyExcel.write(fileName).build(); WriteSheet sheet EasyExcel.writerSheet(报表).build(); // 第一行销售额合并所有月份列 sheet.merge(0, 0, 0, monthCount - 1); writer.write(dataList, sheet); writer.finish();这种实现方式好在运行时月份列表一变表头和合并区域跟着变代码结构不用动。需要注意的就是列索引从 0 开始所以结束列是monthCount - 1千万别写成monthCount不然会多合并一列数据错位。4. 合并单元格常见问题与排查技巧实录4.1 合并后其他单元格数据被清空的真相这个问题几乎每个新手都会遇到。明明数据都写进去了合并区域里只有左上角有值其他全空。刚才已经讲了原因——Excel 合并区域本来就不显示非左上角的值。但你如果确实需要让用户看到值比如合并的是一个“产品类别”合并后每行原本都有值你希望第一行显示其他行也保留可见值那就要换一个思路不要用合并单元格来达到让用户看到值的目的。一个折中方案是原本相同值的那些行只在第一行写值其他行写空字符串。这样合并后视觉效果就是只有第一个格有值文件整体又不会有那么多重复值。许多报表工具就是这样处理的。还有一种情况是你的数据写完之后用 POI 再读一遍发现别的格子有值而左上角没值。这时要检查你的merge调用时机和数据写出顺序。EasyExcel 在不同版本里merge和write的执行顺序有差异。我的建议是所有merge操作必须在writer.finish()之前、数据写完之后执行。如果你在doWrite之后再调用 merge有些版本可能会因为文件流已经关闭而失效。4.2 报错 IndexOutOfBounds / 区域重叠异常处理合并单元格最常遇到的异常就是区域重叠。POI 的底层对addMergedRegion的调用会校验validateMergedRegions一旦发现两个区域有重叠直接抛错。很多时候你以为两个区域不重叠但因为行号列号计算错误实际重叠了你不知道。我建议写一个简单的断言工具在真正执行合并前把所有合并区域做一个笛卡尔积判断两两是否相交public static void assertNoOverlap(Listint[] regions) { for (int i 0; i regions.size(); i) { for (int j i 1; j regions.size(); j) { int[] a regions.get(i); int[] b regions.get(j); boolean overlap a[0] b[1] b[0] a[1] a[2] b[3] b[2] a[3]; if (overlap) { throw new IllegalArgumentException(合并区域重叠: Arrays.toString(a) 和 Arrays.toString(b)); } } } }这个工具函数虽然简单但真的能救命。尤其是你做动态合并的时候数据一多人工排查根本来不及。另一类异常是IndexOutOfBoundsException一般是行号或列号超出了工作表默认的行列数。比如你删除了表头或者数据行太多超出 1048576 行的上限。或者你计算合并区域时把start和end搞反了导致start end。排查思路是把异常堆栈里的行号打出来对比一下数据行数基本一眼能看出来。4.3 Excel 打开后提示“文件损坏”的排查路径文件损坏这个事十次有八次是合并区域的问题。但还有两次是别的。我做了一个排查清单按照概率排序第一合并区域重叠而未抛出异常。这种情况出现在某些 EasyExcel 版本中底层校验不够严格文件已经写出来了但 Excel 解析时发现区域重叠就报损坏。第二合并区域的行列索引超出了实际写入的数据范围。比如你合并了第 5 行到第 10 行但实际数据只写到了第 8 行这样文件头信息里记录了不存在的区域Excel 打开也可能提示修复。第三在合并区域内的非左上角单元格写了非法值。这在复杂表头里很常见比如某列在合并区域里没有数据但你在afterCellDataConverted回调里给这个单元格设置了null而 POI 对合并格的值处理有 bug导致文件结构异常。遇到这类问题最快的排查方式是先用 POI 原生的方式读取一次生成的文件看能不能正常加载它Workbook workbook WorkbookFactory.create(new File(fileName));如果这一步能正常通过那 Excel 提示损坏大概率是文件内容样式层面的问题比如字体、边框、条件格式冲突。如果这一步也抛异常那就要回过去检查合并区域了。4.4 常见问题速查表现象可能原因解决方案合并区域左上角为空merge 时这一格还没写入值或者值写在其他单元格保证左上角格先写值再合并合并区域不生效merge 的列行参数传反检查行列索引先确认终点大于起点文件打开报损坏合并区域重叠用断言工具提前校验合并区域错位表头行数偏移量没算对动态计算数据起始行索引大数据量合并性能差每个单元格都回调里 merge提前计算区域一次性注册模板导出的合并被破坏新合并区域与模板预置区域冲突读取模板已有合并区域做交集判断5. 性能优化与同类工具的差异化对比5.1 大数据量合并的性能杀手与优化方案合并单元格这个操作本身不是免费的。它会让 POI 底层维护一个mergedRegions列表每次向工作表写入单元格时都要检查当前单元格是否落在某个合并区域内这个查询是 O(n) 复杂度的。换句话说合并区域越多、单元格越多整体写入越慢。举个直观的例子我测试过一个 5 万行、5 列的数据其中一列要按值合并动态计算出来约 3 万个合并区域。如果你在每个单元格回调里都去做一次区域合并判断导出时间从 2 秒直接飙到 15 秒以上而且时不时内存溢出。解决办法是把“计算合并区域”和“执行合并”彻底分离。先用纯内存的数据结构把所有需要合并的区域算好然后一次 merge中间不做任何数据行写入。我用这个优化思路同样 5 万行数据导出时间降回到了 3 秒左右。另外还有一个小细节越靠近表格底部和右侧的区域合并时对性能的影响越大。所以如果你的合并区域是可以自动合并的尽量把数据的顺序排序让相同值排在一起这样可以减少区域数量性能提升非常明显。5.2 与 el-table 合并单元格方案的差异与配合很多人在做前端展示时用 el-table 的span-method实现合并单元格。它的逻辑其实是返回rowspan和colspan告诉前端表格当前单元格纵向跨几行、横向跨几列。这种机制和 Excel 的合并区域是两种完全不同的模型。最关键的问题是前端合并的规则和后端导出的规则必须保持一致但实现方式完全不同。比如你用 el-table 的span-method合并了某个列导出的时候你不能简单把前端的rowspan值直接翻译成 Excel 合并区域的lastRow因为 el-table 的 rowspan 是相对当前行的偏移量而 Excel 的合并区域需要的是绝对的起始行和结束行。我的经验是在前端和后端之间定义一份统一的“合并规则”配置它描述的是每一列“按哪个字段分组、组内值相同则合并”。前端和后端分别用自己的方式实现这份规则。前端用span-method后端用自定义合并策略。两者虽然代码完全不同但对照同一份配置能保证效果一致。5.3 EasyExcel 与 POI-TL、poitl 导出 Word 合并单元格的区别平时工作里也会遇到用 POI-TL 导出 Word 表格并合并单元格的需求很多从 Excel 转过来的人会习惯性地把 EasyExcel 的经验搬过去结果踩坑。核心区别在于Excel 合并单元格是“区域合并”你可以合并一个矩形范围内的任意多个单元格Excel 会自动处理行列布局而 Word 表格里的合并本质是合并行内的列水平合并或列内的行垂直合并它不允许你任意合并一个矩形区域只能做线性的行跨列、列跨行。POI-TL 处理 Word 表格合并的方式通常是在模板表格里预先定义好需要合并的单元格然后通过DynamicTableRenderPolicy在渲染时输出MergeCellData数据控制哪一行哪一列继续合并。它比 EasyExcel 更受限因为你很难在数据都写完之后再大面积合并。所以如果你接的是 Word 表格导出的项目建议从一开始就把合并规则放进模板里设计而不是像 Excel 那样最后统一处理。5.4 关于 AI 工具自动生成合并代码的提醒最近很多人喜欢让 AI 帮忙生成表格导出的代码生成出来的代码乍一看很完整但合并单元格这种需求 AI 往往只会给你一个最简单的sheet.merge(0,0,0,1)示例稍微复杂一点的动态合并且行号偏移它就容易算错。我自己实测过一次让 AI 生成“根据部门列合并员工导出”它给的代码把数据起始行写死成了 1完全没考虑表头可能有两行。结果导出的表格所有数据整体偏移一行部门名称串了行用户看到的第一反应就是“这表是坏的”。所以在把 AI 生成的代码合入项目之前至少自己要在本地准备一份包含多级表头、空数据、单行数据、连续重复值这四类边界情况的测试数据集跑一遍导出打开文件肉眼检查合并区域是否准确。这个检查成本很低但能挡住大部分因为行号偏移导致的返工风险。写在最后的一点个人经验我做导出功能这几年最大的体会是合并单元格本身不难难的是把合并规则想清楚。很多项目里合并规则不是固定的今天按部门明天按项目后天按月份如果每次都去改合并策略代码迟早被这种需求烦死。我自己的做法是把合并规则抽象成配置文件比如 JSON 里声明哪些列需要合并、按哪个字段分组然后在代码里统一解析这份配置并生成合并区域。这样需求一变改配置就行不用重新编译发布。另外再分享一个小技巧在调试导出文件时尽量用办公软件里的“取消合并单元格”功能来反向验证。如果你合并后的区域里被合并的格子居然有值那就能立刻发现代码里的值写入逻辑有问题。这个小技巧帮我排查过好几次棘手的数据错乱问题。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →