一文搞定 Spring Boot 3 + POI XWPF 导出Word 文档

对于许多开发者来说,在 Java 后端动态生成一份格式复杂、内容多样的 Word 文档似乎是一项艰巨的任务。如何优雅地处理文本、表格、图片?如何复用固定的样式和布局?如何将这一切与现代的 Spring Boot 3 框架和前后端分离的架构相结合?

本文将是您寻找答案的终点站。我们将从零开始,手把手带您利用强大的 Apache POI 库,结合 Spring Boot 3,构建一个健壮、高效、可复用的 Word 文档导出解决方案。您将学到:

  • Apache POI 核心原理:深入理解 POI 如何解构 Word(.docx) 文档,掌握 XWPFDocumentXWPFParagraphXWPFRunXWPFTable 等核心对象。

  • 模板驱动的开发模式:学习业界最主流、最高效的“模板填充”方案,将文档样式设计与后端数据逻辑彻底分离,让后端开发者专注于业务,UI 设计师或产品经理可以轻松维护 Word 模板。

  • 后端接口实战:基于 Spring Boot 3,编写一个完整的、符合 RESTful 风格的文件导出接口,并处理好 HTTP 响应头,确保浏览器能正确识别和下载文件。

  • 前端下载逻辑:提供可以直接使用的前端 JavaScript 代码,演示如何通过 fetch API 请求文件接口,并优雅地处理返回的二进制文件流(Blob),触发浏览器进行下载。

  • 进阶技巧与最佳实践:探讨如何处理动态表格、插入图片、优化内存使用以及代码封装,让您的解决方案更加专业和完善。

读完本文,您将不再畏惧任何 Word 导出需求,并能充满信心地在项目中构建出任何想要的 Word 文档。让我们开始吧!

第一章:原理篇 - 揭开 Word 文档的神秘面纱

在动手写代码之前,我们必须先理解工具的原理。所谓“工欲善其事,必先利其器”,这里的“器”就是 Apache POI。

1.1 什么是 Apache POI?

Apache POI (Poor Obfuscation Implementation) 是一个由 Apache 软件基金会维护的开源项目,它提供了一系列 Java API,用于读、写和操作各种基于 Microsoft OLE2 和 OOXML 标准的文件格式。

  • OLE2 (Object Linking and Embedding 2):这是旧版 Office 文件(如 .doc, .xls, .ppt)的复合文档格式。

  • OOXML (Office Open XML):这是新版 Office 文件(如 .docx, .xlsx, .pptx)的格式,它本质上是一个包含了多个 XML 文件和资源的 ZIP 压缩包。

我们的目标是操作 .docx 文件,因此我们将主要关注 POI 中处理 OOXML 的部分。

1.2 XWPF:操作 .docx 的瑞士军刀

针对 .docx 格式,POI 提供了专门的组件——XWPF (XML Word Processor Format)。它为我们提供了一套面向对象的 API,让我们能够像操作 Java 对象一样去操作 Word 文档的各个组成部分。

您可以将一个 .docx 文件重命名为 .zip 并解压缩,您会看到一个类似这样的文件结构:

/
├── [Content_Types].xml
├── _rels/
├── docProps/
│   ├── app.xml
│   ├── core.xml
│   └── custom.xml
└── word/
    ├── document.xml  <-- 文档主体内容在这里!
    ├── _rels/
    ├── theme/
    └── media/        <-- 图片等媒体资源
        └── image1.png

其中,word/document.xml 是最重要的文件,它定义了文档的所有内容和结构。XWPF 的工作原理就是解析这个 XML 文件,将其中的标签映射成 Java 对象,让我们能够通过代码进行增删改查。

1.3 XWPF 的核心对象模型

理解 XWPF 的核心对象模型,是掌握 Word 导出的关键。

