前言
在B/S架構(gòu)中,服務(wù)端導出是一種高效的方式。它將導出的邏輯放在服務(wù)端,前端僅需發(fā)起請求即可。通過在服務(wù)端完成導出后,前端再下載文件完成整個導出過程。服務(wù)端導出具有許多優(yōu)點,如數(shù)據(jù)安全、適用于大規(guī)模數(shù)據(jù)場景以及不受前端性能影響等。
本文將使用前端框架React和服務(wù)端框架Spring Boot搭建一個演示的Demo,展示如何在服務(wù)端導出Excel和PDF文件。當然,對于前端框架,如Vue、Angular等也可以采用類似的原理來實現(xiàn)相同的功能。
在服務(wù)端導出過程中,需要依賴額外的組件來處理Excel和PDF文件。對于Excel相關(guān)操作,可以選擇POI庫,而對于PDF文件,可以選擇IText庫。為了方便起見,本方案選擇了GcExcel,它原生支持Excel、PDF、HTML和圖片等多種格式的導出功能。這樣一來,在實現(xiàn)導出功能的同時,也提供了更多的靈活性和互操作性。
實踐
本文將演示如何創(chuàng)建一個簡單的表單,其中包括姓名和電子郵箱字段,這些字段將作為導出數(shù)據(jù)。同時,前端將提供一個下拉選擇器和一個導出按鈕,通過下拉選擇器選擇導出的格式,然后點擊導出按鈕發(fā)送請求。等待服務(wù)端處理完成后,前端將下載導出的文件。
在服務(wù)端,我們需要實現(xiàn)相應(yīng)的API來處理提交數(shù)據(jù)的請求和導出請求。我們可以定義一個對象,在內(nèi)存中保存提交的數(shù)據(jù)。然后利用GcExcel庫構(gòu)建Excel對象,并將數(shù)據(jù)導出為不同的格式。
前端 React
1.創(chuàng)建React工程
新建一個文件夾,如ExportSolution,進入文件夾,在資源管理器的地址欄里輸入cmd,然后回車,打開命令行窗口。
使用下面的代碼創(chuàng)建名為client-app的react app。
npx create-react-app client-app
進入創(chuàng)建的client-app文件夾,使用IDE,比如VisualStudio Code打開它。
2.設(shè)置表單部分
更新Src/App.js的代碼,創(chuàng)建React app時,腳手架會創(chuàng)建示例代碼,需要刪除它們。如下圖(紅色部分刪除,綠色部分添加)。
在Src目錄下,添加一個名為FormComponent.js的文件,在App.js中添加引用。
在FormComponent.js中添加如下代碼。其中定義了三個state, formData和exportType,count用來存儲頁面上的值。與服務(wù)端交互的方法,僅做了定義。
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;
}
試著運行起來,效果應(yīng)該如下圖:
3.Axios請求及文件下載
前端與服務(wù)端交互,一共有三種請求:
- 頁面加載時,獲取服務(wù)端有多少次數(shù)據(jù)已經(jīng)被提交
- 提交數(shù)據(jù),并且獲取一共有多少次數(shù)據(jù)已經(jīng)被提交
- 發(fā)送導出請求,并根據(jù)結(jié)果下載文件。
通過npm添加兩個依賴,Axios用于發(fā)送請求,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等待返回結(jié)果。三個請求,會分別向已定義的api發(fā)送請求,其中fetchCount,僅會在頁面第一次完成加載時執(zhí)行。其他兩個請求方法會在點擊按鈕時觸發(fā)。
4.配置請求轉(zhuǎn)發(fā)中間件
因為React的程序會默認使用3000端口號,而Springboot默認使用8080端口。如果在Axios直接向服務(wù)端發(fā)送請求時(比如:localhost:8080/api/getListCount ),會出現(xiàn)跨域的問題。因此需要添加一個中間件來轉(zhuǎn)發(fā)請求,避免前端跨域訪問的問題。
在src文件夾下面添加文件,名為setupProxy.js,代碼如下:
const { createProxyMiddleware } = require('http-proxy-middleware');
module.exports = function(app) {
app.use(
'/api',
createProxyMiddleware({
target: 'http://localhost:8080',
changeOrigin: true,
})
);
};
OK,至此前端代碼基本完成,但還暫時不能運行測試,因為服務(wù)端代碼沒有完成。
服務(wù)端 Springboot
1.創(chuàng)建Springboot工程
使用IDEA創(chuàng)建一個Springboot工程,如果使用的是社區(qū)(community)版本,不能直接創(chuàng)建Springboot項目,那可以先創(chuàng)建一個空項目,idea創(chuàng)建project的過程,就跳過了,這里我們以創(chuàng)建了一個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,并新創(chuàng)建一個ExportServerApplication.java,然后添加以下代碼。
@SpringBootApplication
@RestController
@RequestMapping("/api")
public class ExportServerApplication {
public static void main(String[] args) {
SpringApplication.run(ExportServerApplication.class, args);
}
}
3.添加 getListCount 和 commitData API
繼續(xù)在ExportServerApplication.java中添加一個ArraryList用來臨時存儲提交的數(shù)據(jù),commitData把數(shù)據(jù)添加進ArraryList中,getListCount從ArraryList中獲取數(shù)據(jù)數(shù)量。
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構(gòu)建Excel文件,把提交的數(shù)據(jù)填入到Excel的工作簿中。之后,根據(jù)前端傳遞的導出類型來生成文件,最后給前端返回,進行下載。
在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;
}
至此我們就完成了服務(wù)端的代碼。
最終效果
通過表單添加一些數(shù)據(jù),同時導出不同類型的文件。
打開這些文件,看看導出的數(shù)據(jù)是否正確。
Excel
CSV
HTML
PNG
寫在最后
除了上述的導出功能外,GcExcel還可以實現(xiàn)其他功能,如迷你圖,數(shù)據(jù)透視表、自定義函數(shù)等,歡迎大家訪問:https://demo.grapecity.com.cn/documents-api-excel-java/demos/
擴展鏈接:
Spring Boot框架下實現(xiàn)Excel服務(wù)端導入導出
項目實戰(zhàn):在線報價采購系統(tǒng)(React +SpreadJS+Echarts)
Svelte 框架結(jié)合 SpreadJS 實現(xiàn)純前端類 Excel 在線報表設(shè)計