
开发这么多年导出 Excel 算是被问得最多的功能之一。而且每次都不是简单的“导出一份表格”总带点特殊要求数据量大不大、字段里有没有大文本、要不要按条件动态生成表头、前端下载完能不能直接打开……我前前后后踩了不少坑今天把前端、后端两条线的实现方式和注意事项一次说清楚。不管你是刚接手前后端分离项目的新人还是已经在用 POI 或 EasyExcel 的老手这篇文章都能帮你少走一段弯路。先说清楚这篇文章覆盖的范围后端以 Java 生态为主重点讲 Apache POI 和阿里 EasyExcel 怎么选、怎么用大数据量怎么异步导出特殊字段长数字、CLOB、UUID怎么处理前端部分讲 axios 拉取文件流的正确姿势、文件名解析、纯前端用 SheetJS 生成 Excel 的场景边界以及 CSV 导出的中文乱码问题。最后附上我实际排查过的经典案例基本覆盖了导出功能从开发到上线会撞上的大多数坑。1. 导出 Excel 前先想清楚用哪种方案很多新手拿到需求就直接开写写完才发现方案选错了。比如数据就几千行前端表格已经渲染完了却硬要后端查一遍数据库再生成文件又比如几百万行数据前端想用 JS 直接生成 xlsx浏览器直接卡死。这些都属于方案选型失误。1.1 三种常见实现路线目前主流的导出方案就三条纯前端生成、后端生成文件流由前端下载、后端异步生成文件后再下载。纯前端生成适合数据已经在前端、量不大、且不需要强权限控制的场景。比如配置管理后台里把当前筛选结果导出来直接用 SheetJS 之类的前端库就能搞定省去一次 HTTP 请求。它的瓶颈在于内存浏览器里几十万行数据全量加载再序列化页面基本就卡死了所以大数据量别走这条路。后端生成文件流是最常规的做法。前端请求接口后端用 EasyExcel 或 POI 写文件把二进制流返回前端用 Blob 接收并触发下载。数据量在几万到几十万行时最合适代码也不复杂整个链路好排查。第三种异步导出是针对大数据量或复杂统计场景。接口请求后立刻返回“任务已受理”后端异步把 Excel 写完再放到临时存储前端轮询任务状态完成后拿文件地址下载。这个方案能避免浏览器请求超时也能避免后端长时间占用连接但需要额外设计任务状态和存储清理机制。1.2 一张表看清怎么选我整理了一个简化判断表按数据量和业务复杂度直接对照场景特征推荐方案核心原因数据已在页面少于 5 万行纯前端生成不占用后端资源实现最快几万到几十万行后端查库后端生成文件流数据库查询权在后端格式控制强百万行以上或统计耗时很长异步任务 文件下载避免超时内存可控需要复杂样式合并单元格、图表、多级表头后端 POI/EasyExcel前端库对样式支持很弱导出文件需要权限校验后端校验后返回流前端无法可靠保护数据这个表在需求评审阶段就能用。产品经理只提“我要导出 Excel”时你直接反问一句大概多少行数据要不要复杂的样式有没有特殊的字段格式三个问题问完方案基本就定了。1.3 最容易翻车的点前端拿到的不一定是文件流我见过不少同事兴致勃勃写完导出接口前端一调控制台显示“导出成功”结果下载的文件几 KB打开一看内容全是 JSON 字符串。原因就是后端抛了异常返回的是 JSON 错误信息但前端代码里压根没判断响应类型就直接当文件处理了。这是个典型的“半路翻车”问题开发环境看接口正常一到联调就露馅。所以写前端导出逻辑时必须同时处理成功和失败两种情况代码里要对 response 的 content-type 或响应内容做甄别。这一块后面写前端部分的时候我会给出完整写法。2. 后端导出把文件生成这件事做扎实后端是导出功能最关键的环节。文件能不能打开、格式对不对、性能扛不扛得住全看这一层写得好不好。2.1 技术选型POI 还是 EasyExcelJava 后端生成 Excel 绕不开 Apache POI这是最底层的开源库功能全但原生 API 写起来确实繁琐。要合并单元格、设置样式、处理字体代码量能翻两三倍。最关键的是普通 XSSFWorkbook 在写入大量数据时会先把整个工作簿加载进内存几十万行数据容易出现 OOM。EasyExcel 是阿里开源的一个封装库底层复用 POI 的 SAX 模式做流式读写内存控制得很好。我在实际项目中同样导出 50 万行数据原生 POI 的内存占用轻松超过 1GB换成 EasyExcel 之后压到 200MB 上下。这一点在大数据量下是决定性的。我列个对比对比项Apache POI阿里 EasyExcel内存占用百万行易 OOM流式写入占用低API 复杂度繁琐样式需要手写注解驱动代码量少复杂样式支持非常强常用样式够用复杂排版需自定义学习成本较高低看几个例子就能上手适合场景复杂报表、模板开发绝大多数业务导出场景结论很直接新项目、普通导出功能直接用 EasyExcel。除非你要做定制级报表、复杂格式模板套打否则没必要用原生 POI 硬扛。2.2 基于 EasyExcel 的标准导出流程先看一个最基础的后端导出写法。假设我们导出一张用户列表包含姓名、手机号、注册时间手机号必须以文本格式展示避免精度丢失。定义导出模型Data public class UserExportVO { ExcelProperty(value 姓名, index 0) ColumnWidth(20) private String name; ExcelProperty(value 手机号, index 1) ColumnWidth(20) private String phone; ExcelProperty(value 注册时间, index 2) ColumnWidth(24) private Date registerTime; }注意手机号字段我用的是 String 而不是 Long。这是因为 Excel 对超过 15 位的数字会自动转成科学计数法显示后端如果直接返回 Long 类型生成的文件里号码就会变成类似 1.38E10 的格式用户打开后完全没法直接用。手机号、身份证、银行卡号这类长数字统一用字符串导出这是最稳妥的做法。如果你的数据源是数据库 varchar 字段查询出来本身就是 String就没这类问题但如果你在 VO 里定义成了数字类型就要格外小心。Controller 里直接写文件流PostMapping(/export/users) public void exportUsers(HttpServletResponse response) throws IOException { // 设置响应头 response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setCharacterEncoding(utf-8); String fileName URLEncoder.encode(用户列表, UTF-8).replaceAll(\\, %20); response.setHeader(Content-disposition, attachment;filename*utf-8 fileName .xlsx); // 查询数据 ListUserExportVO userList userService.listExportData(); // 写入 Excel EasyExcel.write(response.getOutputStream(), UserExportVO.class) .sheet(用户列表) .doWrite(userList); }这段代码里面有几个细节很多人会漏。第一文件名的 Content-Disposition 头最好用filename*utf-8这种格式前端解析的时候不容易乱码兼容性也好第二如果数据量不小查询时不要一次性把全部数据 load 到内存里而是要分页或分批流式查询配合 EasyExcel 的doWrite分批写入第三不要忘记设置响应类型否则前端拿到的 content-type 不对可能导致浏览器直接展示乱码内容。2.3 大数据量导出流式写入与异步任务设计数据量一旦上来光靠同步接口就不合适了。两个核心问题要解决内存吃紧和请求超时。先处理内存。假设要导出 100 万行数据无论如何不能一次SELECT出来再写正确做法是分页查询每次查 5000 到 10000 条边查边写。EasyExcel 的流式写入能力是专门为这种场景设计的写一行到一堆行都不必整表驻留内存。关键代码思路是// 使用 EasyExcel.write(outputStream) 之后多次调用 doWrite 追加数据 String fileName big_export.xlsx; ExcelWriter writer EasyExcel.write(fileName, BigDataVO.class).build(); WriteSheet sheet EasyExcel.writerSheet(数据).build(); int pageNum 1; int pageSize 5000; while (true) { ListBigDataVO pageData mapper.selectPage(pageNum, pageSize); if (pageData.isEmpty()) { break; } writer.write(pageData, sheet); pageNum; // 及时清理对当前批次对象的引用减轻 GC 压力 } writer.finish();这里还有一个容易看走眼的点如果是用 MyBatis-Plus 的selectPage不要想着让分页插件去 count 总条数那个 count 在大表上可能会拖垮数据库直接用一个“查一下有没有下一批”的循环即可。再处理超时问题。假设这个导出任务要跑三分钟用户的浏览器等待三分钟不现实网关层很可能已经断掉了连接。更稳健的方案是设计异步任务前端请求导出后端立即返回taskId后端用线程池或消息队列异步执行导出状态写入任务表前端每秒轮询一次任务状态状态变为 completed 后拿到文件下载地址文件生成后保存在服务器临时目录或对象存储定期清理。如果项目里已经有消息队列这个模型的扩展性会更好。没有 MQ 也不要硬上直接搞一个配置了线程池的导出服务类就行。任务表至少要有这几个字段任务 ID、状态pending/running/success/failed、文件路径、创建时间、完成时间、失败原因。文件过期清理直接写一个定时任务删除超过 24 小时的文件即可。2.4 特殊字段处理CLOB、UUID 和日期格式热词里反复出现clob字段怎么导出、excel写uuid说明这两类字段在真实业务里很常踩坑我单独展开讲。CLOB 字段在 Oracle 里非常常见存大段公告、描述、审批意见等。如果你用 MyBatis 直接映射到 String大多数情况下没问题但遇到特别大的 CLOB 内容一次性把整个值拉进内存就很危险。稳妥做法是用流式读取比如 JDBC 层面的Clob.getCharacterStream()逐段读取后再拼接到字符串缓冲里。EasyExcel 写入这个长字符串时如果单个单元格内容大还需要注意 Excel 单格上限是 32767 个字符超过会截断。遇到这种情况可以提前截断并加上省略号或者考虑导出为 CSV/压缩包而非死磕单个单元格。UUID 字段的坑主要在“前端拿到之后把它当数字或日期处理”。本质原因是 Excel 对单元格类型有严格判断字符串里如果带-一般没问题但如果 UUID 没有横杠且以数字开头后端若用了不恰当的类型定义就可能被转成科学计数法。解决思路很粗暴凡是不是真正需要参与计算的字段一律用 String 类型输出并且在表头里不要露出任何“让 Excel 猜测”的机会。我用过的最省心写法是直接在 VO 字段上ExcelProperty(value UUID)ColumnWidth(32)类型定义为 String保证 open 出来是文本。日期字段的坑主要是时区。后端应用服务器时区如果不是东八区生成的 Excel 时间会比北京时间少 8 小时。用 EasyExcel 时可以在字段上标注DateTimeFormat(yyyy-MM-dd HH:mm:ss)并且确保 JVM 启动参数里加了-Duser.timezoneGMT8。检查时区的一个办法是在本地启动后先导出一次用 Excel 打开看看时间是不是跟数据库里一致不一致就排查 JVM 时区。3. 前端接收文件流下载与纯前端生成后端把文件生成好只是第一步前端能不能正确接住、触发下载、处理好异常决定用户实际体验。这块看起来简单但坑也最密。3.1 axios 拉取文件流的完整姿势前端最常规的写法是用 axios 请求导出接口设置responseType: blob然后创建一个 URL 并触发下载。完整代码如下import axios from axios; export function exportExcel(params) { return axios({ url: /api/export/users, method: post, data: params, responseType: blob, timeout: 30000 }); }在业务方法里处理响应async function handleExport() { try { const res await exportExcel({ keyword: searchKeyword }); // 关键判断返回的到底是文件还是错误信息 const contentType res.headers[content-type] || ; if (contentType.includes(application/json)) { // 后端返回了 JSON 错误体需要读出来提示用户 const reader new FileReader(); reader.onload function () { const errorObj JSON.parse(reader.result); alert(errorObj.message || 导出失败); }; reader.readAsText(res.data); return; } // 正常文件流从响应头中取文件名 const fileName getFileNameFromDisposition(res.headers[content-disposition]); const blob new Blob([res.data], { type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet }); const downloadUrl window.URL.createObjectURL(blob); const link document.createElement(a); link.href downloadUrl; link.download fileName; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(downloadUrl); } catch (error) { console.error(导出失败, error); alert(导出失败请稍后重试); } }这个写法里有几个值得细抠的地方。第一content-type判断必须做。很多导出接口在业务层抛异常后返回的是全局异常处理器输出的 JSON前端如果不判断拿到的 blob 就是一段错误说明用户下载后打开会发现里面全是英文和堆栈信息体验极差。我在项目里把这个判断封装成了一个公共函数所有导出下载都复用。第二window.URL.revokeObjectURL的调用时机。理论上link.click()调用之后就可以释放但部分浏览器在极短时间内还会依赖这个 URL稳妥做法是在setTimeout里延迟几百毫秒再释放避免低概率的文件下载失败。第三blob 的 MIME type 要显式设置。如果后端响应头已经带了正确的 content-type前端可以不传但我习惯显式设置这样即便后端漏了响应头前端也能兜底生成正确文件。3.2 文件名解析处理 Content-Disposition 乱码文件名通常写在响应头里格式类似Content-Disposition: attachment; filenameexport.xlsx; filename*utf-8%E7%94%A8%E6%88%B7%E5%88%97%E8%A1%A8.xlsx正确做法是优先解析filename*部分因为它专门用于非 ASCII 文件名且做了 URL 编码。解析函数参考function getFileNameFromDisposition(disposition) { if (!disposition) { return export.xlsx; } // 优先取 filename* 中的编码内容 const starMatch disposition.match(/filename\*utf-8([^;])/i); if (starMatch starMatch[1]) { try { return decodeURIComponent(starMatch[1]); } catch (e) { // 解码失败时回退到普通 filename } } const plainMatch disposition.match(/filename?([^;])?/i); if (plainMatch plainMatch[1]) { return plainMatch[1]; } return export.xlsx; }这里有个兼容性注意点。老一点的浏览器对中文文件名支持不理想如果后端只用了filenamexxx.xlsx且中文直接放入前端拿到的可能是乱码。所以我在后端约定统一用filename*utf-8格式前端只要解析这一种就够了。如果项目要兼容老系统可以同时保留两种写法。3.3 纯前端用 SheetJS 生成 Excel能做什么不能做什么有些场景确实不需要后端参与。比如用户在前端页面上勾选了几行数据或者当前表格本身就是从接口查出来的完整数据集数量不大产品只要求“把当前页面导出”。这个时候用 SheetJSnpm 包名xlsx最直接。一个最简单的 JSON 转 Excel 示例import * as XLSX from xlsx; function exportJsonToExcel(jsonData, fileName 导出数据.xlsx) { // jsonData 是对象数组key 为表头 const worksheet XLSX.utils.json_to_sheet(jsonData); const workbook XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, Sheet1); XLSX.writeFile(workbook, fileName); }如果是页面上已有的 Table 元素也可以直接抓取 DOMconst table document.getElementById(reportTable); const worksheet XLSX.utils.table_to_sheet(table); const workbook XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, 报表); XLSX.writeFile(workbook, 报表.xlsx);这套方案在开发效率上确实无可挑剔但局限也很明显。首先SheetJS 社区版对单元格样式的支持非常弱设置字体颜色、背景色、边框这些能力要么不支持要么做得不够细如果你要导出“那种带红黄绿灯状态标识”的报表纯前端搞不定。其次数据量一大浏览器内存会先爆炸我实测十万行以上就会明显卡顿几十万行基本别想。最后如果导出数据来自后端接口前端需要先把全部数据请求到本地这样多了一次全量传输浪费流量也增加加载时间。所以我的场景判断是管理员后台的配置列表导出、内部工具的简单报表纯前端很好用面向客户的大数据报表、格式复杂的统计表还是走后端导出。3.4 CSV 导出加 BOM解决中文乱码有些团队偷懒直接用 CSV 做导出如果表结构简单、没有复杂样式CSV 也够用。但 CSV 有个非常经典的问题Excel 打开 UTF-8 编码的 CSV 会乱码因为 Excel 默认按系统 ANSI 编码去解析。解决办法是在 CSV 内容前面加上 BOM 头\uFEFF。const csvContent \uFEFF rows.map(row row.join(,)).join(\n); const blob new Blob([csvContent], { type: text/csv;charsetutf-8; }); const url URL.createObjectURL(blob); const link document.createElement(a); link.href url; link.download 数据.csv; link.click(); URL.revokeObjectURL(url);加了 BOM 之后再用 Excel 双击打开中文和符号都能正常识别。这个技巧我每次都要在团队里重复强调因为大多数人写完 CSV 导出用记事本看正常用 Excel 打开乱码第一反应都是编码问题但其实是缺了 BOM。另外提醒一句CSV 对单元格内的逗号和换行要及时处理否则列会错位最简单的做法是对每个字段做包裹并转义内部引号。4. 导出 Excel 的坑我踩过的都在这最后一块内容是实战排查经验。我按“现象、原因、处理”的格式整理成速查表并展开讲几个典型场景。现象根因处理建议下载的文件打开乱码前端未加 BOM 或编码不一致CSV 加 BOM 或统一 UTF-8文件几 KB内容是 JSON/HTML后端异常返回了错误信息前端判断 content-type文件几十 KB 但 Excel 提示格式损坏实际内容是 HTML/CSV改扩展名为 xlsx后端正确生成二进制 xlsx长数字显示为科学计数法数字类型导致 Excel 自动转换用 String 导出并设置文本格式Excel 打开提示受保护视图文件来自网络被 Office 安全策略锁定下载后在文件属性中解除锁定浏览器下载超时数据量大同步接口耗时超网关限制改成异步任务轮询状态导出文件时间是昨天 16 点服务端时区问题差 8 小时设置 JVM 时区前端不额外转换4.1 文件名为中文但下载后是乱码这是一个兼容性大坑。后端用 URL 编码后的文件名放到filename*里前端按decodeURIComponent解析按理不该乱码但很多老系统用了filename中文.xlsx这种老格式甚至没做任何编码。浏览器拿到后按照 Latin-1 解码中文自然就变成了乱码。我的建议是后端统一使用RFC 5987标准只响应filename*UTF-8字段前端也只解析这个字段。这样两边约定一致彻底避开历史包袱。如果遇到历史接口改不动前端的兜底策略是用时间戳 固定前缀作为下载名虽然文件名变丑了但至少不是乱码。4.2 导出的文件提示格式与扩展名不符这个提示我看到过太多次了。最常见的原因是后端返回的内容不是真正的 xlsx而是 HTML 页面或者 JSON 文本只是把响应头写成了application/vnd.openxmlformats-officedocument.spreadsheetml.sheet。比如网关层拦截了接口返回了登录页 HTML前端拿到后照样包成 blob 下载保存成 .xlsxExcel 一打开就报格式错误。排查思路很简单用文本编辑器打开那个“损坏”的 xlsx如果能看见html或者{code:...}字样说明拿到的是文本而非二进制。我在实际项目里遇到过一次是因为前端接口请求没有携带认证 header被网关重定向到了登录页前端由于没判断 HTML 也强制下载才生成了一堆假文件。所以前端判断 content-type 时不要只看是不是application/json还要把text/html也视为异常。4.3 导出文件里 CLOB 字段内容被截断这个属于字段级问题。Excel 单元格最多容纳 32767 个字符超过这个长度无论你用 EasyExcel 还是 POI都会被截断。CLOB 字段如果存的是几千字一般没事但真要遇到上万字的公告详情导出前必须先确认业务预期是截断加省略号还是拆行展示还是直接用压缩包导出原文。我之前做过一个方案把超过长度的大文本单独拼成一个 TXT 打包进 ZIPExcel 里只留提示文字既保证了 Excel 可用又保留了完整信息。还有就是大 CLOB 的读取性能。有些框架在查询时会把整个 CLOB 一次性加载如果导出量又大内存和 IO 都扛不住。更合理的方式是对数据做分批处理比如一次查 2000 条写完后释放引用。4.4 前端拿到 Blob 后下载文件却是 0KB出现 0KB 文件大概率是后端在写输出流时抛了异常但异常没有正确传播到响应状态里或者响应已经被finally块提前关闭了。我在排查这种问题时会先看后端日志里有没有IOException或OutOfMemoryError再看 controller 是否在finally里重复调用了close()。因为 EasyExcel 在doWrite结束后会自动关闭输出流如果再手动response.getOutputStream().close()文件可能只写了一半就中断。前端看到 0KB 文件时也可以换个思路直接看 Network 面板检查请求的响应大小是不是 0。如果接口本身响应就是 0问题肯定在后端如果响应有几百 KB 但下载下来是 0问题就在前端 Blob 生成环节。4.5 用户反馈“Excel 加载项被禁用”或“公式不生效”这个现象经常被误认为是代码问题其实多半是 Office 客户端的设置。用户双击打开从网上下载的文件时Office 会默认启用“受保护视图”并且可能禁用加载项或宏。如果导出的文件里包含公式、数据验证、宏之类的动态内容用户打开后看不到效果第一反应就是开发把功能做坏了。我的处理经验是在交付文档里写清“如果打开显示受保护视图请点击启用编辑”并在代码层面尽量导出静态数据而非依赖 Excel 计算公式。比如要算合计后端直接把最终结果算好填进去不要指望 Excel 打开后自动 recalc。如果你确实要导出带公式的模板POI 里有setForceFormulaRecalculation(true)EasyExcel 也可以自定义写入 handler 设置相关属性。但这类需求一旦牵涉到用户环境差异维护成本会直线上升能避开就避开。4.6 大文件异步导出完成后用户却下载失败异步导出做得再好最后下载环节照样有坑。最常见的是文件生成后放在应用服务器本地磁盘只存活在当前节点上一旦用户请求下载时被负载均衡转发到另一台机器就 404 了。很多自认为“部署没问题”的项目在这栽过跟头。解决思路通常是把文件上传到对象存储任务表里存下载 URL。没有对象存储的小项目也得用共享目录或把文件放到一个固定节点保证下载请求能路由过去。另外一定要有文件清理机制否则导出任务一多磁盘会被填满。我用定时任务每天凌晨清理超过 24 小时的历史文件上线之后从没因为磁盘告警被 过。写在最后说实话导出 Excel 本身不难难的是细节。文件名字符集、大数据量内存、CLOB 读取、异步任务状态、前端 Blob 异常判断每一个单项拆开看都是很小的点但串起来之后就是一张完整的问题地图。我个人体会是小项目直接 EasyExcel 前端 blob 下载别自己封装一套导出工具需求里带“大量”“复杂报表”“动态表头”这些关键词时优先确认数据量然后考虑异步方案。你要是把文章里这些点都提前想到开发阶段基本不会有大返工。