!(https://i.imgur.com/gY8X2aE.png)

  • XWPFDocument: 代表整个 .docx 文档。它是所有操作的入口,相当于 word/document.xml 文件的根节点。我们可以通过它获取文档中的所有段落、表格等。

  • XWPFParagraph: 代表一个段落。在 Word 中,每次按下回车键,就会创建一个新的段落。它是文本的基本容器。

  • XWPFRun: 代表一个文本“运行块”。一个段落(XWPFParagraph)可以由一个或多个 XWPFRun 组成。这是 POI 中一个极其重要的概念。同一个 Run 内的文本拥有完全相同的样式(字体、大小、颜色、加粗、斜体等)。如果一段话中,“你好”是红色,“世界”是黑色,那么它们必然属于两个不同的 XWPFRun。我们在替换文本时,操作的主要对象就是 Run

  • XWPFTable: 代表一个表格。通过它可以获取表格的所有行。

  • XWPFTableRow: 代表表格中的一行。

  • XWPFTableCell: 代表一个单元格。单元格内部可以包含段落(XWPFParagraph),因此也可以包含复杂的文本和样式。

1.4 为什么选择“模板填充”方案?

生成 Word 文档主要有两种方式:

  1. 纯代码生成:从 new XWPFDocument() 开始,用 Java 代码一行一行地创建段落、设置样式、构建表格。这种方式灵活度极高,但对于有复杂样式(如页眉、页脚、特定字体、段间距)的文档来说,代码会变得极其冗长和难以维护。任何微小的样式调整都需要修改代码并重新部署,效率低下。

  2. 模板填充:预先创建一个包含所有固定样式、布局和占位符的 .docx 模板文件。然后通过代码加载这个模板,识别并替换其中的占位符为动态数据。这种方式的优势是巨大的:

    • 职责分离:文档的样式和布局由产品经理或UI设计师在 Word 中直接设计,他们不需要懂代码。开发者只需关注数据填充逻辑。

    • 维护高效:当需要调整样式(比如改个字体、换个Logo),只需修改模板文件,后端代码无需任何改动。

    • 所见即所得:模板本身就是一个标准的 Word 文档,最终导出的效果在模板中一目了然。

因此,本文将重点围绕模板填充方案进行实战讲解。我们将使用 ${placeholder} 这样的格式作为占位符。

第二章:后端实战篇 - 构建 Spring Boot 导出服务

理论学习完毕,让我们卷起袖子开始编码!

2.1 项目初始化

首先,我们需要一个 Spring Boot 3 的项目。您可以通过 Spring Initializr 快速创建一个。

项目配置:

  • Project: Maven

  • Language: Java

  • Spring Boot: 3.x.x (选择一个最新的稳定版)

  • Group: com.example

  • Artifact: word-export-demo

  • Packaging: Jar

  • Java: 17 or newer

添加依赖:

  1. Spring Web: 用于构建 Web 应用和 RESTful API。

  2. Apache POI OOXML: 这是操作 .docx 文件所必需的核心库。

在您的 pom.xml 文件中,确保包含以下依赖:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Apache POI for .docx (OOXML) -->
    <dependency>
        <groupId>org.apache.poi</groupId>
        <artifactId>poi-ooxml</artifactId>
        <version>5.2.5</version> <!-- 建议使用较新版本 -->
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

2.2 创建 Word 模板

src/main/resources 目录下创建一个名为 templates 的文件夹。然后,使用 Microsoft Word 或 WPS 创建一个名为 report_template.docx 的文件,并保存在这个文件夹下。

模板内容可以设计如下:

${reportTitle}

报告编号: ${reportId}

生成日期: ${reportDate}

制 表 人: ${author}

一、项目概述

本报告旨在总结 ${projectName} 项目的进展情况。项目自 ${startDate} 启动以来,取得了阶段性的成果。

二、核心数据
指标名称指标值备注

${item.name}

${item.value}

${item.remark}

三、项目图片

${projectImage}

模板说明:

  • 我们使用了 ${...} 格式的占位符来标记需要动态填充的数据。

  • 对于表格,我们设计了一个“模板行”。稍后,我们将通过代码复制这一行,并用一个列表的数据来循环填充,从而动态生成表格内容。

  • 我们还为图片预留了一个占位符 ${projectImage}

2.3 编写导出服务核心逻辑

现在,我们来编写负责生成 Word 文档的 Service。

src/main/java/com/example/wordexportdemo/service/WordExportService.java

package com.example.wordexportdemo.service;

import org.apache.poi.openxml4j.exceptions.InvalidFormatException;
import org.apache.poi.util.Units;
import org.apache.poi.xwpf.usermodel.*;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Service
public class WordExportService {

    /**
     * 生成报告的Word文档
     * @return 包含Word文档内容的字节数组输出流
     * @throws IOException
     * @throws InvalidFormatException
     */
    public ByteArrayOutputStream generateReport() throws IOException, InvalidFormatException {
        // 1. 准备数据
        Map<String, Object> data = prepareData();

        // 2. 加载模板
        // 使用 ClassPathResource 来加载资源文件,这是Spring推荐的方式
        ClassPathResource resource = new ClassPathResource("templates/report_template.docx");
        try (InputStream templateInputStream = resource.getInputStream();
             XWPFDocument document = new XWPFDocument(templateInputStream)) {

            // 3. 替换段落中的占位符
            replacePlaceholdersInParagraphs(document.getParagraphs(), data);

            // 4. 填充表格
            populateTable(document, data);
            
            // 5. 插入图片
            replaceImagePlaceholder(document, "projectImage", "static/project_logo.png");

            // 6. 将处理后的文档写入字节数组输出流
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            document.write(baos);
            return baos;
        }
    }

    /**
     * 准备填充到模板中的数据
     * @return 数据Map
     */
    private Map<String, Object> prepareData() {
        Map<String, Object> data = new HashMap<>();
        data.put("reportTitle", "XX项目月度进展报告");
        data.put("reportId", "PRJ-2025-07-001");
        data.put("reportDate", new SimpleDateFormat("yyyy年MM月dd日").format(new Date()));
        data.put("author", "技术部 - 张三");
        data.put("projectName", "天网一号");
        data.put("startDate", "2025-01-01");

        // 准备表格数据
        List<Map<String, String>> tableData = List.of(
                Map.of("name", "用户增长率", "value", "15%", "remark", "超出预期"),
                Map.of("name", "活跃用户数", "value", "1.2 Million", "remark", "稳定增长"),
                Map.of("name", "服务器负载", "value", "65%", "remark", "正常范围")
        );
        data.put("tableData", tableData);

        return data;
    }

    /**
     * 替换段落列表中的占位符
     * @param paragraphs 段落列表
     * @param data 数据Map
     */
    private void replacePlaceholdersInParagraphs(List<XWPFParagraph> paragraphs, Map<String, Object> data) {
        for (XWPFParagraph p : paragraphs) {
            for (XWPFRun run : p.getRuns()) {
                String text = run.getText(0);
                if (text != null) {
                    for (Map.Entry<String, Object> entry : data.entrySet()) {
                        String key = "${" + entry.getKey() + "}";
                        if (text.contains(key) && entry.getValue() instanceof String) {
                            text = text.replace(key, (String) entry.getValue());
                        }
                    }
                    // 清除旧的文本并设置新的文本
                    run.setText(text, 0);
                }
            }
        }
    }

    /**
     * 填充表格数据
     * @param document 文档对象
     * @param data 数据Map
     */
    @SuppressWarnings("unchecked")
    private void populateTable(XWPFDocument document, Map<String, Object> data) {
        List<Map<String, String>> tableData = (List<Map<String, String>>) data.get("tableData");
        if (tableData == null || tableData.isEmpty()) {
            return;
        }

        // 假设文档中只有一个表格
        XWPFTable table = document.getTables().get(0);
        // 获取模板行(第二行,索引为1)
        XWPFTableRow templateRow = table.getRow(1);

        for (Map<String, String> rowData : tableData) {
            // 创建新行,并复制模板行的样式
            XWPFTableRow newRow = table.createRow();
            copyRow(templateRow, newRow);
            // 填充新行的数据
            fillRow(newRow, rowData);
        }

        // 删除模板行
        table.removeRow(1);
    }
    
    /**
     * 替换图片占位符
     * @param document 文档对象
     * @param placeholder 占位符文本
     * @param imagePath 图片在classpath下的路径
     */
    private void replaceImagePlaceholder(XWPFDocument document, String placeholder, String imagePath) throws IOException, InvalidFormatException {
        String placeholderText = "${" + placeholder + "}";
        
        for (XWPFParagraph p : document.getParagraphs()) {
            List<XWPFRun> runs = p.getRuns();
            if (runs == null || runs.isEmpty()) continue;

            String paragraphText = p.getText();
            if (paragraphText.contains(placeholderText)) {
                // 清空段落内容,准备插入图片
                // 从后往前删除run,避免索引问题
                for (int i = runs.size() - 1; i >= 0; i--) {
                    p.removeRun(i);
                }

                // 加载图片文件
                ClassPathResource imageResource = new ClassPathResource(imagePath);
                try (InputStream imageStream = imageResource.getInputStream()) {
                    // 在段落中创建一个新的run来承载图片
                    XWPFRun imageRun = p.createRun();
                    imageRun.addPicture(imageStream, Document.PICTURE_TYPE_PNG, imageResource.getFilename(), Units.toEMU(400), Units.toEMU(200)); // 设置图片大小
                }
                // 找到并处理完一个占位符后即可退出,避免重复处理
                return;
            }
        }
    }


    /**
     * 复制行,包括单元格内容和样式
     * @param sourceRow 源行
     * @param targetRow 目标行
     */
    private void copyRow(XWPFTableRow sourceRow, XWPFTableRow targetRow) {
        for (int i = 0; i < sourceRow.getTableCells().size(); i++) {
            XWPFTableCell sourceCell = sourceRow.getCell(i);
            XWPFTableCell targetCell = targetRow.getCell(i);
            if (targetCell == null) {
                targetCell = targetRow.addNewTableCell();
            }
            // 复制单元格的XML,这是最完整的复制方式
            targetCell.getCTTc().set(sourceCell.getCTTc().copy());
        }
    }

    /**
     * 填充行数据
     * @param row 要填充的行
     * @param rowData 行数据
     */
    private void fillRow(XWPFTableRow row, Map<String, String> rowData) {
        for (XWPFTableCell cell : row.getTableCells()) {
            for (XWPFParagraph p : cell.getParagraphs()) {
                replacePlaceholdersInParagraphs(List.of(p), new HashMap<>(rowData));
            }
        }
    }
}

代码解析:

  1. generateReport(): 这是主方法。它协调整个流程:准备数据 -> 加载模板 -> 替换段落占位符 -> 填充表格 -> 插入图片 -> 输出到流。

  2. prepareData(): 模拟从数据库或其他服务获取的业务数据。注意,表格数据我们用 List<Map<String, String>> 结构,非常灵活。

  3. replacePlaceholdersInParagraphs(): 这是文本替换的核心。它遍历所有段落和其中的 Run,找到包含 ${...} 的文本并进行替换。

    • 注意一个陷阱:一个占位符,如 ${projectName},可能会因为在 Word 中编辑过(比如一部分加粗)而被分割到多个 Run 中,例如 Run1 包含 ${projRun2 包含 ectNaRun3 包含 me}。上面的简单实现无法处理这种情况。更健壮的方案需要先将一个段落的所有 Run 的文本拼接起来,找到占位符,然后再进行复杂的替换和样式重建。不过对于由程序生成的、未被手动破坏的模板,简单实现通常够用。

  4. populateTable(): 这是动态表格处理的核心。

    • 它首先定位到模板中的表格。

    • 获取作为“模板”的行(这里是第二行)。

    • 遍历数据列表,每条数据都调用 table.createRow() 创建一个新行。

    • copyRow() 方法负责将模板行的样式(边框、背景色等)复制到新行。

    • fillRow() 方法在新行中执行占位符替换。

    • 最后,移除作为模板的第二行,留下表头和新填充的数据行。

  5. replaceImagePlaceholder(): 专门处理图片。它找到包含图片占位符的段落,清空该段落的所有 Run,然后加载 src/main/resources/static 目录下的图片文件,并使用 run.addPicture() 将其插入。你需要自己准备一张名为 project_logo.png 的图片放在该目录下。

2.4 创建 Controller 接口

最后,我们需要一个 Controller 来暴露这个服务为 HTTP 接口。

src/main/java/com/example/wordexportdemo/controller/WordExportController.java

package com.example.wordexportdemo.controller;

import com.example.wordexportdemo.service.WordExportService;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.io.ByteArrayOutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;

@RestController
@RequestMapping("/api/export")
public class WordExportController {

    @Autowired
    private WordExportService wordExportService;

    @GetMapping("/word")
    public void exportWord(HttpServletResponse response) {
        try {
            // 调用Service生成Word文档的字节流
            ByteArrayOutputStream baos = wordExportService.generateReport();

            // 设置响应头
            // 1. 内容类型:告诉浏览器这是一个 .docx 文件
            response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
            // 2. 内容长度
            response.setContentLength(baos.size());
            // 3. 内容处置:attachment表示以附件形式下载,并设置文件名
            //    对文件名进行URL编码,以防止中文乱码
            String fileName = "项目报告.docx";
            String encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replaceAll("\\+", "%20");
            response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encodedFileName);

            // 将字节流写入响应的输出流
            response.getOutputStream().write(baos.toByteArray());
            response.getOutputStream().flush();

        } catch (Exception e) {
            e.printStackTrace();
            // 可以添加更完善的错误处理逻辑
            response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
        }
    }
}

