想讓你的工作輕鬆高效嗎?揭祕Java + React匯出Excel/PDF的絕妙技巧!

2023-09-04 09:00:33

前言

在B/S架構中,伺服器端匯出是一種高效的方式。它將匯出的邏輯放在伺服器端,前端僅需發起請求即可。通過在伺服器端完成匯出後,前端再下載檔案完成整個匯出過程。伺服器端匯出具有許多優點,如資料安全、適用於大規模資料場景以及不受前端效能影響等。

本文將使用前端框架React和伺服器端框架Spring Boot搭建一個演示的Demo,展示如何在伺服器端匯出Excel和PDF檔案。當然,對於前端框架,如Vue、Angular等也可以採用類似的原理來實現相同的功能。

在伺服器端匯出過程中,需要依賴額外的元件來處理Excel和PDF檔案。對於Excel相關操作,可以選擇POI庫,而對於PDF檔案,可以選擇IText庫。為了方便起見,本方案選擇了GcExcel,它原生支援Excel、PDF、HTML和圖片等多種格式的匯出功能。這樣一來,在實現匯出功能的同時,也提供了更多的靈活性和互操作性。

實踐

本文將演示如何建立一個簡單的表單,其中包括姓名和電子郵箱欄位,這些欄位將作為匯出資料。同時,前端將提供一個下拉選擇器和一個匯出按鈕,通過下拉選擇器選擇匯出的格式,然後點選匯出按鈕傳送請求。等待伺服器端處理完成後,前端將下載匯出的檔案。

在伺服器端,我們需要實現相應的API來處理提交資料的請求和匯出請求。我們可以定義一個物件,在記憶體中儲存提交的資料。然後利用GcExcel庫構建Excel物件,並將資料匯出為不同的格式。

前端 React

1.建立React工程

新建一個資料夾,如ExportSolution,進入資料夾,在資源管理器的位址列裡輸入cmd,然後回車,開啟命令列視窗。

使用下面的程式碼建立名為client-app的react app。

npx create-react-app client-app

進入建立的client-app資料夾,使用IDE,比如VisualStudio Code開啟它。

2.設定表單部分

更新Src/App.js的程式碼,建立React app時,腳手架會建立範例程式碼,需要刪除它們。如下圖(紅色部分刪除,綠色部分新增)。

在Src目錄下,新增一個名為FormComponent.js的檔案,在App.js中新增參照。

在FormComponent.js中新增如下程式碼。其中定義了三個state, formData和exportType,count用來儲存頁面上的值。與伺服器端互動的方法,僅做了定義。

import React, { useEffect, useState } from 'react';

export const FormComponent = () => {
    const [formData, setFormData] = useState({
        name: '',
        email: '',
    });
    const [exportType, setExportType] = useState('0');
    const [count, setCount] = useState(0);

    useEffect(() => {
        fetchCount();
    },[]);

    const fetchCount = async () => {
        //TODO
    }
    
    const formDataHandleChange = (e) => {
        setFormData({
            ...formData,
            [e.target.name]: e.target.value
        });
    };

    const exportDataHandleChange = (e) => {
        setExportType(e.target.value);
    };

    const handleSubmit = async (e) => {
        //TODO
    };

    const download = async (e) => {        
        //TODO
    }

    return (
        <div class="form-container">
            <label>資訊提交</label>
            <br></br>
            <label>已有<span class="submission-count">{count}</span>次提交</label>
            <hr></hr>
            <form class="form" onSubmit={handleSubmit}>
                <label>
                    姓名:
                    <input type="text" name="name" value={formData.name} onChange={formDataHandleChange} />
                </label>
                <br />
                <label>
                    郵箱:
                    <input type="email" name="email" value={formData.email} onChange={formDataHandleChange} />
                </label>
                <button type="submit">提交</button>
            </form>
            <hr />
            <div className='export'>
                <label>
                    匯出型別:
                    <select class="export-select" name="exportType" value={exportType} onChange={exportDataHandleChange}>
                        <option value='0'>Xlsx</option>
                        <option value='1'>CSV</option>
                        <option value='2'>PDF</option>
                        <option value='3'>HTML</option>
                        <option value='4'>PNG</option>
                    </select>
                </label>
                <br />
                <button class="export-button" onClick={download}>匯出並下載</button>
            </div>
        </div>
    );
}

CSS的程式碼如下:

.form-container {
  margin: 20px;
  padding: 20px;
  border: 1px solid #ccc;
  width: 300px;
  font-family: Arial, sans-serif;
  min-width: 40vw;
}

.submission-count {
  font-weight: bold;
  
}
.form{
  
  text-align: left;
}

