ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Excel导出实战:前后端选型、大文件优化与踩坑指南

Excel导出实战:前后端选型、大文件优化与踩坑指南 Excel导出大概是后台管理系统里出现频率最高的“简单功能”之一。我在大小项目里都做过这个需求也被它坑过不少次。看起来不复杂前端遍历一下数据生成一个表格或者后端写个接口返回一个文件好像就能收工。但真实项目里我见过前端导出几十万行把浏览器直接卡死也见过后端用 XSSFWorkbook 写 20 万行数据把服务器堆内存顶满更常见的是辛辛苦苦导出来的文件一打开就提示“内容损坏”。这篇文章把 Excel 导出这件事拆开来讲从前端和后端两个方向分别给出能直接落地的方案覆盖选型思路、代码实现、大文件优化以及实际工作中踩过的坑。如果你正在做前后端分离项目或者正在为导出一堆数据发愁这里面的东西应该能直接帮你少走弯路。后端示例我用 Java 生态来写但思路和排查方法迁移到 Python、Go 都成立。1. 先把思路理清楚前端导出还是后端导出1.1 两种方案的适用边界做导出前第一件事不是打开编辑器写代码而是先想清楚这个文件应该由前端生成还是由后端生成。很多项目里这个决定做得太随意后面才发现数据量一大整个方案要推翻重来。前端导出的本质是浏览器已经拿到了数据通常是一个 JSON 数组前端 JS 在自己的内存里把数据拼成 Excel 所需的 XML/二进制结构再触发浏览器下载。整个过程不经过服务器后端甚至毫不知情。后端导出的本质是服务端查询数据库、组装数据、生成真正的 .xlsx 文件通过 HTTP 响应流把文件字节返回给浏览器前端通常只需要发起请求并把返回的 Blob 保存下来。两者的取舍也很直白我常用下面这张表来跟产品和后端同事对齐对比维度前端导出后端导出数据量适合几千行以内几万、几十万甚至百万行都可以扛样式复杂度简单样式能做复杂样式很吃力模板 工具库可以做出很专业的报表样式服务器开销无有但可以通过流式写入、异步任务优化权限与审计权限只依赖接口导出动作难统一审计服务端可以统一拦截、记录谁导了什么实时性取决于前端已有数据可以支持大数据量实时生成典型场景临时小报表、本地数据二次导出管理后台正式报表、定时任务导出一句话结论数据量小、样式要求低、追求开发速度可以前端导出但正式项目里的报表导出尤其是管理后台的我几乎都推荐后端导出。原因很简单后端导出能把权限校验、审计日志、数据脱敏、大文件处理全部收口到服务端出问题也好排查。1.2 前后端分离项目中的组合与分工你搜“前后端分离项目实战”的时候大概率也会见到导出功能怎么做。在这种架构下前端和后端的边界可以划得很清楚后端负责“生成 .xlsx”前端负责“触发下载”。前端通常只做两件事携带查询条件调用导出接口拿到后端返回的二进制流把它保存成用户文件。这里有个容易踩的坑很多前端同学为了省事会把查询结果全量拉下来在前端用 xlsx 库直接导出。这个玩法在演示项目里很好用一旦数据量到几万行浏览器内存翻倍页面直接卡成 PPT用户会以为系统挂了。我在实际项目里见过有人为了导出一份 5 万行的订单前端拉完接口内存涨到了 2 个多 G风扇狂转最后只能刷新页面。所以我的原则是凡是接口已经做了分页的数据导出都走后端凡是前端本地已经全部加载的数据临时做个 Excel 给用户下载才用前端方案。大多数后端框架和脚手架无论你是用 Spring Boot、RuoYi 这类常见脚手架还是 FastAPI导出接口的写法都是同一套套路不会因为你换了语言而改变核心逻辑。2. 前端导出的完整实现与隐藏坑2.1 五分钟搞定SheetJS 快速导出前端导出最简单、最快上手的方案是 SheetJSnpm 上就是xlsx这个包。它不需要后端参与把二维数组“翻译”成 Excel 的 XML 结构然后直接触发浏览器下载。一个完整可用的最小实现差不多是这样import * as XLSX from xlsx; function exportSimple(list) { const rows [ [姓名, 工号, 部门, 入职日期], ...list.map((item) [item.name, item.no, item.dept, item.joinDate]) ]; const worksheet XLSX.utils.aoa_to_sheet(rows); // 设置列宽wch 是按字符宽度估算的大概 18 个字符宽 worksheet[!cols] rows[0].map((_, i) ({ wch: 18 })); const workbook XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, 导出数据); XLSX.writeFile(workbook, 员工列表.xlsx); }理解这段代码有三个关键点aoa_to_sheet是 SheetJS 最常用的入口a 是 arrayo 是 ofa 是 arrays也就是“数组的数组”。行和列的顺序完全由二维数组决定第一行就是表头。这个 API 足够应付大部分平铺数据。!cols不是必写的但如果不写打开 Excel 时默认列宽非常窄中文表头会被挤成一串省略号体验很差。这个细节在文档里不明显实际导出时几乎每次都要设置。XLSX.writeFile做了所有脏活创建 Blob、创建 a 标签、模拟点击、触发下载、释放对象 URL。你不需要自己写那些下载代码直接调用就行。2.2 需要样式时用 ExcelJS如果用 SheetJS 导出后发现样式不够用比如想要合并单元格、单元格背景色、字体加粗、边框这时候就应该切换到 ExcelJS。ExcelJS 同样是纯前端库处理样式的能力比 SheetJS 强很多也支持按行渲染。const ExcelJS require(exceljs); async function exportWithStyle(list) { const workbook new ExcelJS.Workbook(); workbook.creator admin; const worksheet workbook.addWorksheet(员工列表); worksheet.columns [ { header: 姓名, key: name, width: 15 }, { header: 手机号, key: phone, width: 20 }, { header: 入职日期, key: joinDate, width: 16 } ]; list.forEach((item) worksheet.addRow(item)); // 表头加粗、白字、灰底 const headerRow worksheet.getRow(1); headerRow.font { bold: true, color: { argb: FFFFFFFF } }; headerRow.fill { type: pattern, pattern: solid, fgColor: { argb: FF4472C4 } }; headerRow.alignment { vertical: middle, horizontal: center }; const buffer await workbook.xlsx.writeBuffer(); downloadBlob(buffer, 员工列表.xlsx); } function downloadBlob(buffer, fileName) { const blob new Blob([buffer], { type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet }); const link document.createElement(a); link.href URL.createObjectURL(blob); link.download fileName; link.click(); URL.revokeObjectURL(link.href); }ExcelJS 的写法更贴近 Excel 的物理结构workbook 里有 worksheetworksheet 里有 rowrow 里有 cell样式挂到 row、cell 或 column 上。这一点对从后端 POI 移民过来的人特别友好概念几乎一一对应。2.3 前端导出不能忽略的边界问题前端导出不是拿个库就完事至少还有三件容易被忽略的事。第一数据量临界点。SheetJS 和 ExcelJS 都是把整个表格构建在浏览器内存里的行数过多会直接内存膨胀。我自己的经验是十兆字节以内的数据、几千行的规模前端导出还很流畅超过这个量果断设计成后端接口不要硬扛。第二文件名的处理。如果用户连续两次点击导出浏览器会生成同名文件部分浏览器会主动在文件名后面加“(1)”用户可能困惑。可以在文件名里拼上时间戳比如员工列表_20250514_1530.xlsx能减少一大半这类咨询。第三按钮防重复点击。导出往往伴随较长的数据处理时间一旦用户以为没反应连续点了五六次前端就会创建五六个下载任务后端接口也被重复请求。前端最好在点击后立刻把按钮置灰等文件下载完成或接口返回失败再恢复。这个做法成本极低收益非常直接。3. 后端导出从 POI 到 EasyExcel3.1 POI 标准导出流程后端导出传统方案是 Apache POI。POI 用 XSSFWorkbook 表示 .xlsx 文件创建 Sheet、Row、Cell最后写入响应流。下面是一段最标准的示例也是我早期项目里的模板public void exportUserList(HttpServletResponse response, ListUserDTO list) { String fileName 用户明细.xlsx; String encodedFileName URLEncoder.encode(fileName, StandardCharsets.UTF_8); response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charsetUTF-8); response.setHeader(Content-Disposition, attachment;filename encodedFileName); try (XSSFWorkbook workbook new XSSFWorkbook()) { XSSFSheet sheet workbook.createSheet(用户); createHeader(sheet, workbook); int rowIndex 1; for (UserDTO user : list) { XSSFRow row sheet.createRow(rowIndex); row.createCell(0).setCellValue(user.getId().toString()); row.createCell(1).setCellValue(user.getName()); row.createCell(2).setCellValue(user.getEmail()); row.createCell(3).setCellValue(user.getCreateTime()); } workbook.write(response.getOutputStream()); } catch (IOException e) { throw new RuntimeException(导出失败, e); } }POI 的核心坑点不在于 API 复杂而在于内存模型。XSSFWorkbook 会把整个工作簿的所有 Cell 对象都放在堆内存里写着写着 Excel 文件越写越大内存也越撑越高。之前有个同事导出一份 20 万行的年度账单第一次跑直接java.lang.OutOfMemoryError: Java heap space这就是典型的 XSSFWorkbook 全量内存导致的。如果要保留 POI 又不想 OOM可以换用它的流式版本SXSSFWorkbook区别是它维护一个“窗口大小”窗口之外的行会被刷到磁盘临时文件内存里始终只保留最近若干行。这个我们放到大文件优化那一节详细讲。3.2 EasyExcel内存友好的导出方案如果是在 Java 生态里造轮子我更推荐直接用阿里开源的 EasyExcel。它底层是 SAX 模式逐行读写不会像 XSSFWorkbook 那样把所有行都放内存导出几十万行数据的内存占用要小得多。ExcelProperty(姓名) private String name; ExcelProperty(手机号) private String phone; ExcelProperty(入职时间) DateTimeFormat(yyyy-MM-dd) private Date joinTime;把这些字段放在模型类上导出接口几行就能写完public void export(HttpServletResponse response, Long deptId) { String fileName 部门员工 System.currentTimeMillis() .xlsx; response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment;filename URLEncoder.encode(fileName, UTF-8)); EasyExcel.write(response.getOutputStream(), UserExportDTO.class) .sheet(员工) .doWrite(() - userService.listByDept(deptId)); }EasyExcel 的注解方式最大优点是列顺序和列名都在模型类里集中维护换个报表只用调整 DTO不需要改写入逻辑。它还支持数据转换器比如 Long 型 ID 自动转字符串、日期自动格式化这样能直接在源头避免“ID 变科学计数法”的经典问题。3.3 动态列和模板导出的最佳实践实际业务很少是固定列产品经理经常提出“用户想勾选哪几列就导出哪几列”这就是动态列需求。EasyExcel 处理动态列也很方便核心思路是用 ListList 作为表头而不是用注解ListListString head new ArrayList(); for (String colName : selectedColumns) { head.add(Collections.singletonList(colName)); } EasyExcel.write(outputStream) .head(head) .sheet(动态列) .doWrite(dataRows);另一种常见场景是模板导出公司报销单、部门预算表、盖章通知单格式是 HR 或财务用 Excel 排好版的后端直接用程序“填格子”而不是重新绘制。这种我一般用 EasyExcel 的 fill 功能把一个带占位符的 .xlsx 模板放到 resources 目录代码读取模板后在指定区域填充数据。好处是格式永远跟模板一致业务方要改样式时改模板文件就行后端代码一行不用动。4. 大文件导出的优化与异步化4.1 先找瓶颈内存爆掉的真实原因导出几十万行数据最先崩的往往是内存。要把问题看懂得拆开算一笔账假设一张表 20 万行、每行 10 个字段查询出来的 Java 对象堆内存按每行 200 字节算大约 40 MB这还能接受。但 XSSFWorkbook 每生成一个 Cell 不只是存一个值还要维护单元格样式、字体、列信息等元数据实际内存消耗会放大几十倍。数据对象 40 MB工作簿可能要 1 个多 G服务器 2 G 堆内存一下子就顶满了。所以优化方向不是去猜而是三个点逐个排查数据库查询是否把全量数据都加载进内存写入 Excel 时是否用了全量 DOM 模型一次导出到底需要多少行4.2 三个直接落地的优化手段第一数据库分批或游标查询。默认情况下JDBC 查询一次executeQuery会把所有结果拉到客户端内存。改成流式游标后可以从 ResultSet 里逐行读取try (PreparedStatement ps conn.prepareStatement(sql, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) { ps.setFetchSize(1000); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { // 逐行写进 Excel } } }注意 MySQL 需要设置 useCursorFetch 连接参数才能真正生效不同数据库语法略有差异。这项优化的收益非常大能直接把查询侧内存占用降下来。第二写入侧改用流式模型。POI 场景选用SXSSFWorkbook构造参数new SXSSFWorkbook(100)表示内存窗口是 100 行超出部分自动落到磁盘临时文件EasyExcel 本身就是 SAX 流式写不需要额外配置。用 SXSSF 时有个副作用窗口外的行无法再修改样式所以表头、汇总行这类需要后置处理的逻辑要规划好写入顺序。第三合理设计 Sheet 和数据分布。Excel 单个工作表最多 1048576 行大数据量要么分多个 Sheet要么分多个文件打包下载。分 Sheet 的辅助收益是用户查看时不用一次加载全部数据兼容性也更好。4.3 大文件导出的异步下载设计数据量大到几百万行时接口同步返回必定超时用户会一直傻等。这时候要改成异步任务用户点击导出后后端立刻返回“任务已创建”后台用线程池生成文件完成后把文件路径存好前端轮询任务状态Ready 以后就能下载。PostMapping(/export/async) public void startExport(RequestBody ExportQuery query) { exportTaskService.submit(query); // 线程池执行写文件到临时目录 }生产环境要注意清理临时文件否则任务一多磁盘会被撑满。常见的做法是任务记录表里保存生成时间和文件地址定期清理超过 24 小时的文件。这个设计虽然多几行代码但真正遇到大导出需求时效果立竿见影。5. 常见问题速查与排查实录5.1 数字精度Excel 把 ID 变成科学计数法第一次导出用户表的时候很多人会发现身份证号、订单号这类长数字全部变成了6.2301E18之类的科学计数法甚至末尾几位变成了 0。原因很简单Excel 内部把数值类型存成 IEEE 754 的 double有效数字只有 15 位18 位 ID 后面的几位必然丢精度。这不是导出代码的锅是 Excel 数值格式的物理限制。解决办法有两个后端让 ID 字段以字符串返回并在导出模型里用ExcelProperty或 POI 的 setCellValue(String) 写入文本类型如果某些工具库强制识别为数字可以在导出前给单元格设置文本格式。工程上我默认所有超过 15 位的数字字段在 DTO 里都用 String。5.2 CLOB、UUID 这类特殊字段怎么处理“CLOB 字段怎么导出”是数据库导出场景里一个高频问题。CLOB 是 Oracle 里的大文本类型比如备注、审批意见、JSON 日志可能很长。直接 JDBCgetString(1)在大数据量下很容易把内存打满正确做法是拿getCharacterStream分段读取。还要注意 Excel 单元格的硬限制单个单元格最多 32767 个字符超出部分 Excel 不会帮你自动拆分文件可能报错。我的做法是对超长文本做截断并追加省略号或者把长文本拆到多个单元格里。“Excel 写 UUID”类似UUID 本身是字符串生成后直接写入文本单元格就行注意不要配置成数值格式。这边最常见的坑是有人把 UUID 里的横杠去掉后当成 32 位数字导致精度丢失或变成科学计数法实际上它应该永远按字符串处理。5.3 前端下载的文件打开总是“文件损坏”后端明明能正常生成文件前端下载到本地却提示损坏这个问题出现频率极高。常见原因之一前端用 axios 请求导出接口时没有设置 responseType。const res await axios.post(/api/export, params, { responseType: blob }); const blob new Blob([res.data], { type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet });如果少了 responseTypeaxios 会把返回内容当成 JSON 解析你下载下来的其实是“不知道为什么变成了二进制字符串”的文本文件自然打不开。还有一个隐蔽场景接口内部出错时返回的是统一 JSON 错误体但前端拿到的是一个 Blob仍然硬保存成 .xlsx。所以下载前最好判断一下 Blob 的 typeif (res.data.type.includes(application/json)) { // 解析错误信息并提示用户 } else { saveAsExcel(res.data, fileName); }这条经验是我在线上支持群里被同一个问题问了不下五回之后总结出来的现在写进了团队的前端规范。5.4 文件名中文乱码的处理后端返回attachment;filename用户明细.xlsx的时候HTTP 头默认只支持 ASCII 字符中文文件名大概率在浏览器里变成一串乱码甚至直接下载失败。正确做法是用 URL 编码后的文件名同时提供现代的 filename* 形式String encodedFileName URLEncoder.encode(fileName, StandardCharsets.UTF_8); response.setHeader(Content-Disposition, attachment;filename*UTF-8 encodedFileName);浏览器会优先识别 filename*现代浏览器基本都能拿到正常的中文文件名。我的经验是不要依赖框架自动帮你做这件事上传、下载、导出三个场景建议在工具类里统一封装文件名处理逻辑避免每个接口各写一套。5.5 几个与导出代码无关的 Excel 端坑最后说几句不能甩锅给开发的事。用户反馈“Excel 加载项被禁用”、“CtrlV 没反应”、“Office 2019 公式下拉失效”这类问题很多时候并不是你导出的文件有问题而是用户本机 Excel 客户端的环境配置或加载项冲突。我在实际排查时会先让用户下载一个你自己用 WPS 或 Office 打开都正常的样例文件如果样例文件正常那就优先引导用户检查 Excel 加载项管理和安全设置。同时也要提醒后端不要在生产导出文件里放大量公式尤其是那种几十万行每个单元格都带 VLOOKUP 的文件。公式虽然能用但打开文件时会触发 Excel 重新计算文件尺寸不大却慢如蜗牛用户只会把“打开很慢”这笔账算到系统头上。导出的文件本质上应该是干净的“数据快照”不是“计算模板”公式尽量留在数据组装阶段完成落到 Excel 里的就是最终结果。我自己实践下来的原则很简单先清楚目标用户和数据量再决定前端还是后端导出一旦上了规模直接考虑 EasyExcel 或 SXSSFWorkbook文件名、编码、Blob 类型这类细节全部收口到公共工具函数里。做到这几点Excel 导出这个“简单功能”基本就不会再半夜给你打电话了。
返回列表