代码解析:

  1. @GetMapping("/word"): 定义了接口的路径。

  2. HttpServletResponse response: 我们直接注入 HttpServletResponse 对象,以便完全控制 HTTP 响应。

  3. 设置响应头 (至关重要):

    • response.setContentType(...): 这是 MIME 类型,application/vnd.openxmlformats-officedocument.wordprocessingml.document.docx 文件标准的 MIME 类型。浏览器根据它来决定如何处理响应体。

    • response.setContentLength(...): 设置内容长度,有助于浏览器显示下载进度。

    • response.setHeader("Content-Disposition", ...): 这是最关键的头。attachment 指示浏览器将响应内容作为附件下载,而不是试图在页面内直接打开。filename*= 是 RFC 5987 中推荐的方案,可以更好地处理非 ASCII 字符(如中文)的文件名,避免乱码问题。

  4. 写入输出流: response.getOutputStream().write(...) 将 Service 生成的 Word 文档字节数据写入响应体,发送给客户端。

至此,我们的后端服务已经全部完成!运行 Spring Boot 应用,后端将在 8080 端口等待请求。

第三章:前端交互篇 - 从浏览器发起下载

后端接口已经就绪,现在我们需要一个前端页面来调用它。

3.1 HTML 页面

创建一个简单的 HTML 文件 index.html,可以放在 src/main/resources/static 目录下,这样就可以通过 http://localhost:8080/index.html 访问。

