
1. 方案选型对比为什么我最终选择了 POI-TL 而不是 Apache POI先聊一个很现实的问题当你接到“Springboot Vue 实现导出 Word”这种需求时第一反应是什么我猜很多人会直接想到 Apache POI。POI 确实是 Java 生态里操作 Office 文件的老牌方案功能全面能读写 Word、Excel、PowerPoint但恰恰是这个“全面”在导出 Word 这个场景里成了负担。用 POI 导出 Word最难受的地方在于——你需要用代码一行一行地构建文档结构。创建 Paragraph、创建 Run、设置样式、处理表格行列写出来的代码又长又难维护。我早期接过一个项目需求是根据数据库里的几十个字段动态生成一份十几页的合同文档用纯 POI 写光那一段 createContent 方法就堆了六百多行后续业务变更改字段的时候简直是灾难。后来我换了个思路改用模板填充的方式才真正把这个问题解决掉。模板填充的思路其实很好理解先用 Word 把文档的整体结构和样式排好该留空的地方留下占位符后端拿到数据后把占位符替换成真实内容。这就像是做填空题模板是试卷数据是答案。Java 这边做这件事的主流方案有三个方案原理优势劣势POI-TL在 POI 基础上封装模板引擎基于 Word 2007 XML 结构做标签替换模板直观好维护、语法简单、支持图片/表格/循环等高级功能社区规模中等遇到特殊需求可能要读源码Apache POI XWPF 原生 API纯代码构建 XWPFDocument功能最底层最全面可控性极强代码量大开发效率低文档结构变更成本高FreeMarker 生成 Word XML用 FreeMarker 渲染一个预先转成 XML 格式的 Word 模板模板逻辑强适合复杂条件判断模板里的 XML 结构极其冗余改一处样式要在几千行 XML 里找位置“动态生成合同”这个场景数据字段多、文档结构固定但内容会变用 POI-TL 真的省太多事了。而且 POI-TL 本身就建立在 POI 之上底层还是 XWPFDocument遇到模板引擎覆盖不了的特殊需求随时可以拿到原生 Document 对象继续操作退路也有。当然纯前端方案我也考虑过。Vue 生态里有一个 docx 库可以直接在浏览器端生成 docx 文件配合 html-docx-js 这种把 HTML 转换成 Word 的老库也能实现。这个方案的好处是后端完全不用管省掉一次 HTTP 请求。但如果业务里涉及服务端数据组装、权限校验、审计日志或者生成的文档需要直接存到服务器 OSS那逻辑就得前后端各写一套反而更割裂。所以我的建议是核心生成逻辑放后端前端只负责把文件流“接住”并触发下载各司其职。2. 项目整体设计搞清楚数据流再动手写代码动手之前一定要把整个链路在脑子里过一遍。这个项目虽然是“Springboot Vue”但导出 Word 的核心数据流是单向的前端发起请求 - 后端接收参数并查询/组装数据 - 加载 Word 模板 - 用 POI-TL 渲染数据 - 生成字节流 - 通过 HTTP 响应返回 - 前端接收 Blob 并触发浏览器下载。2.1 用时序逻辑理解前后端职责边界先说后端要做的事。后端这个环节里最容易出问题的是响应头设置。很多新手写文件下载接口ResponseEntity 返回了前端拿到的却是一堆乱码甚至 response 里什么都没有多半就是 Content-Type 和 Content-Disposition 没配对。标准的做法是Content-Type用application/vnd.openxmlformats-officedocument.wordprocessingml.document这个 MIME 类型对应 Word 2007 版本;Content-Disposition设置成attachment; filename告诉浏览器这是一个需要下载的附件。文件名还有个编码坑。如果文件名是中文直接放在filename后面浏览器默认按 ISO-8859-1 解码大概率会乱码。标准处理方式是URLEncoder.encode(fileName, UTF-8).replaceAll(\\, %20)然后再拼进 Content-Disposition。不过这里要留意不同浏览器对 URL 编码的兼容性不太一样最稳妥的方案是在filename的基础上再加一个filename*UTF-8参数前后端配合着读。前端要做的事则相对单纯。Vue 里发请求用 axios 是主流但文件下载和普通 JSON 请求在这有一个关键差异——必须设置responseType: blob。如果不设置这个axios 会默认把响应体当 JSON 解析拿到的 Blob 对象就是一个没法正常落地的数据块。文件流到了前端之后再用URL.createObjectURL(blob)生成一个临时地址创建一个a标签触发 click最后还要记得把URL.revokeObjectURL释放掉避免内存泄漏。2.2 为什么需要模板管理这个“隐藏环节”我看到很多项目里Word 模板直接放到src/main/resources/templates/下面这在小项目里没什么问题但模板一旦需要经常改内容、改公司 logo、改合同条款就比较麻烦了。每次改模板都要走一次代码发布加上 CDN 缓存十几分钟都上不了线。我的习惯是把模板统一放到后端的一个目录中比如服务器磁盘上的/data/word-templates/接口只负责读取指定名称的模板文件。模板变更时运维、业务人员直接通过后台菜单上传覆盖同名模板程序连重启都不用。这个设计其实就是为了应对真实业务里模板频繁变动这一残酷现实。另外模板要给每种业务场景做“独立模板 配置映射”。比如你导出的是报告 A 和报告 B它们结构差异很大就应该有两套模板后端通过一个模板编码参数去查找模板文件而不是用一套模板加一堆 if/else 的奇怪逻辑来硬撑。3. POI-TL 模板渲染细节从零开始写一个可运行的导出接口方案和技术储备都说清楚了现在进入正题写一个可以直接跑的项目。我用的是 Maven 构建的 Spring Boot 工程JDK 1.8Spring Boot 2.7.xPOI-TL 1.12.x。这里提醒一句POI-TL 和 POI 的版本兼容性有点麻烦最好从官方文档查推荐搭配我遇到过 Spring Boot 内嵌的 POI 版本和 POI-TL 依赖的 POI 版本冲突导致NoSuchMethodError的情况解决办法是手动指定 POI 的版本号。3.1 引入依赖版本锁定这一步千万别省dependency groupIdcom.deepoove/groupId artifactIdpoi-tl/artifactId version1.12.1/version /dependency !-- 如果项目中已有 POI 相关依赖建议显式锁定版本 -- dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.5/version /dependencypoi-tl默认依赖的 POI 版本比较老如果你用的 Spring Boot 版本比较高或者其他模块引入了新版 POI很容易出现类冲突。我建议你把poi-ooxml的版本在 Maven 的 dependencyManagement 里锁一锁保证全工程只有一个有效版本。3.2 准备 Word 模板占位符语法要记牢POI-TL 的模板语法是基于{{ }}的。最常用的几种写法{{name}}普通变量填充一个字符串。{{pic}}图片变量后端传一个 PictureRenderData 对象。{{table}}表格变量传一个 TableRenderData或者直接用 List 配合遍历。{{?list}} ... {{/list}}循环块这里面可以嵌套其他变量常用于动态表格行、动态列表。这里有个实战经验模板里尽量不要自己手打花括号最好从 POI-TL 官方文档下载一个语法样例模板然后在样例的基础上复制、修改因为 Word 经常会自动把{{和}}中间的空格、换行弄出隐藏符号导致标签识别失败。我在刚接触 POI-TL 的时候踩过一个很隐蔽的坑在 Word 里直接输入{{name}}看起来没问题但模板渲染之后 name 的位置留着空白渲染接口报错Cannot resolve the tag。后来用XML方式打开 word/document.xml 检查发现{{和name之间多了一个不可见的零宽空格。排查办法也很简单把模板另存为.xml搜索标签名看标签前后是否有异常字符。3.3 后端核心代码组装数据并渲染假设需求是导出一份“员工信息登记表”里面包括姓名、部门、入职时间、一张头像照片、以及一个技能列表的循环表格。模板写法大概是这样员工姓名{{name}} 所属部门{{department}} 入职日期{{hireDate}} 技能清单 {{?skills}} | 技能名称 | 熟练程度 | | --- | --- | | {{skillName}} | {{level}} | {{/skills}} 签名照片{{photo}}后端渲染的核心代码import com.deepoove.poi.XWPFTemplate; import com.deepoove.poi.data.*; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import javax.servlet.http.HttpServletResponse; import java.io.*; import java.net.URLEncoder; import java.nio.file.Files; import java.nio.file.Paths; import java.util.*; RestController public class WordExportController { GetMapping(/export/employee) public void exportEmployee(RequestParam String employeeId, HttpServletResponse response) throws IOException { // 1. 查询员工数据这里用 Mock 代替 MapString, Object employee queryEmployee(employeeId); // 2. 组装 POI-TL 需要的数据模型 MapString, Object data new HashMap(); data.put(name, employee.get(name)); data.put(department, employee.get(department)); data.put(hireDate, employee.get(hireDate)); // 技能列表对应模板中的循环块 ListMapString, Object skillList querySkillList(employeeId); ListMapString, Object skillTableData new ArrayList(); for (MapString, Object skill : skillList) { MapString, Object row new HashMap(); row.put(skillName, skill.get(skillName)); row.put(level, skill.get(level)); skillTableData.add(row); } data.put(skills, skillTableData); // 3. 加载模板文件注意模板文件放在外部目录 String templatePath /data/word-templates/employee-info.docx; XWPFTemplate template XWPFTemplate.compile(templatePath) .render(data); // 4. 设置响应头 String fileName URLEncoder.encode( employee.get(name) -员工信息登记表.docx, UTF-8) .replaceAll(\\, %20); response.setContentType(application/vnd.openxmlformats-officedocument.wordprocessingml.document); response.setCharacterEncoding(UTF-8); response.setHeader(Content-Disposition, attachment; filename\ fileName \; filename*UTF-8 fileName); // 5. 把渲染后的文档写入响应流 OutputStream out response.getOutputStream(); BufferedOutputStream bos new BufferedOutputStream(out); template.write(bos); bos.flush(); bos.close(); template.close(); } private MapString, Object queryEmployee(String employeeId) { MapString, Object map new HashMap(); map.put(name, 张三); map.put(department, 研发中心); map.put(hireDate, 2023-06-15); return map; } private ListMapString, Object querySkillList(String employeeId) { ListMapString, Object list new ArrayList(); MapString, Object java new HashMap(); java.put(skillName, Java); java.put(level, 熟练); list.add(java); MapString, Object vue new HashMap(); vue.put(skillName, Vue); vue.put(level, 熟练); list.add(vue); return list; } }这段代码的核心点在于template.render(data)是同步的会直接把数据渲染进内存里的 XWPFTemplate 对象随后template.write(bos)把整个文档的字节流写出来。有几个细节值得特意说如果你的模板里不需要图片photo这个字段可以不传一旦模板里出现了{{photo}}但 data 没给值POI-TL 会抛异常不是自动留空。模板用XWPFTemplate.compile加载时建议用绝对路径。用 classpath 路径虽然方便打包但模板就不能外部替换了。template.close()一定要调用它底层会关闭 XWPFDocument 相关的资源不然频繁导出会出现句柄泄漏。3.4 前端 Vue 代码Blob 下载的正确姿势后端写好了前端这边用 axios 请求文件流。这里提供一个封装好的函数import axios from axios export function downloadEmployeeWord(employeeId) { return axios({ url: /api/export/employee, method: get, params: { employeeId }, responseType: blob, timeout: 30000 }).then(res { const blob new Blob([res.data], { type: application/vnd.openxmlformats-officedocument.wordprocessingml.document }) // 尝试从响应头里拿文件名 const disposition res.headers[content-disposition] let fileName export.docx if (disposition) { const match disposition.match(/filename\*UTF-8([^;])/) if (match) { fileName decodeURIComponent(match[1]) } } const url window.URL.createObjectURL(blob) const link document.createElement(a) link.href url link.download fileName document.body.appendChild(link) link.click() document.body.removeChild(link) window.URL.revokeObjectURL(url) }) }这个封装里最关键的是responseType: blob这是拿文件流而不是 JSON 的前提。文件名解析部分我优先读filename*UTF-8这个参数它是最标准的中文文件名传递方式。还有一个小坑如果res.headers[content-disposition]拿不到先检查后端有没有在跨域配置里暴露这个响应头。Spring Boot 里如果是自定义 CORS 配置要加exposedHeaders(Content-Disposition)否则浏览器会因为安全策略把响应头藏起来。4. 动态表格与图片插入这两个需求最常被问到做了几个真实项目之后我发现导出 Word 的需求里十个里至少有八个离不开表格和图片。表格场景常常是“根据数据库记录数动态生成多个数据行”图片场景则是“把用户上传的签名、产品图、二维码插到文档里”。4.1 动态表格别再手动合并单元格了POI-TL 处理动态表格有两种方式。第一种是像我前面代码里展示的直接在模板里用{{?skills}}和{{/skills}}包住一整行渲染时按列表长度自动扩充行数。这里的模板语法要求循环块必须在一个表格行内注意别把循环标记放在表格外面否则渲染出来的内容会变成一坨普通段落格式全乱。第二种方式是整表数据替换。适用于那种整张表都是动态生成、没有固定表头样式的场景。后端构造一个TableRenderDataListRenderData header Arrays.asList( new TextRenderData(技能名称), new TextRenderData(熟练程度) ); ListObject row1 Arrays.asList(new TextRenderData(Java), new TextRenderData(熟练)); ListObject row2 Arrays.asList(new TextRenderData(Vue), new TextRenderData(熟练)); TableRenderData tableRenderData new TableRenderData(new ArrayList(header), Arrays.asList(row1, row2), null, 0); data.put(dynamicTable, tableRenderData);模板里的对应位置写{{dynamicTable}}POI-TL 会用代码里生成的表格替换掉这个占位符。这个方式的优点是不受模板样式限制适合完全动态的报表缺点是表头、边框、字体颜色都要在代码里定义稍微繁琐一点。4.2 插入图片宽高设置是重点图片插入最常用的场景是二维码、头像、签名。POI-TL 提供了一个PictureRenderDataimport com.deepoove.poi.data.PictureRenderData; import com.deepoove.poi.data.PictureType; // 假设图片已经读到 byte[] 里 byte[] photoBytes Files.readAllBytes(Paths.get(/data/photos/zhangsan.png)); PictureRenderData photo new PictureRenderData(120, 150, PictureType.PNG, photoBytes); data.put(photo, photo);注意构造函数里的 120 和 150 代表的是显示宽度和高度单位是像素。这个值会影响图片在 Word 里的实际显示大小但不会改变图片文件本身。实际项目里如果用户上传的图片非常大比如 5MB 的现场照片直接把原始字节塞进 Word 会导致文档体积膨胀、打开卡顿。我的习惯是先生成一张压缩后的缩略图再放进文档。import javax.imageio.ImageIO; import java.awt.*; import java.awt.image.BufferedImage; public static byte[] resizeImage(byte[] source, int targetWidth, int targetHeight) throws IOException { BufferedImage original ImageIO.read(new ByteArrayInputStream(source)); BufferedImage resized new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB); Graphics2D g resized.createGraphics(); // 设置抗锯齿保证缩放后的质量 g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); g.drawImage(original, 0, 0, targetWidth, targetHeight, null); g.dispose(); ByteArrayOutputStream baos new ByteArrayOutputStream(); ImageIO.write(resized, jpg, baos); return baos.toByteArray(); }压缩成 JPG 后图片文件大小可能只有原来的十分之一而肉眼几乎看不出差别。前端上传图片时也可以先做一次 canvas 压缩这是双保险。4.3 一个频繁踩坑的场景ECharts 图表导出到 Word接着图片说一个高频实战场景系统里的统计分析页面前端用 ECharts 画了柱状图、折线图用户要求“把这张图也导进 Word 报告里”。这个需求跨了前后端实现方式有两条路线路线一前端用chart.getDataURL()拿到 base64 图片数据随请求传到后端后端把它解码成 byte[]再用 PictureRenderData 插入。路线二后端根据查询到的原始数据直接用 Java 绘图库比如 JFreeChart、XChart生成图片再插入 Word。路线一的优点是实现快图表样式和页面完全一致缺点是依赖前端把图传回来如果用户直接调接口导出、不走页面图片就没了。路线二更“正统”后端自成一体但绘图代码需要额外维护生成的图也很难做到和 ECharts 一模一样。我通常是按场景取舍报表类的导出必须从接口直接触发用路线二页面上的“一键导出当前视图”用路线一。如果是路线一前端可以把 base64 数据放到请求体里const chartBase64 myChart.getDataURL({ type: png, pixelRatio: 2, backgroundColor: #fff }) // 传输时去掉 data:image/png;base64, 前缀 const base64Data chartBase64.split(,)[1] axios.post(/api/export/report, { chartBase64: base64Data })后端收到后byte[] chartBytes Base64.getDecoder().decode(request.getChartBase64()); PictureRenderData chart new PictureRenderData(500, 300, PictureType.PNG, chartBytes); data.put(chart, chart);这个方案我在至少三个项目里验证过图片清晰度因为pixelRatio: 2而选得比较高导出在 Word 里也不会模糊。5. 外部文件流与常见异常这些坑我每一个都填过最后这部分专门讲“跑不起来”和“结果不对”这两类问题。文件导出这个功能写起来好像很简单但真正在线上跑各种环境差异导致的诡异问题并不少。5.1 模板文件读取不到最典型的异常是java.io.FileNotFoundException。很多新手把模板放在 resources 目录下用new File(templates/xx.docx)去读本地 IDE 里运行没问题一打成 jar 包就报找不到文件。原因很简单jar 包里的文件不能直接用 File 去定位它在 classpath 里要用ClassPathResource去拿流。如果需要模板跟随应用一起打包可以这样处理import org.springframework.core.io.ClassPathResource; ClassPathResource resource new ClassPathResource(templates/employee-info.docx); InputStream inputStream resource.getInputStream(); XWPFTemplate template XWPFTemplate.compile(inputStream).render(data);但正如前面提到的如果要支持模板在线替换外部目录方案更靠谱。我在生产环境用的是两者结合默认读 classpath 下的模板如果外部磁盘目录存在同名文件就优先生成外部文件。5.2 下载下来的文件打不开前端下载完成双击文件Word 弹窗说文件已损坏。这个问题十有八九是文件流被污染了。常见原因有两个第一个后端往 response 里写数据之前不小心写入了日志或其他字符串。有的开发者会有这种习惯response.getWriter().write(导出成功);这个操作会把导出成功四个字写进文件流的开头docx 文件头部就无效了。注意getOutputStream()和getWriter()混用也会抛异常。第二个文件内容被压缩过。如果你在 Spring Boot 里开启了 Gzip 响应压缩而前端又没有在请求头声明Accept-Encoding: gzip可能导致 Blob 解压失败。更常见的是开发环境里配了 nginx 反代出了这种问题可以先绕过后端直连测试缩小问题范围。5.3 文件名中文乱码这个我在前面已经提过。乱码的根因是响应头里的filename参数只支持 ISO-8859-1 字符集。最稳妥的组合就是filename放 ASCII 安全的 URL 编码字符串再加上filename*UTF-8。有的项目里前端会忽略后端给的文件名前端自己生成一个固定的名字下载。这也是一种解法但不够优雅因为用户可能希望下载下来的文件带有业务编号、日期等后缀。我更倾向于后端下发标准文件名前端按照标准规定解析。5.4 排查实战综合表现象原因处理方式下载的 docx 无法打开提示损坏响应流被日志或额外字符污染检查代码里是否有getWriter().write确保只写模板字节流模板标签没被渲染显示原样{{name}}模板中有隐藏字符或不可见空格用文本编辑器查看 XML去掉标签之间的隐藏字符循环块只渲染了一行数据循环标记放在了表格外部将{{?skills}}和{{/skills}}放在同一表格行内中文文件名乱码响应头 Content-Disposition 未设置 filename*后端给 URL 编码文件名前端解析 filename* 参数图片插入后特别大Word 卡顿原始图片未经压缩直接插入后端生成缩略图后再渲染模板文件在 jar 包里读取不到用了 File 而不是 ClassPathResource改用 getResourceAsStream 或 ClassPathResource 获取流高版本 Spring Boot 下 POI-TL 报 NoSuchMethodError依赖版本冲突在 pom 里锁定 poi-ooxml 版本排查这类问题我的经验是先看响应头状态再抓取原始响应内容。前端浏览器开发者工具里Network 面板能看到 Content-Disposition 是否正常;后端接口测试可以用 curl 或者 Postman 直接拉取文件看能不能正常打开。把前后端问题隔离开定位速度会快很多。6. 前端体验优化让用户觉得这个下载功能“很稳”下载文件这种功能用户感知最强的不是文件里的内容排版而是能不能一次点成功、文件有没有下载下来、下载后文件名对不对、中途断网有没有提示。这些小细节往往是晋升答辩里的加分项也是生产事故里最容易忽略的点。6.1 请求期间加 loading 状态后端生成 Word 文档如果数据量大、模板复杂耗时可能到几秒甚至十几秒。这个时间不能让用户产生“页面是不是卡死了”的错觉。axios 拦截器里可以统一在文件下载请求中设置一个 loading 标记// 简易示例在请求拦截器里开启 loading service.interceptors.request.use(config { if (config.responseType blob) { ElLoading.service({ lock: true, text: 正在生成 Word 文档... }) } return config }) service.interceptors.response.use( response { if (response.config.responseType blob) { ElLoading.service().close() } return response }, error { if (error.config.responseType blob) { ElLoading.service().close() } ElMessage.error(文件下载失败请重试) return Promise.reject(error) } )有一个细节要注意下载接口一旦 HTTP 状态码不是 2xx响应体的 Blob 里可能封装着一份 JSON 错误信息而不是文件内容。前端最好在拿到 Blob 之后先判断数据类型如果 blob.type 是application/json说明后端返回了错误信息要把 JSON 解析出来给用户提示。if (res.data.type application/json) { const reader new FileReader() reader.onload () { const errorInfo JSON.parse(reader.result) ElMessage.error(errorInfo.message || 导出失败) } reader.readAsText(res.data) return }这个细节我见过很多团队没做导致后端一抛异常前端下载的文件直接损坏用户根本不知道发生了什么。6.2 下载完成后自动打开导出的文件有的项目里用户导出后希望立刻看到文件内容。浏览器安全性限制前端不能未经用户允许直接打开本地文件。但可以提供一个轻提示让用户点击“打开文件”按钮。更好的方案是结合 Electron 等桌面容器做自动打开不过那是另一个话题浏览器场景下不做强制。6.3 大数据量导出如何防止用户重复点击如果一次导出要处理几万条数据耗时几十秒用户极大概率会反复点击按钮产生多个并发导出请求数据库和内存都会遭殃。前端的简单做法是按钮禁用el-button :loadingexporting clickhandleExport导出 Word/el-button后端的正解是加幂等控制同一个用户同一份数据在同一时间只允许一个导出任务。实现也不复杂可以在 Redis 里放一个 key导出前setIfAbsent导出结束删除 key期间如果 key 已存在就直接返回“正在导出请勿重复操作”。这个防护在线上高并发环境真的能救命。7. 版本兼容性POI、Spring Boot 和 Vue 之间的几个“隐形地雷”写到这里我觉得很有必要单独开一节聊聊版本兼容。很多问题不是代码逻辑的问题而是生态内依赖版本互相打架导致的问题。这一节的经验主要来源于我踩过的几个深夜加班坑。7.1 Spring Boot 3.x 与 POI-TL 的兼容性网上关于“Spring Boot 版本太高导致 POI-TL 无法使用”的讨论不少。核心原因是 Spring Boot 3.x 基于 Jakarta EE 9而 POI-TL 底层依赖的某些库是 Java EE 命名空间在打包或运行期会报ClassNotFoundException: javax.xml.bind...之类的问题。如果你的项目必须用 Spring Boot 3.x我目前用过比较可行的方法是使用最新版的 POI-TL1.12.x 及以上并在 pom 里显式引入jakarta.xml.bind-api和jaxb-runtime。但这里也要注意POI-TL 官方仓库里对 Spring Boot 3 的适配说明并不多社区里一些老项目仍然留在 Spring Boot 2.7.x。如果公司技术栈强制要求 Spring Boot 3建议先做一个最小原型验证 POI-TL 能正常跑通再铺开。7.2 poi-tl 与 poi 版本映射POI-TL 1.12.x 默认适配 POI 5.2.x如果你在 pom 里引入了其他依赖比如 EasyExcel、Hutool它们可能传递依赖了不同版本的 POI。Maven 依赖仲裁会选最近版本但有时候选出来的反而和 POI-TL 不兼容。最好在 dependencyManagement 里直接锁死 POI 版本保证全局只有一份 POI。7.3 Vue 2 / Vue 3 与文件下载的差异前端这块Vue 2 和 Vue 3 在使用 axios 做文件下载上没有本质区别唯一的注意点是响应拦截器。很多项目从 Vue 2 升到 Vue 3 时把全局请求实例从vue-axios换成了axios直接调用响应拦截器里的responseType判断逻辑要同步检查一遍。比如有的拦截器默认会把 response.data 拿出来返回但对 Blob 类型不做 JSON 解析这个逻辑Vue 3 的 setup 组合式 API 里很容易漏写。另一个和 Vue 版本无关但经常被问到的为什么下载的文件名总是“download”因为a标签没有设置download属性或者设置了但浏览器认为跨域了。同源请求下download属性必须指定文件名且名称里不能有路径分隔符。跨域下载时这个属性会被忽略浏览器就用响应头里的 Content-Disposition 作为文件名。所以你会发现有时候本地开发代理下文件名正常部署到服务器跨域访问就变成 download。如果提前知道会有这个坑前端就可以统一从响应头解析文件名。7.4 时间字段的格式化坑最后提一个和数据组装有关的细节。从数据库查出来的 LocalDateTime直接放进 data map模板渲染后显示出来的可能是一串数字毫秒时间戳或者包含 T 的 ISO 格式。POI-TL 对时间的处理很简陋需要你提前把时间格式化成字符串。我一般统一在 service 层做格式化DateTimeFormatter formatter DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss); data.put(createTime, localDateTime.format(formatter));这个坑很小但如果不注意交付的 Word 文档里时间字段会非常难看给业务方的第一印象就差了。8. 项目落地后的几点扩展思考功能跑通只是开始真实业务里你对“导出 Word”的要求会不断变化。这里分享几个我觉得特别值得做的扩展方向每个都是我在实际项目里做过、并且确实解决过问题的。8.1 支持导出 PDF 作为备份有些场景下Word 文档生成之后业务方还要一个 PDF 版本用于归档。与其让前端再走一遍 pdf 导出逻辑不如后端在生成 docx 后直接转 PDF。Java 生态里可以用 LibreOffice headless 方式转换或者用 Aspose.Words 这类商业库。LibreOffice 方案免费但转换速度一般还依赖服务器安装软件Aspose.Words 转 PDF 的效果几乎无损但商业授权不便宜。一个比较折中的方案是后端只生成 docx前端页面提供 Word 和 PDF 两个下载按钮PDF 直接用浏览器的打印功能window.print由用户手动导出。虽然不够自动化但胜在零成本、零依赖。8.2 把模板拆分到微服务如果你的系统比较庞大多个模块都需要导出 Word共享一套模板管理服务会非常方便。模板落库或落 OSS模板元数据模板编码、版本号、关联表单存数据库。导出接口统一走模板服务获取模板内容业务模块只负责传数据模型。这样模板的更新就完全不依赖业务代码发布非常适合作为基础平台沉淀。8.3 异步导出与通知数据量一旦上来同步导出会卡住接口。更合理的做法是请求到达后立刻返回一个“任务已接受”后台异步线程池执行导出完成后把文件路径存库再通过 WebSocket 或者轮询告知前端下载。这个方案实现复杂度更高但用户体验好很多。而且还能配合任务表做失败重试、导出记录审计。其实对于绝大多数 Spring Boot Vue 项目来说掌握模板渲染、文件流传递、动态表格和图片插入这几件事就足以覆盖 90% 的导出 Word 需求。我遇到过很多人上来就研究底层 XML、自定义样式函数结果项目交期都过了模板还没调好。建议你先跑通一条最小链路再逐步扩展需求这会让你的开发过程轻松很多。