.form label {
  display: block;
  margin-bottom: 10px;
  font-weight: bold;
}

.form input[type="text"],
.form input[type="email"] {
  width: 100%;
  padding: 5px;
  margin-bottom: 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
}

.form button {
  padding: 10px 20px;
  background-color: #007bff;
  color: #fff;
  border-radius: 4px;
  cursor: pointer;
  width: 100%;
}

.export{
  text-align: left;
}

.export-select {
  padding: 5px;
  margin-bottom: 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
  width: 10vw;
}

.export-button {
  padding: 10px 20px;
  background-color: #007bff;
  color: #fff;
  border-radius: 4px;
  cursor: pointer;
  width: 100%;
}

hr {
  margin-top: 20px;
  margin-bottom: 20px;
  border: none;
  border-top: 1px solid #ccc;
}

試著執行起來,效果應該如下圖:

3.Axios請求及檔案下載

前端與伺服器端互動,一共有三種請求:

  • 頁面載入時,獲取伺服器端有多少次資料已經被提交
  • 提交資料,並且獲取一共有多少次資料已經被提交
  • 傳送匯出請求,並根據結果下載檔案。

通過npm新增兩個依賴,Axios用於傳送請求,file-saver用於下載檔案。

npm install axios
npm install file-saver

在FormComponent.js中新增參照

import axios from 'axios';
import { saveAs } from 'file-saver';

三個請求方法的程式碼如下:

    const fetchCount = async () => {
        let res = await axios.post("api/getListCount");
        if (res !== null) {
            setCount(res.data);
        }
    }
    
    const handleSubmit = async (e) => {
        e.preventDefault();
        let res = await axios.post("api/commitData", {...formData});
        if (res !== null) {
            setCount(res.data);
        }
    };

    const download = async (e) => {
        let headers = {
            'Content-Type': 'application/json',
            'Access-Control-Allow-Headers': 'Content-Disposition'
        };
        let data = { exportType: exportType };
        let res = await axios.post('/api/exportDataList', data, { headers: headers, responseType: 'blob' });
        if (res !== null) {
            let contentDisposition = res.headers['content-disposition']
            let filename = contentDisposition.substring(contentDisposition.indexOf('"') + 1, contentDisposition.length - 1);
            saveAs(res.data, filename);
        }
    }

三個請求都是同步的,使用了await等待返回結果。三個請求,會分別向已定義的api傳送請求,其中fetchCount,僅會在頁面第一次完成載入時執行。其他兩個請求方法會在點選按鈕時觸發。

4.設定請求轉發中介軟體

因為React的程式會預設使用3000埠號,而Springboot預設使用8080埠。如果在Axios直接向伺服器端傳送請求時(比如:localhost:8080/api/getListCount ),會出現跨域的問題。因此需要新增一箇中介軟體來轉發請求,避免前端跨域存取的問題。

在src資料夾下面新增檔案,名為setupProxy.js,程式碼如下:

const { createProxyMiddleware } = require('http-proxy-middleware');

module.exports = function(app) {
  app.use(
    '/api',
    createProxyMiddleware({
      target: 'http://localhost:8080',
      changeOrigin: true,
    })
  );
};

OK,至此前端程式碼基本完成,但還暫時不能執行測試,因為伺服器端程式碼沒有完成。

伺服器端 Springboot

1.建立Springboot工程

使用IDEA建立一個Springboot工程,如果使用的是社群(community)版本,不能直接建立Springboot專案,那可以先建立一個空專案,idea建立project的過程,就跳過了,這裡我們以建立了一個gradle專案為例。

plugins {
    id 'org.springframework.boot' version '3.0.0'
    id 'io.spring.dependency-management' version '1.1.0'
    id 'java'
    id 'war'
}

group = 'org.example'
version = '1.0-SNAPSHOT'

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'com.grapecity.documents:gcexcel:6.2.0'
    implementation 'javax.json:javax.json-api:1.1.4'
    providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat'
    testImplementation('org.springframework.boot:spring-boot-starter-test')
}

test {
    useJUnitPlatform()
}

在dependencies 中,我們除了依賴springboot之外,還新增了GcExcel的依賴,後面匯出時會用到GcExcel,目前的版本是6.2.0。

2.新增SpringBootApplication

完成依賴的新增後,刪除原有的main.java,並新建立一個ExportServerApplication.java,然後新增以下程式碼。

@SpringBootApplication
@RestController
@RequestMapping("/api")
public class ExportServerApplication {

    public static void main(String[] args) {
        SpringApplication.run(ExportServerApplication.class, args);
    }    
}

3.新增 getListCount 和 commitData API