src/main/resources/static/index.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Word 导出示例</title>
    <style>
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            margin: 0;
            background-color: #f4f7f6;
        }
        .container {
            text-align: center;
        }
        h1 {
            color: #333;
        }
        #exportBtn {
            background-color: #4CAF50; /* Green */
            border: none;
            color: white;
            padding: 15px 32px;
            text-align: center;
            text-decoration: none;
            display: inline-block;
            font-size: 16px;
            margin: 4px 2px;
            cursor: pointer;
            border-radius: 8px;
            transition: background-color 0.3s, box-shadow 0.3s;
            box-shadow: 0 4px 6px rgba(0,0,0,0.1);
        }
        #exportBtn:hover {
            background-color: #45a049;
            box-shadow: 0 6px 8px rgba(0,0,0,0.15);
        }
        #exportBtn:disabled {
            background-color: #cccccc;
            cursor: not-allowed;
        }
        .loader {
            border: 4px solid #f3f3f3;
            border-radius: 50%;
            border-top: 4px solid #3498db;
            width: 20px;
            height: 20px;
            animation: spin 2s linear infinite;
            display: none; /* Initially hidden */
            margin: 10px auto;
        }
        @keyframes spin {
            0% { transform: rotate(0deg); }
            100% { transform: rotate(360deg); }
        }
    </style>
