前陣子居家辦公接到一個需求前端匯出Excel
,我興致勃勃的的和產品說這個東西沒什麼問題,接著產品就詳細的把他的需求一字不差的和我講了遍,當時我想收回我剛才說過的話。
大致需求如下:
頁面中需要根據不同的查詢條件展示不同的表頭資訊和表格資料,匯出Excel
的時候也需要根據當前表頭資訊匯出資料。表頭可能是一級,可能是二級,可能是...N級。我當時想拿大刀砍產品。
我和後端商量了一下這個東西可不可以他們做,為了效能最後決定前後端都得做(誰也跑不了...),資料量大的時候後端匯出,資料量小的時候前端匯出。
背景故事大概就是這樣,因為筆者原來做過Excel
匯出,但是也只是簡單應用而是,涉及到二級表頭都很少,更別說N級了。為了能夠實現這個需求我重新看了一下原來使用的框架以及其他方向的調研,於是經過筆者不懈的努力,忙碌了兩個小時的筆者,最後覺得開啟淘寶...錯了,重來。最終實現了兩版,出現的效果是一樣的,但是兩者仍存在差別。
匯出Excel多級表頭 1.0
最開始調研的時候使用的是xlsx
這個工具包,因為這個包相對來說比較是熟悉,上手比較快。當時在最開始要優先考慮。
安裝依賴:
npm install xlsx -S
or
yarn add xlsx -S
建立檔案DownExcel.js
在檔案中引入xlsx
,廢話不多說建立一個class
,程式設計前期思考是這樣的,在class
初始化的時候,需要接收一個tableHead
即需要匯出資料的表頭,下載檔案的時候需要呼叫down
方法,在down
方法中需要接收data
和需要匯出的檔名。
class DownExcel {
constructor({ header = [] }) {
this.tableHeader = header;
}
down(fileName, tableData = []){
}
}
最棘手其實不是表格資料的填寫,而是如何讓匯出的Excel
支援N
級動態表頭。由於xlsx
框架在匯出excel
的時候支援Csv
形式的,最後將Csv
轉換成Sheet
之後再匯出檔案,那麼也就是說我需要在整理Csv
資料之前,就應該知道了表頭的合併資訊。但是xlsx
框架的合併資訊是單獨儲存的,大概內容如下:
const marge = [{
s: { r: 0, c: 1 },
e: { r: 0, c: 2 }
}];
上述內容中,s
代表開始的節點位置,e
代表的是結束的節點位置,r
代表的是行,c
代表的是列。通過分析資料可以看出。分別確認了兩個座標點,依照兩個座標點進行單元格合併。那麼如果想要支援多級表頭資料那麼就需要支援marge
資訊是什麼,如果可以根據表頭資訊來生成豈不就行了嗎?
頁面表格中是存有表頭資訊的,匯出Excel
需要根據頁面表頭來生成一樣的。表頭資料資訊大概是這個樣子的:
const tableHeader = [{
field: "a111",
title: "a1"
},{
field: "a333",
title: "a3",
children: [{
field: "b111",
title: "b1",
},{
field: "b222",
title: "b2",
children: [{
field: "c1",
title: "c111",
children: [{
file: "d444",
title: "d4"
}]
}]
}]
}];
以上就是表頭的資料為巢狀資料格式,只要是下級存在children
那麼該級就需要進行合併,如果沒有的話就需要合併到表頭的底部。
建立方法resetMergeHeaderInfo
class DownExcel {
down(fileName, tableData = []){
this.resetMergeHeaderInfo();
}
// 表頭資料 tableHeader
// 表頭深度 maxLevel
// 合併表頭臨時儲存資訊 outMarge
// 最終結果 result
resetMergeHeaderInfo(tableHeader, maxLevel, outMarge, result = []){
}
}
在方法中其中有一個引數是maxLevel
,這個引數是當前表頭資訊的最大深度?也就是當前表頭資訊一共巢狀了多少層。有了這個引數,就能知道最外層的資料,如果沒有children
的時候單元格的合併範圍。那麼也就需要確認哪些是最外層,哪些是內層的,哪些是有children
,哪些是沒有children
。大致上在表頭中出現的情況也就這幾種了。
首先確認外層資料,如果想實現巢狀資料,比不少會用到遞迴,需要對外層書進行標記。
class DownExcel {
// 表頭資料 tableHeader
// 表頭深度 maxLevel
// 合併表頭臨時儲存資訊 outMarge
// 最終結果 result
resetMergeHeaderInfo(tableHeader, maxLevel, outMarge, result = []){
this.tagHeadIn();
}
// 標記最外層資料
tagHeadIn(){
const { tableHeader } = this;
tableHeader.forEach((el) => {
el.isOut = true;
return el;
})
}
}
先處理外層資料,剛才也說過了,需要知道最大的巢狀層級是多大,所以這裡需要方法獲取到所需要的這個引數。
class DownExcel {
down(){
const { tableHeader } = this;
let maxLevel = this.maxLevel(tableHeader);
this.resetMergeHeaderInfo(tableHeader,maxLevel);
}
resetMergeHeaderInfo(tableHeader, maxLevel, outMarge, result = []){
this.tagHeadIn();
}
tagMaxLevel(tableHeader){
const maxLevel = this.maxLevel(tableHeader, false);
tableHeader.forEach((el) => {
if(!el.children){
el.maxLen = maxLevel;
}
else{
this.tagMaxLevel(el.children);
el.maxLen = maxLevel;
}
});
}
}
在標記層級的的時候,在每一層都標註了,他下面的最大層級,這樣就不需要每次都需要遍歷去去獲取最大深度。以為子級也是有巢狀,如果沒有巢狀越需要知道當前單元格向下合併幾個,否則會導致合併不能統一。
接下來就是需要處理最外層的表格資訊了:
class DownExcel {
resetMergeHeaderInfo(tableHeader, maxLevel, outMarge, result = []){
this.tagHeadIn();
for(let i = 0; i < tableHeader.length; i++){
let item = tableHeader[i];
// 縱向跨度
const { maxLen } = item;
// 開始節點資訊
let s = {};
// 結束節點資訊
let e = {};
// 如果沒有子級
if(!item.children){
// 如果是最外層元素
if(item.isOut){
// 當前列開始位置
outMarge.startCell += 1;
// 全域性列開始位置
outMarge.basisCell += 1;
// 開始行
s.r = 0;
// 結束行
e.r = maxLevel;
// 開始列
s.c = outMarge.startCell;
// 結束列
e.c = outMarge.startCell;
result.push({ s, e, item });
}else{ // 不是外層元素
}
}
// 如果有子級
if(item.children){
// 如果是最外層元素
if(item.isOut){
}else{ // 不是外層元素
}
}
}
}
}
根據已知的資訊對外層沒有子級的單元格資訊已經獲取到了,為什麼要記錄兩個列開始資訊?如果A
單元格下面有兩個子級,一個沒有children
(A1)另一個有children
(A2)確定完資訊之後,開始列就會發生變化自增1
,A2
渲染的時候子級有多少個需要在全域性記錄的行資訊上加上,這樣進入到B
單元格迴圈的時候才會從對應的地方開始進行下一次合併資訊記錄。
當有子級的時候就會有一個問題,那麼就是當前單元格的橫向合併多少個單元格?那麼就可以根據當前單元格的children
所有沒有children
的子級,就是當前單元格的橫向跨度。
class DownExcel {
// 獲取當前下面所有子級
// 即:表頭橫向跨度單元格數量
getLastChild (arr, result = []){
for(let i = 0,item; item = arr[i++];){
if(!item.children){
result.push(item);
}else{
result = this.getLastChild(item.children, result);
}
}
return result;
}
}
知道了橫向跨度之後可以處理外部有子級的單元格合併資訊的收集:
class DownExcel {
resetMergeHeaderInfo(tableHeader, maxLevel, outMarge, result = []){
this.tagHeadIn();
for(let i = 0; i < tableHeader.length; i++){
let item = tableHeader[i];
// 縱向跨度
const { maxLen } = item;
// 橫向跨度
let lastChild = this.getLastChild(item.children || []);
// 開始節點資訊
let s = {};
// 結束節點資訊
let e = {};
// 如果沒有子級
if(!item.children){
// 如果是最外層元素
if(item.isOut){
// .....
}else{ // 不是外層元素
}
}
// 如果有子級
if(item.children){
// 如果是最外層元素
if(item.isOut){
// 開始行
s.r = 0;
// 結束行
e.r = 0;
// 區域性開始列自增
outMarge.startCell += 1;
// 開始列
s.c = outMarge.startCell;
// 開始列加上橫向跨度
outMarge.startCell += lastChild.length - 1;
// 結束列
e.c = outMarge.startCell;
result.push({ s, e, item });
}else{ // 不是外層元素
}
}
}
}
}
因為橫向開始位置是需要記錄的,所以要加上當前的橫向跨度,避免下次迴圈的開始位置出現錯誤。
class DownExcel {
resetMergeHeaderInfo(tableHeader, maxLevel, outMarge, result = []){
this.tagHeadIn();
for(let i = 0; i < tableHeader.length; i++){
let item = tableHeader[i];
// 縱向跨度
const { maxLen } = item;
// 橫向跨度
let lastChild = this.getLastChild(item.children || []);
// 開始節點資訊
let s = {};
// 結束節點資訊
let e = {};
// 如果沒有子級
if(!item.children){
// 如果是最外層元素
if(item.isOut){
// .....
}else{ // 不是外層元素
// 開始行
let r = maxLevel - (outMarge.basisRow + maxLen);
r = Math.max(r, 0);
s.c = outMarge.basisCell;
e.c = outMarge.basisCell;
s.r = outMarge.basisRow;
e.r = r + outMarge.basisRow + maxLen;
result.push({ s, e, item });
// 開始行資料 + 1
outMarge.basisCell += 1;
}
}
// 如果有子級
if(item.children){
// 如果是最外層元素
if(item.isOut){
// ...
}else{ // 不是外層元素
}
}
}
}
}
處理完外部資料就可以處理內部了,處理內部資料其實和處理外部資料是差不多的。在處理子級的時候,如果單元格下面兩個如果A
單元格下面有兩個子級,一個沒有children
(A1)另一個有children
(A2),這個時候A1
需要向下合併到最底部,用最大深度 - (開始位置 + 合併高度)得到差值,即當前單元個的結束位置。
class DownExcel {
resetMergeHeaderInfo(tableHeader, maxLevel, outMarge, result = []){
this.tagHeadIn();
for(let i = 0; i < tableHeader.length; i++){
let item = tableHeader[i];
// 縱向跨度
const { maxLen } = item;
// 橫向跨度
let lastChild = this.getLastChild(item.children || []);
// 開始節點資訊
let s = {};
// 結束節點資訊
let e = {};
// 如果沒有子級
if(!item.children){
// 如果是最外層元素
if(item.isOut){
// .....
}else{ // 不是外層元素
// .....
}
}
// 如果有子級
if(item.children){
// 如果是最外層元素
if(item.isOut){
// ...
}else{ // 不是外層元素
s.c = outMarge.basisCell;
e.c = outMarge.basisCell + lastChild.length - 1;
s.r = outMarge.basisRow;
e.r = outMarge.basisRow;
result.push({ s, e, item });
}
}
}
}
}
這裡處理邏輯和外層有子級的處理邏輯是類似的。
class DownExcel {
resetMergeHeaderInfo(tableHeader, maxLevel, outMarge, result = []){
this.tagHeadIn();
for(let i = 0; i < tableHeader.length; i++){
let item = tableHeader[i];
// 縱向跨度
const { maxLen } = item;
// 橫向跨度
let lastChild = this.getLastChild(item.children || []);
// 開始節點資訊
let s = {};
// 結束節點資訊
let e = {};
// 如果沒有子級
if(!item.children){
// 如果是最外層元素
if(item.isOut){
// .....
}else{ // 不是外層元素
// .....
}
}
// 如果有子級
if(item.children){
// 如果是最外層元素
if(item.isOut){
// ...
}else{ // 不是外層元素
// ....
}
}
outMarge.basisRow += 1;
this.resetMergeHeaderInfo(item.children, maxLevel, outMarge, result);
}
outMarge.basisRow -= 1;
return result;
}
}
處理完收集資訊,需要把所有子級的資訊收集一下,需要把本地的列數自增一下,保證所和合並的時候合併座標值是隨著遍歷和遞迴是同步進行的。每次遞迴完需要減一,也就是遞迴一次需要加一次。
這樣針對合併資訊收集就完成了,得到的資料和xlsx
框架所需要的是一致的。
class DownExcel {
down(fileName, tableData = []){
const { tableHeader, outMarge } = this;
let maxLevel = this.maxLevel(tableHeader);
const mergeInfo = this.resetMergeHeaderInfo(tableHeader, maxLevel, outMarge);
const lastChild = this.getLastChild(tableHeader);
const headCsv = this.getHeadCsv(tableHeader, lastChild, maxLevel, mergeInfo);
const dataCsv = this.getDataCsv(tableData, lastChild);
const allCsv = this.margeCsv(headCsv, dataCsv);
}
// 將資料轉換成Csv格式
getHeadCsv(tableHeader, lastChild, maxLevel, mergeInfo){
let csvArr = [];
let csv = "";
for(let i = 0; i < (maxLevel + 1); i++){
let item = [];
for(let j = 0; j < lastChild.length; j++){
item.push(null);
}
csvArr.push(item);
}
for(let i = 0; i < mergeInfo.length; i++){
let info = mergeInfo[i];
const { s, item } = info;
const { c, r } = s;
const { title } = item;
csvArr[r][c] = title;
console.log(mergeInfo);
}
csvArr = csvArr.map((el) => {
return el.join("^");
});
return csvArr.join("~");
}
// 獲取data的Csv
getDataCsv(data, lastChild){
let result = [];
for(let j = 0, ele; ele = data[j++];){
let value = [];
for(let i = 0, item; item = lastChild[i++];){
value.push(ele[item.field] || "-");
};
result.push(value);
}
result = result.map((el) => {
return el.join("^");
});
return result.join("~");
}
// 合併Csv
margeCsv(headCsv, dataCsv){
return `${headCsv}~${dataCsv}`;
}
}
合併資訊處理完了之後需要把當前的表頭資料和列表資料處理成csv
格式,方便匯出excel,首先考慮的是表頭合併資訊部分資料需要用空資料代替,不能把所有的資料直接填寫進去,否則在匯出的時候會發生資料位置錯亂的問題。
class DownExcel {
down(fileName, tableData = []){
const { tableHeader, outMarge } = this;
let maxLevel = this.maxLevel(tableHeader);
const mergeInfo = this.resetMergeHeaderInfo(tableHeader, maxLevel, outMarge);
const lastChild = this.getLastChild(tableHeader);
const headCsv = this.getHeadCsv(tableHeader, lastChild, maxLevel, mergeInfo);
const dataCsv = this.getDataCsv(tableData, lastChild);
const allCsv = this.margeCsv(headCsv, dataCsv);
const cscSeet = this.csv2sheet(allCsv);
console.log(cscSeet);
let blob = this.sheet2blob(cscSeet);
this.openDownloadDialog(blob,`${fileName}.xlsx`);
}
// 將 csv轉換成sheet資料
csv2sheet(csv) {
csv = csv.split('~');
// 快取
let arr = [];
// 剪下未陣列
csv.forEach((el) => {
// 剪下資料並新增答題arr
arr.push(el.split("^"));
});
// 呼叫方法
return XLSX.utils.aoa_to_sheet(arr);
}
// sheet轉blob檔案
sheet2blob(sheet, sheetName) {
// 匯出檔案型別
sheetName = sheetName || 'sheet1';
var workbook = {
SheetNames: [sheetName],
Sheets: {}
};
workbook.Sheets[sheetName] = sheet;
var wopts = {
bookType: "xlsx",
bookSST: false,
type: 'binary'
};
var wbout = XLSX.write(workbook, wopts);
var blob = new Blob([s2ab(wbout)], {type:"application/octet-stream"});
// 字串轉ArrayBuffer
function s2ab(s) {
var buf = new ArrayBuffer(s.length);
var view = new Uint8Array(buf);
for (var i=0; i!=s.length; ++i) view[i] = s.charCodeAt(i) & 0xFF;
return buf;
}
return blob;
}
// 匯出excel
openDownloadDialog(url, saveName){
if(typeof url == 'object' && url instanceof Blob){
url = URL.createObjectURL(url); // 建立blob地址
}
var aLink = document.createElement('a');
aLink.href = url;
aLink.download = saveName || '';
var event;
if(window.MouseEvent) event = new MouseEvent('click');
else
{
event = document.createEvent('MouseEvents');
event.initMouseEvent('click', true, false, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null);
}
aLink.dispatchEvent(event);
}
}
將Csv
轉換成sheet
資料,並把合併資訊賦值給sheet
就可以就可以通過呼叫框架的方法轉換成blob
物件完成最後的資料匯出。
完整程式碼:
import * as XLSX from "xlsx";
export default class DownExcel {
constructor({ header = [] }) {
this.tableHeader = header;
this.outMarge = {
startCell: -1,
basisRow: 0,
basisCell: 0,
maxRow: 0
};
}
down(fileName, tableData = []){
const { tableHeader, outMarge } = this;
let maxLevel = this.maxLevel(tableHeader);
const mergeInfo = this.resetMergeHeaderInfo(tableHeader, maxLevel, outMarge);
const lastChild = this.getLastChild(tableHeader);
const headCsv = this.getHeadCsv(tableHeader, lastChild, maxLevel, mergeInfo);
const dataCsv = this.getDataCsv(tableData, lastChild);
const allCsv = this.margeCsv(headCsv, dataCsv);
const cscSeet = this.csv2sheet(allCsv);
cscSeet['!merges'] = mergeInfo;
console.log(cscSeet);
let blob = this.sheet2blob(cscSeet);
this.openDownloadDialog(blob,`${fileName}.xlsx`);
}
// sheet轉blob檔案
sheet2blob(sheet, sheetName) {
// 匯出檔案型別
sheetName = sheetName || 'sheet1';
var workbook = {
SheetNames: [sheetName],
Sheets: {}
};
workbook.Sheets[sheetName] = sheet;
var wopts = {
bookType: "xlsx",
bookSST: false,
type: 'binary'
};
var wbout = XLSX.write(workbook, wopts);
var blob = new Blob([s2ab(wbout)], {type:"application/octet-stream"});
// 字串轉ArrayBuffer
function s2ab(s) {
var buf = new ArrayBuffer(s.length);
var view = new Uint8Array(buf);
for (var i=0; i!=s.length; ++i) view[i] = s.charCodeAt(i) & 0xFF;
return buf;
}
return blob;
}
// 匯出Excel
openDownloadDialog(url, saveName){
if(typeof url == 'object' && url instanceof Blob){
url = URL.createObjectURL(url); // 建立blob地址
}
var aLink = document.createElement('a');
aLink.href = url;
aLink.download = saveName || '';
var event;
if(window.MouseEvent) event = new MouseEvent('click');
else
{
event = document.createEvent('MouseEvents');
event.initMouseEvent('click', true, false, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null);
}
aLink.dispatchEvent(event);
}
// 獲取data的Csv
getDataCsv(data, lastChild){
let result = [];
for(let j = 0, ele; ele = data[j++];){
let value = [];
for(let i = 0, item; item = lastChild[i++];){
value.push(ele[item.field] || "-");
};
result.push(value);
}
result = result.map((el) => {
return el.join("^");
});
return result.join("~");
}
// 將資料轉換成Csv格式
getHeadCsv(tableHeader, lastChild, maxLevel, mergeInfo){
let csvArr = [];
let csv = "";
for(let i = 0; i < (maxLevel + 1); i++){
let item = [];
for(let j = 0; j < lastChild.length; j++){
item.push(null);
}
csvArr.push(item);
}
for(let i = 0; i < mergeInfo.length; i++){
let info = mergeInfo[i];
const { s, item } = info;
const { c, r } = s;
const { title } = item;
csvArr[r][c] = title;
console.log(mergeInfo);
}
csvArr = csvArr.map((el) => {
return el.join("^");
});
return csvArr.join("~");
}
// 合併Csv
margeCsv(headCsv, dataCsv){
return `${headCsv}~${dataCsv}`;
}
// 將 csv轉換成sheet資料
csv2sheet(csv) {
csv = csv.split('~');
// 快取
let arr = [];
// 剪下未陣列
csv.forEach((el) => {
// 剪下資料並新增答題arr
arr.push(el.split("^"));
});
// 呼叫方法
return XLSX.utils.aoa_to_sheet(arr);
}
resetMergeHeaderInfo(tableHeader, maxLevel, outMarge, result = []){
this.tagHeadIn();
this.tagMaxLevel(tableHeader);
for(let i = 0; i < tableHeader.length; i++){
let item = tableHeader[i];
// 縱向跨度
const { maxLen } = item;
// 橫向跨度
let lastChild = this.getLastChild(item.children || []);
// s : 開始 e : 結束
// c : 列(橫向)
// r : 行(縱向)
let s = {};
let e = {};
if(!item.children){
if(item.isOut){
outMarge.startCell += 1;
outMarge.basisCell += 1;
s.r = 0;
e.r = maxLevel;
s.c = outMarge.startCell;
e.c = outMarge.startCell;
result.push({ s, e, item });
}
else{
let r = maxLevel - (outMarge.basisRow + maxLen);
r = Math.max(r, 0);
s.c = outMarge.basisCell;
e.c = outMarge.basisCell;
s.r = outMarge.basisRow;
e.r = r + outMarge.basisRow + maxLen;
result.push({ s, e, item });
outMarge.basisCell += 1;
}
};
if(item.children){
if(item.isOut){
s.r = 0;
e.r = 0;
outMarge.startCell += 1;
s.c = outMarge.startCell;
outMarge.startCell += lastChild.length - 1;
e.c = outMarge.startCell;
result.push({ s, e, item });
}else{
s.c = outMarge.basisCell;
e.c = outMarge.basisCell + lastChild.length - 1;
s.r = outMarge.basisRow;
e.r = outMarge.basisRow;
result.push({ s, e, item });
}
outMarge.basisRow += 1;
this.resetMergeHeaderInfo(item.children, maxLevel, outMarge, result);
};
};
outMarge.basisRow -= 1;
return result;
}
tagHeadIn(){
const { tableHeader } = this;
tableHeader.forEach((el) => {
el.isOut = true;
return el;
})
}
// 標記最大層級
tagMaxLevel(tableHeader){
const maxLevel = this.maxLevel(tableHeader, false);
tableHeader.forEach((el) => {
if(!el.children){
el.maxLen = maxLevel;
}
else{
this.tagMaxLevel(el.children);
el.maxLen = maxLevel;
}
});
}
// 獲取最大層級
// 只包含子級最大層級(不包含本級)
maxLevel(arr, isSetFloor = true){
let floor = -1;
let max = -1;
function each (data, floor) {
data.forEach(e => {
max = Math.max(floor, max);
isSetFloor && (e.floor = (floor + 1));
if (e.children) {
each(e.children, floor + 1)
}
})
}
each(arr,0)
return max;
}
// 獲取當前下面所有子級
// 即:表頭橫向跨度單元格數量
getLastChild (arr, result = []){
for(let i = 0,item; item = arr[i++];){
if(!item.children){
result.push(item);
}else{
result = this.getLastChild(item.children, result);
}
}
return result;
}
};
使用:
new DownExcel({
header: [{
field: "c1",
title: "c111",
children: [{
field: "c2",
title: "c222"
},{
field: "c3",
title: "c333"
},{
field: "c4",
title: "c444"
}]
}]
}).down(`${+new Date()}`,[{
a111: "LLL",
c1: "HHH",
c2: "AAA",
a444: "III"
}]);
感謝大家花費很長時間來閱讀這篇文章,若文章中有錯誤灰常感謝大家提出指正,我會盡快做出修改的。如果大家喜歡的話接下來會更新一下,匯出帶入樣式並且支援多級表頭。