繼續在ExportServerApplication.java中新增一個ArraryList用來臨時儲存提交的資料,commitData把資料新增進ArraryList中,getListCount從ArraryList中獲取資料數量。

    private static ArrayList<CommitParameter> dataList = new ArrayList<>();

    @PostMapping("/commitData")
    public int commitData(@RequestBody CommitParameter par) {
        dataList.add(par);
        return dataList.size();
    }

    @PostMapping("/getListCount")
    public int getCount() {
        return dataList.size();
    }
4.新增匯出API

在React app中,我們使用selector允許選擇匯出的型別,selector提供了,Xlsx, CSV, PDF, HTML, PNG, 5種匯出格式。在匯出的API中,需要用GcExcel構建Excel檔案,把提交的資料填入到Excel的工作簿中。之後,根據前端傳遞的匯出型別來生成檔案,最後給前端返回,進行下載。

在GcExcel,可以直接通過workbook.save把工作簿儲存為Xlsx, CSV, PDF 以及HTML。但是在匯出HTML時,因為會匯出為多個檔案,因此我們需要對HTML和PNG進行特殊處理。

    @PostMapping("/exportDataList")
    public ResponseEntity<FileSystemResource> exportPDF(@RequestBody ExportParameter par) throws IOException {
        var workbook = new Workbook();
        copyDataToWorkbook(workbook);
        String responseFilePath = "";
        switch (par.exportType) {
            case Html -> {
                responseFilePath = exportToHtml(workbook);
            }
            case Png -> {
                responseFilePath = exportToImage(workbook);
            }
            default -> {
                responseFilePath = "download." + par.exportType.toString().toLowerCase();
                workbook.save(responseFilePath, Enum.valueOf(SaveFileFormat.class, par.exportType.toString()));
            }
        }

        FileSystemResource file = new FileSystemResource(responseFilePath);
        HttpHeaders headers = new HttpHeaders();
        headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + file.getFilename() + "\"");

        return ResponseEntity.ok()
                .headers(headers)
                .contentLength(file.contentLength())
                .body(file);
    }
    
    private static void copyDataToWorkbook(Workbook workbook) {
        Object[][] data = new Object[dataList.size() + 1][2];
        data[0][0] = "name";
        data[0][1] = "email";
        for (int i = 0; i < dataList.size(); i++) {
            data[i + 1][0] = dataList.get(i).name;
            data[i + 1][1] = dataList.get(i).email;
        }
        workbook.getActiveSheet().getRange("A1:B" + dataList.size() + 1).setValue((Object) data);
    }

對於HTML,可以直接通過FileOutputStream的方式,把HTML輸出成為zip。

    private String exportToHtml(Workbook workbook) {
        String outPutFileName = "SaveWorkbookToHtml.zip";
        FileOutputStream outputStream = null;
        try {
            outputStream = new FileOutputStream(outPutFileName);
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        }

        workbook.save(outputStream, SaveFileFormat.Html);

        try {
            outputStream.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return outPutFileName;
    }    

對於PNG型別,GcExcel可以匯出多種圖片格式,這裡通過ImageType.PNG來選擇匯出為PNG,以檔案流的方式匯出為圖片。

另外,我們需要單獨準備model的類,程式碼如下:

    private String exportToImage(Workbook workbook) {
        String outPutFileName = "ExportSheetToImage.png";
        FileOutputStream outputStream = null;
        try {
            outputStream = new FileOutputStream(outPutFileName);
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        }

        IWorksheet worksheet = workbook.getWorksheets().get(0);
        worksheet.toImage(outputStream, ImageType.PNG);

        try {
            outputStream.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return outPutFileName;
    }

CommitParameter.java:

package org.example;

public class CommitParameter {
    public String name;
    public String email;
}

ExportParameter.java:

package org.example;

public class ExportParameter {
    public ExportType exportType;
}

ExportType.java:

package org.example;

public enum ExportType {
    Xlsx,
    Csv,
    Pdf,
    Html,
    Png;
}

至此我們就完成了伺服器端的程式碼。

最終效果

通過表單新增一些資料,同時匯出不同型別的檔案。

開啟這些檔案,看看匯出的資料是否正確。

Excel

PDF

CSV

HTML

PNG

寫在最後

除了上述的匯出功能外,GcExcel還可以實現其他功能,如迷你圖資料透視表自定義函數等,歡迎大家存取:https://demo.grapecity.com.cn/documents-api-excel-java/demos/


擴充套件連結:

Spring Boot框架下實現Excel伺服器端匯入匯出

專案實戰:線上報價採購系統(React +SpreadJS+Echarts)

Svelte 框架結合 SpreadJS 實現純前端類 Excel 線上報表設計