</head>
<body>

<div class="container">
    <h1>Spring Boot 3 + POI Word 导出</h1>
    <button id="exportBtn">导出报告</button>
    <div id="loader" class="loader"></div>
</div>

<script src="script.js"></script>

</body>
</html>

3.2 JavaScript 逻辑

src/main/resources/static 目录下创建 script.js 文件。

src/main/resources/static/script.js

document.getElementById('exportBtn').addEventListener('click', function() {
    const btn = this;
    const loader = document.getElementById('loader');

    // 禁用按钮并显示加载动画
    btn.disabled = true;
    loader.style.display = 'block';

    // 1. 发起请求
    fetch('/api/export/word', {
        method: 'GET',
    })
    .then(response => {
        // 2. 检查响应是否成功
        if (!response.ok) {
            throw new Error('网络响应错误,状态码: ' + response.status);
        }
        
        // 从响应头中获取文件名
        const contentDisposition = response.headers.get('content-disposition');
        let filename = 'download.docx'; // 默认文件名
        if (contentDisposition) {
            const filenameMatch = contentDisposition.match(/filename\*=UTF-8''(.+)/);
            if (filenameMatch && filenameMatch.length > 1) {
                 // 解码文件名
                filename = decodeURIComponent(filenameMatch[1]);
            } else {
                 const filenameMatchRegular = contentDisposition.match(/filename="(.+)"/);
                 if (filenameMatchRegular && filenameMatchRegular.length > 1) {
                    filename = filenameMatchRegular[1];
                 }
            }
        }

        // 3. 将响应体转换为 Blob 对象
        return response.blob().then(blob => ({ blob, filename }));
    })
    .then(({ blob, filename }) => {
        // 4. 创建一个临时的 URL 指向 Blob 对象
        const url = window.URL.createObjectURL(blob);

        // 5. 创建一个 <a> 标签用于下载
        const a = document.createElement('a');
        a.style.display = 'none';
        a.href = url;
        a.download = filename; // 设置下载的文件名

        // 6. 将 <a> 标签添加到 DOM 中并模拟点击
        document.body.appendChild(a);
        a.click();

        // 7. 清理:释放 URL 对象并移除 <a> 标签
        window.URL.revokeObjectURL(url);
        document.body.removeChild(a);

        console.log('文件下载成功!');
    })
    .catch(error => {
        console.error('下载文件时发生错误:', error);
        alert('导出失败,请查看控制台获取更多信息。');
    })
    .finally(() => {
        // 无论成功或失败,都恢复按钮状态
        btn.disabled = false;
        loader.style.display = 'none';
    });
});

