中后台导出数据的需求场景目前已经是非常常见的,所以在个人遇到这个问题的时候,尝试前端解决并且使用的一些方案。
目前总结了下,大致两个方案:要么导出 excel
,要么导出 csv
,具体在哪 node
层还是浏览器可以看需求以及性能来调整。
方案一:node
层将 json
数据转换为流,前端直接下载
这个前提是有 node
作为中间层,可以把一些数据在这一层适配,所以一些数据的映射、代码的复用也都可以在这一层,在这一层将 json
转为流 excel
。
思路就是前端发起一个请求,将请求参数给 node
,node
利用 axios
发起多个请求,请求的就是导出数据的接口,但是接口返回的是 json
数据,所以在用 Promise.all
请求完成数据之后,在服务端利用 xlsx
的包将 json
数据转为流,前端利用 blob
来处理下载。
这里需要注意下:
xlsx
转为表格的时候数据的格式应该是 [['标题', '生日', '年龄'],['title', 'birthday', 'age'],['title', 'birthday', 'age']...]
,这个数组的第一项就是 excel
的表格表头,剩下的项目是请求回来 json
数据的每一个需要导出的项。
我们伪代码来演示一下:
node
层
- 主逻辑,因为我们用的是
thinkjs
,所以按照 think
的方式处理参数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
| const XLSX = require("xlsx"); async function downloadExcelAction() { const EXCEL_TITLE = ["标题", "生日", "年龄"];
const data = [ { title: "第一个", birthday: "1999/01/03", age: "23", }, { title: "第二个", birthday: "2000/12/03", age: "20", }, ];
let xlsxData = []; data.forEach((item, index) => { const d = [element.title, element.birthday, element.age];
xlsxData[index] = d; }); xlsxData = [EXCEL_TITLE, ...xlsxData];
const sheet = XLSX.utils.aoa_to_sheet(xlsxData);
const ctx = this.ctx;
const result = sheetToBuffer(sheet);
const mimeType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; ctx.set("Content-Type", mimeType); ctx.body = result; ctx.status = 200; }
|
可以看到,这里我们模拟的数据使用的是一个已经定义好的数组 data
,但是现实情况肯定是需要向服务端发请求获取数据,所以这里可以利用 Promise.all
处理。假设列表页中需要导出数据 10000
条,每次请求 500
条,需要请求 20
次,每次分页偏移量增加 1
,所以具体的实现伪代码:
baseAxiosRequest
最基本的单元接口请求数据的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| const axios = require("axios");
function baseAxiosRequest(serviceUrl, params) { return new Promise((resolve, reject) => { return axios .get(serviceUrl, { params, }) .then((result) => { const { desc, errorno, data } = result; if (errorno === 0) { return resolve(data); } else { return reject(desc); } }) .catch((err) => { return reject(err); }); }); }
|
sheetToBuffer
将数据转为 buffer
流数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| function sheetToBuffer(sheetData, sheetName) { sheetName = sheetName || "sheet1"; let workbook = { SheetNames: [sheetName], Sheets: {}, }; workbook.Sheets[sheetName] = sheetData;
let wopts = { bookType: "xlsx", type: "buffer", }; return XLSX.write(workbook, wopts); }
|
1 2 3
| function flatten(arr) { return arr.reduce((pre, cur) => [...pre, ...(Array.isArray(cur) ? this.flatten(cur) : [cur])], []) }
|
因为要使用 Promise.all
所以需要一个 map
方法将所有的请求都转为其可以接受的参数形式,如下 mapRequestList
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| function mapRequestList(serviceUrl, params) { const pageSize = 500; const pageNumber = 20; let requestArr = []; for (let i = 0; i < pageNumber; i++) { requestArr[i] = i + 1; } const att = requestArr.map((pageNumber) => { const opts = { ...params, pageSize, pageNumber }; console.log("发起第" + pageNumber + "个请求"); return baseAxiosRequest(serviceUrl, opts); }); return att; }
|
服务端的功能基本上算是完成了,我们来看一下客户端怎么用。
web
请求
这里因为我们用的 umi-request,axios
稍微有一点不一样,下文会有说明
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| import request from "umi-request"; import axios from "axios"; export default function handleExcelExport(data, fileName) { const url = `/api/app/toolName/downloadExcel/downloadExcel?serviceUrl=http://yourExportListHost.com/list`;
request(url, { method: "POST", data, timeout: 300000, responseType: "blob", }) .then((response) => { const blob = new Blob([response]); const elink = document.createElement("a"); elink.download = `${fileName}.xlsx`; elink.style.display = "none";
elink.href = URL.createObjectURL(blob); document.body.appendChild(elink); elink.click(); URL.revokeObjectURL(elink.href); document.body.removeChild(elink); }) .catch((err) => { console.log("err", err); });
axios({ method: "post", url, data, responseType: "blob", }).then((response) => { const { data } = response; const blob = new Blob([data]); }); }
|
在我导出的时候发现几个问题:
- 第一:接口响应时间
比较慢
。单次查询可能会到 4-7s
,这是因为接口本身问题,数据体量太大,查询耗时长。
- 第二:数据在
node
层将 json
转为流的时候可能会比较耗算力和内存,我们现在所有的前端都用同一个 node
服务,一旦这里单线程耗时比较久的话,可能会因为一系列问题影响到后续的其他工具的接口转发,影响到其他工具的稳定。
- 第三:超时严重,基本无法在超时之前成功导出
10000
条数据。
后来在权衡利弊之后,因为数据只是用来看,不会计算以及别的操作,采用在客户端也就是浏览器中导出 csv
格式的数据,利用 excel
打开是可以满足的,所以就产生了第二中方案。
方案二:纯前端方案,浏览器分片请求接口,将数据组装为 csv
导出
这里我们还是采用方案一中的假数据作为演示
exportJsonToCSV
文件,主体导出方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75
| let percentArr = []; const pageSize = 250; const maxReqCount = 40;
export const exportCsv = (total, queryParams, filename, callback) => { return new Promise((resolve, reject) => { if (!total) throw "无数据";
const maxLen = Math.ceil(total / pageSize);
const pageNumber = maxLen >= maxReqCount ? maxReqCount : maxLen;
let cvsArray = [];
const task = new Promise(async (resolve, reject) => { try { const data = await Promise.all( requestMapList(queryParams, pageNumber, callback) );
data?.flat(2)?.forEach((row, index) => { const newRow = [element.title, element.birthday, element.age];
cvsArray[index] = newRow.join() + "\n"; });
cvsArray.unshift(EXCEL_TITLE.join() + "\n");
await new Promise((_resolve) => { setTimeout(() => _resolve(true), 50); });
resolve(true); } catch (error) { reject(false); } });
task .then((res) => { if (res) { const blob = new Blob([String.fromCharCode(0xfeff), ...cvsArray], { type: "text/plain;charset=utf-8", });
createATagTodownload(blob, filename); percentArr = []; resolve(true); } else { reject(false); } }) .catch((err) => { reject(false); }); }); };
|
baseRequestData
基础单元请求接口的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| function baseRequestData(params, index, pageNumber, callback) { return new Promise((resolve, reject) => { return request(SERVICEURL, { params }) .then((result) => { const { desc, errorno, data } = result; if (errorno === 0) { percentArr[index] = index; const percentLen = percentArr.length - 1;
const percent = Math.ceil((percentLen / pageNumber) * 100);
callback && callback(percent); return resolve(data); } else { return reject(desc); } }) .catch((err) => { return reject(err); }); }); }
|
requestMapList
组装请求为 Promise
数组
1 2 3 4 5 6 7 8 9 10 11
| function requestMapList(params, pageNumber, callback) { const requestMap = new Array(pageNumber) .fill("") .map((item, idx) => idx + 1) .map((pn, index) => { const opts = { ...params, pageSize, pageNumber: pn }; return baseRequestData(opts, index + 1, pageNumber, callback); }); return requestMap; }
|
createATagTodownload
创建 a
标签利用 blob
来导出
1 2 3 4 5 6 7 8 9 10 11 12
| function createATagTodownload(blob, filename) { return new Promise((resolve, reject) => { const elink = document.createElement("a"); elink.download = `${filename || "文件导出"}.csv`; elink.style.display = "none"; elink.href = URL.createObjectURL(blob); document.body.appendChild(elink); elink.click(); URL.revokeObjectURL(elink.href); document.body.removeChild(elink); }); }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| const [loadingPercent, setLoadingPercent] = useState(0); const [loadingMask, setLoadingMask] = useState(false);
const handleExportFile = () => { setLoadingMask(true); exportCsv(pagination?.total, queryParams, filename, (percent) => { setLoadingPercent(percent); }) .then((res) => { if (res) { setLoadingMask(false); setLoadingPercent(0); } }) .catch((err) => { console.log("err", err); }); };
<Modal title="数据导出(默认1w条)进度" visible={loadingMask} width={500} maskClosable={false} keyboard={false} destroyOnClose={true} footer={null} closable={false} > <div style={{ display: "flex", justifyContent: "center" }}> <Progress type="circle" percent={loadingPercent} /> </div> </Modal>;
|
导出 csv
的格式不会出现超时的问题,10000
条数基本会在 40s
左右,这其中有一大部分原因是因为接口响应慢,再就是因为同一域名,在浏览器发起请求的时候 chrome
会限制 6
条,所以方案中采用的导出采用请求 40
次,后续的请求肯定是会被挂起等待新的可用连接,比较耗时,具体的就是浏览器 network
中的 Timing
的 Connection Start
的 Stalled
时间越往后越长。
所以方案二的话目前是比较可行的一种解决问题的套路。
对于 Stalled
时间问题,请求静态资源,可以通过域名分片来拆做到多请求,但是对于接口方面的这种情况目前还不知道怎么解决。
一些关于 stalled
时间过长的文章