代码解析:

  1. fetch('/api/export/word'): 使用 fetch API 向我们后端的 /api/export/word 接口发送一个 GET 请求。

  2. response.ok: 检查 HTTP 状态码是否在 200-299 范围内,确保请求成功。

  3. 获取文件名: 我们尝试从 Content-Disposition 响应头中解析出后端设置的文件名。这使得文件名可以由后端动态控制,更加灵活。

  4. response.blob(): 这是整个前端逻辑的核心!fetchresponse 对象提供了一个 .blob() 方法,它可以将响应体读取为一个 Blob (Binary Large Object) 对象。Blob 对象表示一个不可变的、原始数据的类文件对象,非常适合用来处理图片、音视频和我们这里的 Word 文件流。

  5. window.URL.createObjectURL(blob): 这个方法会创建一个临时的、唯一的 URL,该 URL 指向内存中的 Blob 对象。

  6. 创建并点击 <a> 标签: 我们动态创建一个隐藏的 <a> 链接。

    • a.href = url;: 将其 href 属性设置为我们刚刚创建的 Blob URL。

    • a.download = filename;: download 属性指示浏览器下载 href 指向的资源,而不是导航到它。其值就是下载后保存的文件名。

    • a.click(): 模拟用户点击这个链接,从而触发浏览器的下载行为。

  7. 清理: 下载完成后,window.URL.revokeObjectURL(url) 用于释放之前创建的临时 URL,以避免内存泄漏。同时移除我们动态添加的 <a> 标签。

  8. UI 交互: 代码中还包含了禁用按钮和显示加载动画的逻辑,提升了用户体验。

现在,启动后端应用,访问 http://localhost:8080/index.html,点击“导出报告”按钮,浏览器就会自动下载一份名为“项目报告.docx”的文件。打开它,你会看到模板中的占位符已经被我们准备的数据完美替换了!

!(https://i.imgur.com/kYqU9a3.png)

第四章:进阶与优化

掌握了基本流程后,我们还可以从以下几个方面让我们的方案更加完善。

4.1 内存优化

在上面的例子中,我们使用了 ByteArrayOutputStream。它会在内存中创建一个字节数组来缓存整个 Word 文档,如果文档非常大(比如几十上百兆),可能会对服务器内存造成压力。

一个更优化的方式是直接将 XWPFDocument 写入 HttpServletResponse 的输出流,而不是通过中间的字节数组。

// 在 Controller 中
@GetMapping("/word-stream")
public void exportWordStream(HttpServletResponse response) {
    // ... 设置响应头 (同上) ...
    try {
        // 直接将文档写入响应流
        wordExportService.generateReportAndWriteStream(response.getOutputStream());
        response.getOutputStream().flush();
    } catch (Exception e) {
        // ... 错误处理 ...
    }
}

// 在 Service 中添加一个新方法
public void generateReportAndWriteStream(OutputStream outputStream) throws IOException, InvalidFormatException {
    // ... 加载模板和数据 ...
    try (InputStream templateInputStream = resource.getInputStream();
         XWPFDocument document = new XWPFDocument(templateInputStream)) {
        
        // ... 替换逻辑 (同上) ...
        
        // 直接写入传入的输出流
        document.write(outputStream);
    }
}

这种方式是流式的,内存占用更低,尤其适合大文件的导出场景。

4.2 封装与工具类

随着导出需求的增多,WordExportService 中的替换逻辑会变得越来越复杂。将通用的替换方法封装成一个工具类是很好的实践。

XWPFUtils.java

public class XWPFUtils {
    // 可以将 replacePlaceholdersInParagraphs, populateTable 等方法移到这里
    // 并将它们声明为静态方法,方便在任何地方调用
}

4.3 考虑使用模板引擎库

虽然我们手动实现了占位符替换,但对于更复杂的场景(如循环嵌套、条件判断等),手动操作 XWPFRun 会变得非常困难。

社区中有一些优秀的库,它们在 Apache POI 的基础上提供了更高级的模板引擎功能,最著名的就是 poi-tl (poi-template-language)。

使用 poi-tl,你的代码可以简化成这样:

// 引入 poi-tl 依赖
<dependency>
    <groupId>com.deepoove</groupId>
    <artifactId>poi-tl</artifactId>
    <version>1.12.1</version>
</dependency>

// Service 代码
public void exportWithPoiTl() {
    XWPFTemplate template = XWPFTemplate.compile("template.docx").render(
        new HashMap<String, Object>() {{
            put("reportTitle", "XX项目报告");
            // ... 其他数据 ...
            // 表格可以直接是一个 List<Object>
            put("tableData", userList); 
        }}
    );
    template.writeAndClose(new FileOutputStream("output.docx"));
}
```
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

青见丑橘

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值