前端復(fù)雜表格導(dǎo)出excel,一鍵導(dǎo)出 Antd Table 看這篇就夠了(附源碼)

前端導(dǎo)出 excel 的需求很多,但市面上好用的庫(kù)并不多,講明白復(fù)雜使用場(chǎng)景的文章更少。

本文將以文字 + demo 源碼的形式,力求講清楚滿足 99% 使用場(chǎng)景的終極 excel 導(dǎo)出方案。

如果項(xiàng)目中用到了 AntD,那就更簡(jiǎn)單了,因?yàn)?Table 本身已經(jīng)設(shè)置好了 column 和 dataSource,只需解析 column 和 dataSource 即可快速導(dǎo)出 Excel。

實(shí)現(xiàn)功能:

簡(jiǎn)單表格導(dǎo)出
為表格添加樣式(更改背景色、更換字體、字號(hào)、顏色)
設(shè)置行高、列寬
解析 ant-design 的 Table 直接導(dǎo)出excel,根據(jù) antd 頁面中設(shè)置的列寬動(dòng)態(tài)計(jì)算 excel 中的列寬
多級(jí)表頭(行合并、列合并)
一個(gè) sheet 中放多張表,并實(shí)現(xiàn)每張表的列寬不同
源碼地址:github.com/cachecats/e…[1]

第二篇文章:js 批量導(dǎo)出 excel 為zip壓縮包[2], 對(duì)導(dǎo)出方法進(jìn)行了封裝,還實(shí)現(xiàn)了使用 exceljs、file-saver、jszip實(shí)現(xiàn)下載包含多層級(jí)文件夾、多個(gè) excel、每個(gè) excel 支持多個(gè) sheet 的 zip 壓縮包。

一、技術(shù)選型
xlsx
呼聲最高的是 xlsx[3],又叫 SheetJS,也是下載量最高和 star最多的庫(kù)。試用了一下很強(qiáng)大,但是!默認(rèn)不支持改變樣式,想要支持改變樣式,需要使用它的收費(fèi)版本。

本著勤儉節(jié)約的原則,很多人使用了另一個(gè)第三方庫(kù):xlsx-style[4],但是使用起來極其復(fù)雜,還需要改 node_modules 源碼,這個(gè)庫(kù)最后更新時(shí)間也定格在了 6年前。還有一些其他的第三方樣式拓展庫(kù),質(zhì)量參差不齊。

使用成本和后期的維護(hù)成本很高,不得不放棄。

ExcelJS
ExcelJS[5] 周下載量 450k,github star 9k,并且擁有中文文檔,對(duì)國(guó)內(nèi)開發(fā)者很友好。雖然文檔是以README 的形式,可讀性不太好,但重在內(nèi)容,常用的功能基本都有覆蓋。

最近更新時(shí)間是6個(gè)月內(nèi),試用了一下,集成很簡(jiǎn)單,再加之文檔豐富,就選它了。

安裝:

npm install exceljs

下載到本地還需要另一個(gè)庫(kù):file-saver

npm install file-saver

二、基本概念
先了解下基本概念,更詳細(xì)的介紹參考官方文檔:github.com/exceljs/exc…[6]

workbook
workbook:工作簿,可以理解為整個(gè) excel 表格。

通過 const workbook = new ExcelJS.Workbook() 創(chuàng)建工作簿,還可以設(shè)置工作簿的屬性:

workbook.creator = 'Me';
workbook.lastModifiedBy = 'Her';
workbook.created = new Date(1985, 8, 30);
workbook.modified = new Date();
workbook.lastPrinted = new Date(2016, 9, 27);

worksheet
工作表,即 Excel 表格中的 sheet 頁。

通過 const sheet = workbook.addWorksheet('My Sheet')創(chuàng)建工作表,每個(gè) workbook 可添加多個(gè) worksheet。

使用 addWorksheet 函數(shù)的第二個(gè)參數(shù)來指定工作表的選項(xiàng)。

// 創(chuàng)建帶有紅色標(biāo)簽顏色的工作表
const sheet = workbook.addWorksheet('My Sheet', {properties:{tabColor:{argb:'FFC0000'}}});

// 創(chuàng)建一個(gè)隱藏了網(wǎng)格線的工作表
const sheet = workbook.addWorksheet('My Sheet', {views: [{showGridLines: false}]});

// 創(chuàng)建一個(gè)第一行和列凍結(jié)的工作表
const sheet = workbook.addWorksheet('My Sheet', {views:[{xSplit: 1, ySplit:1}]});

// 使用A4設(shè)置的頁面設(shè)置設(shè)置創(chuàng)建新工作表 - 橫向
const worksheet =  workbook.addWorksheet('My Sheet', {
  pageSetup:{paperSize: 9, orientation:'landscape'}
});

// 創(chuàng)建一個(gè)具有頁眉頁腳的工作表
const sheet = workbook.addWorksheet('My Sheet', {
  headerFooter:{firstHeader: "Hello Exceljs", firstFooter: "Hello World"}
});

// 創(chuàng)建一個(gè)凍結(jié)了第一行和第一列的工作表
const sheet = workbook.addWorksheet('My Sheet', {views:[{state: 'frozen', xSplit: 1, ySplit:1}]});

columns
列,通過 worksheet.columns可設(shè)置表頭。

// 添加列標(biāo)題并定義列鍵和寬度
// 注意:這些列結(jié)構(gòu)僅是構(gòu)建工作簿的方便之處,除了列寬之外,它們不會(huì)完全保留。
worksheet.columns = [
  { header: 'Id', key: 'id', width: 10 },
  { header: 'Name', key: 'name', width: 32 },
  { header: 'D.O.B.', key: 'DOB', width: 10, outlineLevel: 1 }
];

// 通過鍵,字母和基于1的列號(hào)訪問單個(gè)列
const idCol = worksheet.getColumn('id');
const nameCol = worksheet.getColumn('B');
const dobCol = worksheet.getColumn(3);

// 設(shè)置列屬性

// 注意:將覆蓋 C1 單元格值
dobCol.header = 'Date of Birth';

// 注意:這將覆蓋 C1:C2 單元格值
dobCol.header = ['Date of Birth', 'A.K.A. D.O.B.'];

// 從現(xiàn)在開始,此列將以 “dob” 而不是 “DOB” 建立索引
dobCol.key = 'dob';

dobCol.width = 15;

// 如果需要,隱藏列
dobCol.hidden = true;

還可對(duì)列進(jìn)行各種操作。

// 遍歷此列中的所有當(dāng)前單元格
dobCol.eachCell(function(cell, rowNumber) {
  // ...
});

// 遍歷此列中的所有當(dāng)前單元格,包括空單元格
dobCol.eachCell({ includeEmpty: true }, function(cell, rowNumber) {
  // ...
});

// 添加一列新值
worksheet.getColumn(6).values = [1,2,3,4,5];

// 添加稀疏列值
worksheet.getColumn(7).values = [,,2,3,,5,,7,,,,11];

// 剪切一列或多列(右邊的列向左移動(dòng))
// 如果定義了列屬性,則會(huì)相應(yīng)地對(duì)其進(jìn)行切割或移動(dòng)
// 已知問題:如果拼接導(dǎo)致任何合并的單元格移動(dòng),結(jié)果可能是不可預(yù)測(cè)的
worksheet.spliceColumns(3,2);

// 刪除一列,再插入兩列。
// 注意:第4列及以上的列將右移1列。
// 另外:如果工作表中的行數(shù)多于列插入項(xiàng)中的值,則行將仍然被插入,就好像值存在一樣。
const newCol3Values = [1,2,3,4,5];
const newCol4Values = ['one', 'two', 'three', 'four', 'five'];
worksheet.spliceColumns(3, 1, newCol3Values, newCol4Values);

row
行,可以添加一行或者同時(shí)添加多行數(shù)據(jù),是使用最頻繁的屬性。

// 通過 json 添加一行數(shù)據(jù),需要先設(shè)置 columns
worksheet.addRow({id: 1, name: 'John Doe', dob: new Date(1970,1,1)});
worksheet.addRow({id: 2, name: 'Jane Doe', dob: new Date(1965,1,7)});
// 通過數(shù)組添加一行數(shù)據(jù)
worksheet.addRow([3, 'Sam', new Date()]);

// 同時(shí)添加多行數(shù)據(jù)
worksheet.addRows(list);

// 遍歷工作表中具有值的所有行
worksheet.eachRow(function(row, rowNumber) {
  console.log('Row ' + rowNumber + ' = ' + JSON.stringify(row.values));
});

// 遍歷工作表中的所有行(包括空行)
worksheet.eachRow({ includeEmpty: true }, function(row, rowNumber) {
  console.log('Row ' + rowNumber + ' = ' + JSON.stringify(row.values));
});

// 連續(xù)遍歷所有非空單元格
row.eachCell(function(cell, colNumber) {
  console.log('Cell ' + colNumber + ' = ' + cell.value);
});

// 遍歷一行中的所有單元格(包括空單元格)
row.eachCell({ includeEmpty: true }, function(cell, colNumber) {
  console.log('Cell ' + colNumber + ' = ' + cell.value);
});

三、簡(jiǎn)單表格導(dǎo)出
本文所有示例都使用 React + AntD。

先看效果,我們用 AntD 的 Table 寫個(gè)簡(jiǎn)單的表格頁面,并設(shè)置不同的列寬:



點(diǎn)擊導(dǎo)出 excel,然后打開得到以下結(jié)果:



可以看到,導(dǎo)出的 excel 列寬比例跟在線的表格是一致的。

貼源碼:

// 簡(jiǎn)單 demo
import React, {useEffect, useState} from 'react'
import {Button, Card, Table} from "antd";
import {ColumnsType} from "antd/lib/table/interface";
import * as ExcelJs from 'exceljs';
import {generateHeaders, saveWorkbook} from "../utils";

interface SimpleDemoProps {
}

interface StudentInfo {
  id: number;
  name: string;
  age: number;
  gender: string;
}

const SimpleDemo: React.FC<SimpleDemoProps> = () => {

  const [list, setList] = useState<StudentInfo[]>([]);

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

  function generateData() {
    let arr: StudentInfo[] = [];
    for (let i = 0; i < 10; i++) {
      arr.push({
        id: i,
        name: `小明${i}號(hào)`,
        age: i,
        gender: i % 2 === 0 ? '男' : '女'
      })
    }
    setList(arr);
  }

  const columns: ColumnsType<any> = [
    {
      width: 50,
      dataIndex: 'id',
      key: 'id',
      title: 'ID',
    },
    {
      width: 100,
      dataIndex: 'name',
      key: 'name',
      title: '姓名',
    },
    {
      width: 50,
      dataIndex: 'age',
      key: 'age',
      title: '年齡',
    },
    {
      width: 80,
      dataIndex: 'gender',
      key: 'gender',
      title: '性別',
    },
  ];

  function onExportBasicExcel() {
    // 創(chuàng)建工作簿
    const workbook = new ExcelJs.Workbook();
    // 添加sheet
    const worksheet = workbook.addWorksheet('demo sheet');
    // 設(shè)置 sheet 的默認(rèn)行高
    worksheet.properties.defaultRowHeight = 20;
    // 設(shè)置列
    worksheet.columns = generateHeaders(columns);
    // 添加行
    worksheet.addRows(list);
    // 導(dǎo)出excel
    saveWorkbook(workbook, 'simple-demo.xlsx');
  }

  return (
    <Card>
      <h3>簡(jiǎn)單表格</h3>
      <Button type={'primary'} style={{marginBottom: 10}} onClick={onExportBasicExcel}>導(dǎo)出excel</Button>
      <Table
        columns={columns}
        dataSource={list}
      />
    </Card>
  );
}

export default SimpleDemo

真正導(dǎo)出的代碼只有幾行,重點(diǎn)看 onExportBasicExcel方法:

先創(chuàng)建工作簿和 sheet 頁,這兩行是固定代碼。如果需要多 sheet,則創(chuàng)建多個(gè) sheet 即可。后續(xù)對(duì)表格的所有操作,都是對(duì) worksheet 的操作。
設(shè)置表格的默認(rèn)行高。這步非必要,但是設(shè)置了更美觀。否則會(huì)出現(xiàn)有內(nèi)容的行跟沒有內(nèi)容的行行高不一致的情況。
設(shè)置列數(shù)據(jù)(表頭)和每行的數(shù)據(jù)。
導(dǎo)出 excel。
解析 AntD Table 的 columns 和 dataSource
因?yàn)槲覀兪怯?AntD 的 Table,其實(shí)已經(jīng)構(gòu)造出了表頭和具體的表格數(shù)據(jù),所以只需解析即可。

generateHeaders()方法是自己封裝的,將 Table 的 columns 轉(zhuǎn)換為 ExcelJS的表頭格式的方法:

import {ITableHeader} from "src/types";
import {ColumnsType} from "antd/lib/table/interface";

const DEFAULT_COLUMN_WIDTH = 20;

// 根據(jù) antd 的 column 生成 exceljs 的 column
export function generateHeaders(columns: any[]) {
  return columns?.map(col => {
    const obj: ITableHeader = {
      // 顯示的 name
      header: col.title,
      // 用于數(shù)據(jù)匹配的 key
      key: col.dataIndex,
      // 列寬
      width: col.width / 5 || DEFAULT_COLUMN_WIDTH,
    };
    return obj;
  })
}

在ExcelJS中,header 字段表示顯示的表頭內(nèi)容,key 是用于匹配數(shù)據(jù)的 key,width 是列寬。在 Table 的 column 中都有對(duì)應(yīng)的字段,取出來賦值即可。
注意設(shè)置列寬的時(shí)候,在線表格和 excel 的單位可能不一致,需要除以一個(gè)系數(shù)才不至于太寬。至于具體除多少,可以不斷試驗(yàn)得出個(gè)最佳值,我試的除以 5 效果比較好。

通過 worksheet.addRows()方法可以為工作表添加多行數(shù)據(jù),因?yàn)樯厦嫖覀円呀?jīng)設(shè)置了表頭,程序知道了每列數(shù)據(jù)應(yīng)該匹配哪個(gè)字段,所以這里直接傳入 Table 的 dataSource 即可。

也可以通過 worksheet.addRow()逐行添加數(shù)據(jù)。

下載 excel
saveWorkbook()也是自己封裝的方法,接收 workbook 和文件名來下載 excel 到本地。

下載是使用 file-saver庫(kù)。

import {saveAs} from "file-saver";
import {Workbook} from "exceljs";

export function saveWorkbook(workbook: Workbook, fileName: string) {
  // 導(dǎo)出文件
  workbook.xlsx.writeBuffer().then((data => {
    const blob = new Blob([data], {type: ''});
    saveAs(blob, fileName);
  }))
}

到此,可以通過短短幾行代碼實(shí)現(xiàn) AntD 的 Table 導(dǎo)出啦。






四、修改樣式
單元格,行和列均支持一組豐富的樣式和格式,這些樣式和格式會(huì)影響單元格的顯示方式。

通過分配以下屬性來設(shè)置樣式:

numFmt[7]
font[8]
alignment[9]
border[10]
fill[11]
添加背景色
我們先給表頭添加背景。因?yàn)楸眍^是第一行,可以通過 getRow(1) 來獲取表頭這一行:

// 給表頭添加背景色
let headerRow = worksheet.getRow(1);
headerRow.fill = {
  type: 'pattern',
  pattern: 'solid',
  fgColor: {argb: 'dff8ff'},
}

可以直接用 row.fill為整行設(shè)置背景色,這樣的話這一行沒有內(nèi)容的單元格也會(huì)有顏色,如圖:



從 E 列開始其實(shí)就沒有數(shù)據(jù)了,如果只想給非空單元格設(shè)置背景呢?

很遺憾 row 暴露的方法不支持直接這樣設(shè)置,但可以曲線救國(guó),遍歷本行的所有非空單元格,再給每個(gè)單元格設(shè)置背景即可。

// 通過 cell 設(shè)置背景色,更精準(zhǔn)
headerRow.eachCell((cell, colNum) => {
  cell.fill = {
    type: 'pattern',
    pattern: 'solid',
    fgColor: {argb: 'dff8ff'},
  }
})



使用單元格控制會(huì)更加的精準(zhǔn),可以看到空的單元格已經(jīng)沒有背景色了。

修改字體樣式
可以設(shè)置文字的字體、字號(hào)、顏色等屬性,支持的屬性如下表:

字體屬性    描述    示例值
name    字體名稱。    'Arial', 'Calibri', etc.
family    備用字體家族。整數(shù)值。    1 - Serif, 2 - Sans Serif, 3 - Mono, Others - unknown
scheme    字體方案。    'minor', 'major', 'none'
charset    字體字符集。整數(shù)值。    1, 2, etc.
size    字體大小。整數(shù)值。    9, 10, 12, 16, etc.
color    顏色描述,一個(gè)包含 ARGB 值的對(duì)象。    { argb: 'FFFF0000'}
bold    字體 粗細(xì)    true, false
italic    字體 傾斜    true, false
underline    字體 下劃線 樣式    true, false, 'none', 'single', 'double', 'singleAccounting', 'doubleAccounting'
strike    字體 刪除線    true, false
outline    字體輪廓    true, false
vertAlign    垂直對(duì)齊    'superscript', 'subscript'
與設(shè)置背景色相同,可以通過 row 或 cell 來設(shè)置。示例將通過 cell 設(shè)置。

修改表頭的字體為微軟雅黑,字號(hào)12號(hào),顏色為紅色,加粗斜體。

// 通過 cell 設(shè)置樣式,更精準(zhǔn)
headerRow.eachCell((cell, colNum) => {
  // 設(shè)置背景色
  cell.fill = {
    type: 'pattern',
    pattern: 'solid',
    fgColor: {argb: 'dff8ff'},
  }
  // 設(shè)置字體
  cell.font = {
    bold: true,
    italic: true,
    size: 12,
    name: '微軟雅黑',
    color: {argb: 'ff0000'},
  };
})



設(shè)置對(duì)齊方式
有效的對(duì)齊屬性:

horizontal    vertical    wrapText    shrinkToFit    indent    readingOrder    textRotation
left    top    true    true    integer    rtl    0 to 90
center    middle    false    false   
ltr    -1 to -90
right    bottom   

vertical
fill    distributed   

justify    justify   

centerContinuous   

distributed   

表格默認(rèn)的對(duì)齊方式是靠下對(duì)齊,一般都會(huì)設(shè)置為垂直方向居中對(duì)齊,文本靠左對(duì)齊,數(shù)字靠右對(duì)齊。這里為了方便都設(shè)置為水平方向靠左對(duì)齊,垂直方向居中對(duì)齊。

// 添加行
let rows = worksheet.addRows(list);
rows?.forEach(row => {
  // 設(shè)置字體
  row.font = {
    size: 11,
    name: '微軟雅黑',
  };
  // 設(shè)置對(duì)齊方式
  row.alignment = {vertical: 'middle', horizontal: 'left', wrapText: false,};
})

addRows()的返回值是被添加的行的數(shù)組,然后循環(huán)對(duì)每行設(shè)置字體和對(duì)齊方式,就完成了對(duì)整個(gè) excel 的樣式自定義。

當(dāng)然也可以對(duì)每個(gè) cell 進(jìn)行設(shè)置,效果是一樣的。



設(shè)置邊框也是同樣的方法,這里不做介紹啦。

完整的導(dǎo)出帶樣式的 excel 代碼:

 // 導(dǎo)出
  function onExportBasicExcelWithStyle() {
    // 創(chuàng)建工作簿
    const workbook = new ExcelJs.Workbook();
    // 添加sheet
    const worksheet = workbook.addWorksheet('demo sheet');
    // 設(shè)置 sheet 的默認(rèn)行高
    worksheet.properties.defaultRowHeight = 20;
    // 設(shè)置列
    worksheet.columns = generateHeaders(columns);
    // 給表頭添加背景色。因?yàn)楸眍^是第一行,可以通過 getRow(1) 來獲取表頭這一行
    let headerRow = worksheet.getRow(1);
    // 直接給這一行設(shè)置背景色
    // headerRow.fill = {
    //   type: 'pattern',
    //   pattern: 'solid',
    //   fgColor: {argb: 'dff8ff'},
    // }
    // 通過 cell 設(shè)置樣式,更精準(zhǔn)
    headerRow.eachCell((cell, colNum) => {
      // 設(shè)置背景色
      cell.fill = {
        type: 'pattern',
        pattern: 'solid',
        fgColor: {argb: 'dff8ff'},
      }
      // 設(shè)置字體
      cell.font = {
        bold: true,
        italic: true,
        size: 12,
        name: '微軟雅黑',
        color: {argb: 'ff0000'},
      };
      // 設(shè)置對(duì)齊方式
      cell.alignment = {vertical: 'middle', horizontal: 'left', wrapText: false,};
    })
    // 添加行
    let rows = worksheet.addRows(list);
    // 設(shè)置每行的樣式
    rows?.forEach(row => {
      // 設(shè)置字體
      row.font = {
        size: 11,
        name: '微軟雅黑',
      };
      // 設(shè)置對(duì)齊方式
      row.alignment = {vertical: 'middle', horizontal: 'left', wrapText: false,};
    })
    // 導(dǎo)出excel
    saveWorkbook(workbook, 'simple-demo.xlsx');
  }

五、行合并&列合并
先看在線表格的效果:



導(dǎo)出的 excel:



這個(gè)表格涉及到多級(jí)表頭、行合并、列合并。

涉及到以下幾個(gè)重難點(diǎn):

Table 表頭的解析。多級(jí)表頭有 children,要解析 Table 的 columns 為想要的數(shù)據(jù)結(jié)構(gòu)。
列合并。一塊內(nèi)容占用了多個(gè)單元格,要進(jìn)行一行中多個(gè)列的列合并,如成績(jī)和老師評(píng)語列。
行合并。表頭其實(shí)是占了兩行,除了成績(jī)外,其他的列都應(yīng)該把兩行合并為一行。
行和列同時(shí)合并。如果一個(gè)單元格合并過一次,就不能再合并,所以如果有行和列都需要合并的單元格,必須一次性同時(shí)進(jìn)行行和列合并,不能拆開為兩步。如老師評(píng)語列。
表頭和數(shù)據(jù)的樣式調(diào)整。
先貼出完整的代碼

import React, {useEffect, useState} from 'react'
import {Button, Card, Space, Table} from "antd";
import {ColumnsType} from "antd/lib/table/interface";
import {ITableHeader, StudentInfo} from "../types";
import * as ExcelJs from "exceljs";
import {
  addHeaderStyle,
  DEFAULT_COLUMN_WIDTH, DEFAULT_ROW_HEIGHT,
  generateHeaders,
  getColumnNumber,
  mergeColumnCell,
  mergeRowCell,
  saveWorkbook
} from "../utils";
import {Worksheet} from "exceljs";

interface MultiHeaderProps {
}

const columns: ColumnsType<any> = [
  {
    width: 50,
    dataIndex: 'id',
    key: 'id',
    title: 'ID',
  },
  {
    width: 100,
    dataIndex: 'name',
    key: 'name',
    title: '姓名',
  },
  {
    width: 50,
    dataIndex: 'age',
    key: 'age',
    title: '年齡',
  },
  {
    width: 80,
    dataIndex: 'gender',
    key: 'gender',
    title: '性別',
  },
  {
    dataIndex: 'score',
    key: 'score',
    title: '成績(jī)',
    children: [
      {
        width: 80,
        dataIndex: 'english',
        key: 'english',
        title: '英語',
      },
      {
        width: 80,
        dataIndex: 'math',
        key: 'math',
        title: '數(shù)學(xué)',
      },
      {
        width: 80,
        dataIndex: 'physics',
        key: 'physics',
        title: '物理',
      },
    ]
  },
  {
    width: 250,
    dataIndex: 'comment',
    key: 'comment',
    title: '老師評(píng)語',
  },
];


const MultiHeader: React.FC<MultiHeaderProps> = () => {

  const [list, setList] = useState<StudentInfo[]>([]);

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

  function generateData() {
    let arr: StudentInfo[] = [];
    for (let i = 0; i < 5; i++) {
      arr.push({
        id: i,
        name: `小明${i}號(hào)`,
        age: 8+i,
        gender: i % 2 === 0 ? '男' : '女',
        english: 80 + i,
        math: 60 + i,
        physics: 70 + i,
        comment: `小明${i}號(hào)同學(xué)表現(xiàn)非常好,熱心助人,成績(jī)優(yōu)秀,是社會(huì)主義接班人`
      })
    }
    setList(arr);
  }

  function onExportMultiHeaderExcel() {
    // 創(chuàng)建工作簿
    const workbook = new ExcelJs.Workbook();
    // 添加sheet
    const worksheet = workbook.addWorksheet('demo sheet');
    // 設(shè)置 sheet 的默認(rèn)行高
    worksheet.properties.defaultRowHeight = 20;
    // 解析 AntD Table 的 columns
    const headers = generateHeaders(columns);
    console.log({headers})
    // 第一行表頭
    const names1: string[] = [];
    // 第二行表頭
    const names2: string[] = [];
    // 用于匹配數(shù)據(jù)的 keys
    const headerKeys: string[] = [];
    headers.forEach(item => {
      if (item.children) {
        // 有 children 說明是多級(jí)表頭,header name 需要兩行
        item.children.forEach(child => {
          names1.push(item.header);
          names2.push(child.header);
          headerKeys.push(child.key);
        });
      } else {
        const columnNumber = getColumnNumber(item.width);
        for (let i = 0; i < columnNumber; i++) {
          names1.push(item.header);
          names2.push(item.header);
          headerKeys.push(item.key);
        }
      }
    });
    handleHeader(worksheet, headers, names1, names2);
    // 添加數(shù)據(jù)
    addData2Table(worksheet, headerKeys, headers);
    // 給每列設(shè)置固定寬度
    worksheet.columns = worksheet.columns.map(col => ({ ...col, width: DEFAULT_COLUMN_WIDTH }));
    // 導(dǎo)出excel
    saveWorkbook(workbook, 'simple-demo.xlsx');
  }

  function handleHeader(
    worksheet: Worksheet,
    headers: ITableHeader[],
    names1: string[],
    names2: string[],
  ) {
    // 判斷是否有 children, 有的話是兩行表頭
    const isMultiHeader = headers?.some(item => item.children);
    if (isMultiHeader) {
      // 加表頭數(shù)據(jù)
      const rowHeader1 = worksheet.addRow(names1);
      const rowHeader2 = worksheet.addRow(names2);
      // 添加表頭樣式
      addHeaderStyle(rowHeader1, {color: 'dff8ff'});
      addHeaderStyle(rowHeader2, {color: 'dff8ff'});
      mergeColumnCell(headers, rowHeader1, rowHeader2, names1, names2, worksheet);
      return;
    }
    // 加表頭數(shù)據(jù)
    const rowHeader = worksheet.addRow(names1);
    // 表頭根據(jù)內(nèi)容寬度合并單元格
    mergeRowCell(headers, rowHeader, worksheet);
    // 添加表頭樣式
    addHeaderStyle(rowHeader, {color: 'dff8ff'});
  }

  function addData2Table(worksheet: Worksheet, headerKeys: string[], headers: ITableHeader[]) {
    list?.forEach((item: any) => {
      const rowData = headerKeys?.map(key => item[key]);
      const row = worksheet.addRow(rowData);
      mergeRowCell(headers, row, worksheet);
      row.height = DEFAULT_ROW_HEIGHT;
      // 設(shè)置行樣式, wrapText: 自動(dòng)換行
      row.alignment = { vertical: 'middle', wrapText: false, shrinkToFit: false };
      row.font = { size: 11, name: '微軟雅黑' };
    })
  }

  return (
    <Card>
      <h3>多表頭表格</h3>
      <Space style={{marginBottom: 10}}>
        <Button type={'primary'} onClick={onExportMultiHeaderExcel}>導(dǎo)出excel</Button>
      </Space>
      <Table
        key={'id'}
        columns={columns}
        dataSource={list}
      />
    </Card>
  );
}

export default MultiHeader

前面幾步創(chuàng)建 workbook 和 worksheet 都是一樣的,從解析表頭 generateHeaders() 開始邏輯會(huì)有所不同。






表頭解析
我們修改上一節(jié)的generateHeaders()方法,添加有 children 時(shí)的邏輯。多級(jí)表頭時(shí)我們也構(gòu)造出 children。

// 根據(jù) antd 的 column 生成 exceljs 的 column
export function generateHeaders(columns: any[]) {
  return columns?.map(col => {
    const obj: ITableHeader = {
      // 顯示的 name
      header: col.title,
      // 用于數(shù)據(jù)匹配的 key
      key: col.dataIndex,
      // 列寬
      width: col.width / 5 || DEFAULT_COLUMN_WIDTH,
    };
    if (col.children) {
      obj.children = col.children?.map((item: any) => ({
        key: item.dataIndex,
        header: item.title,
        width: item.width,
        parentKey: col.dataIndex,
      }));
    }
    return obj;
  })
}

構(gòu)造出來的數(shù)據(jù)結(jié)構(gòu)如下:



上一節(jié)簡(jiǎn)單表格中我們用 worksheet.columns = generateHeaders(columns)設(shè)置每一個(gè)表頭列所要顯示的信息和應(yīng)該匹配的 key,但是它無法設(shè)置多級(jí)表頭,所以需要換一種思路,摒棄列(表頭)的概念,把表頭也當(dāng)成一行數(shù)據(jù)來自己寫入。下面的每行數(shù)據(jù),也都自己通過計(jì)算匹配出應(yīng)該在什么位置顯示什么內(nèi)容。

先來看這段代碼:

// 解析 AntD Table 的 columns
const headers = generateHeaders(columns);
// 第一行表頭
const names1: string[] = [];
// 第二行表頭
const names2: string[] = [];
// 用于匹配數(shù)據(jù)的 keys
const headerKeys: string[] = [];
headers.forEach(item => {
  if (item.children) {
    // 有 children 說明是多級(jí)表頭,header name 需要兩行
    item.children.forEach(child => {
      names1.push(item.header);
      names2.push(child.header);
      headerKeys.push(child.key);
    });
  } else {
    const columnNumber = getColumnNumber(item.width);
    for (let i = 0; i < columnNumber; i++) {
      names1.push(item.header);
      names2.push(item.header);
      headerKeys.push(item.key);
    }
  }
});

這個(gè)例子有兩級(jí)表頭,所以需要兩行來設(shè)置每一級(jí)表頭,分別命名為 names1和 names2,它們里面存的是展示出來的 name,如:ID、姓名、年齡等。還需要一個(gè)headerKeys用來存儲(chǔ)每一列需要匹配的 key,如:id、name、age 等 json 的 key。

注意一點(diǎn),headerKeys是以第二行表頭為準(zhǔn),因?yàn)榈诙胁攀钦嬲@示的內(nèi)容。

構(gòu)造出了 names1、names2和headerKeys,就可以開始生成真正的表頭了:

  function handleHeader(
    worksheet: Worksheet,
    headers: ITableHeader[],
    names1: string[],
    names2: string[],
  ) {
    // 判斷是否有 children, 有的話是兩行表頭
    const isMultiHeader = headers?.some(item => item.children);
    if (isMultiHeader) {
      // 加表頭數(shù)據(jù)
      const rowHeader1 = worksheet.addRow(names1);
      const rowHeader2 = worksheet.addRow(names2);
      // 添加表頭樣式
      addHeaderStyle(rowHeader1, {color: 'dff8ff'});
      addHeaderStyle(rowHeader2, {color: 'dff8ff'});
      mergeColumnCell(headers, rowHeader1, rowHeader2, names1, names2, worksheet);
      return;
    }
    // 加表頭數(shù)據(jù)
    const rowHeader = worksheet.addRow(names1);
    // 表頭根據(jù)內(nèi)容寬度合并單元格
    mergeRowCell(headers, rowHeader, worksheet);
    // 添加表頭樣式
    addHeaderStyle(rowHeader, {color: 'dff8ff'});
  }

先判斷有沒有多級(jí)表頭,單行表頭和多行表頭執(zhí)行的邏輯不同。

通過 worksheet.addRow()將表頭添加為一行數(shù)據(jù),多行表頭就添加兩次。然后通過 addHeaderStyle()給表頭添加樣式,這是自己封裝的方法,在 utils里。最后也是最重要的是合并單元格,

合并同一行多列
合并單元格的方法是 worksheet.mergeCells(),可以有很多種合并方式:

// 合并一系列單元格
worksheet.mergeCells('A4:B5');

// ...合并的單元格被鏈接起來了
worksheet.getCell('B5').value = 'Hello, World!';
expect(worksheet.getCell('B5').value).toBe(worksheet.getCell('A4').value);
expect(worksheet.getCell('B5').master).toBe(worksheet.getCell('A4'));

// ...合并的單元格共享相同的樣式對(duì)象
expect(worksheet.getCell('B5').style).toBe(worksheet.getCell('A4').style);
worksheet.getCell('B5').style.font = myFonts.arial;
expect(worksheet.getCell('A4').style.font).toBe(myFonts.arial);

// 取消單元格合并將打破鏈接的樣式
worksheet.unMergeCells('A4');
expect(worksheet.getCell('B5').style).not.toBe(worksheet.getCell('A4').style);
expect(worksheet.getCell('B5').style.font).not.toBe(myFonts.arial);

// 按左上,右下合并
worksheet.mergeCells('K10', 'M12');

// 按開始行,開始列,結(jié)束行,結(jié)束列合并(相當(dāng)于 K10:M12)
worksheet.mergeCells(10,11,12,13);

先看合并同一行多列的算法,核心在于先設(shè)置一個(gè)索引,從1開始,代表第一列。然后循環(huán) headers,如果當(dāng)前 header 有 children,則每個(gè)子級(jí)占一列,然后索引值加1。如果沒有 children,計(jì)算這一個(gè)數(shù)據(jù)的寬度將會(huì)占用幾個(gè)單元格,也就是幾列,這個(gè)列數(shù)就是需要合并的列數(shù),合并完之后索引值加1。

// 行合并單元格
export function mergeRowCell(headers: ITableHeader[], row: Row, worksheet: Worksheet) {
  // 當(dāng)前列的索引
  let colIndex = 1;
  headers.forEach(header => {
    const { width, children } = header;
    if (children) {
      children.forEach(child => {
        colIndex += 1;
      });
    } else {
      // 需要的列數(shù),四舍五入
      const colNum = getColumnNumber(width);
      // 如果 colNum > 1 說明需要合并
      if (colNum > 1) {
        worksheet.mergeCells(Number(row.number), colIndex, Number(row.number), colIndex + colNum - 1);
      }
      colIndex += colNum;
    }
  });
}

export function getColumnNumber(width: number) {
  // 需要的列數(shù),四舍五入
  return Math.round(width / DEFAULT_COLUMN_WIDTH);
}

合并單元格的方法是:

worksheet.mergeCells(Number(row.number), colIndex, Number(row.number), colIndex + colNum - 1);

四個(gè)參數(shù)分別是合并的開始行、開始列、結(jié)束行、結(jié)束列。

通過 row.number得到當(dāng)前行的行數(shù),因?yàn)槭峭恍械亩嗔泻喜?,所以開始結(jié)束行一致,開始列是索引值 colIndex,結(jié)束列是 colIndex + colNum - 1。

同時(shí)合并行和列
如果是多級(jí)表頭,需要同時(shí)處理行和列合并,用到了封裝的 mergeColumnCell方法。

基本思路是先判斷合并的類型,一共有三種情況:

只有行合并
只有列合并
同時(shí)進(jìn)行行和列合并
然后計(jì)算出起始的行和列,以及結(jié)束的行和列。

// 合并行和列,用于處理表頭合并
export function mergeColumnCell(
  headers: ITableHeader[],
  rowHeader1: Row,
  rowHeader2: Row,
  nameRow1: string[],
  nameRow2: string[],
  worksheet: Worksheet,
) {
  // 當(dāng)前 index 的指針
  let pointer = -1;
  nameRow1.forEach((name, index) => {
    // 當(dāng) index 小于指針時(shí),說明這一列已經(jīng)被合并過了,不能再合并
    if (index <= pointer) return;
    // 是否應(yīng)該列合并
    const shouldVerticalMerge = name === nameRow2[index];
    // 是否應(yīng)該行合并
    const shouldHorizontalMerge = index !== nameRow1.lastIndexOf(name);
    pointer = nameRow1.lastIndexOf(name);
    if (shouldVerticalMerge && shouldHorizontalMerge) {
      // 兩個(gè)方向都合并
      worksheet.mergeCells(
        Number(rowHeader1.number),
        index + 1,
        Number(rowHeader2.number),
        nameRow1.lastIndexOf(name) + 1,
      );
    } else if (shouldVerticalMerge && !shouldHorizontalMerge) {
      // 只在垂直方向上同一列的兩行合并
      worksheet.mergeCells(Number(rowHeader1.number), index + 1, Number(rowHeader2.number), index + 1);
    } else if (!shouldVerticalMerge && shouldHorizontalMerge) {
      // 只有水平方向同一行的多列合并
      worksheet.mergeCells(
        Number(rowHeader1.number),
        index + 1,
        Number(rowHeader1.number),
        nameRow1.lastIndexOf(name) + 1,
      );
      // eslint-disable-next-line no-param-reassign
      const cell = rowHeader1.getCell(index + 1);
      cell.alignment = { vertical: 'middle', horizontal: 'center' };
    }
  });
}

添加數(shù)據(jù)行
在計(jì)算表頭時(shí),已經(jīng)得到了每列的 key 值列表 headerKeys,通過headerKeys可以取出每一列對(duì)應(yīng)的具體數(shù)據(jù)。

function addData2Table(worksheet: Worksheet, headerKeys: string[], headers: ITableHeader[]) {
  list?.forEach((item: any) => {
    const rowData = headerKeys?.map(key => item[key]);
    const row = worksheet.addRow(rowData);
    mergeRowCell(headers, row, worksheet);
    row.height = DEFAULT_ROW_HEIGHT;
    // 設(shè)置行樣式, wrapText: 自動(dòng)換行
    row.alignment = { vertical: 'middle', wrapText: false, shrinkToFit: false };
    row.font = { size: 11, name: '微軟雅黑' };
  })
}

先循環(huán)數(shù)據(jù)列表,然后循環(huán) headerKeys取出對(duì)應(yīng)的值,再通過 worksheet.addRow將這一行數(shù)據(jù)添加進(jìn)表格中。由于可能出現(xiàn)一個(gè)字段占用多列的情況,所以還需要進(jìn)行合并單元格操作,可以復(fù)用 mergeRowCell()方法。最后設(shè)置每行的樣式,即可得到最終的數(shù)據(jù)。

一個(gè) sheet 中放多張表
在導(dǎo)出多級(jí)表頭表格的時(shí)候,我們寫表頭和數(shù)據(jù)行都是用的worksheet.addRow方法,而沒有用 worksheet.column設(shè)置表格的表頭,這樣更加靈活,每一列想顯示什么內(nèi)容完全自己控制。

處理多個(gè)表格時(shí),也可以用同樣的方法。因?yàn)槊恳恍袛?shù)據(jù)都是自己寫入的,所以不管有幾張表都沒有關(guān)系,我們關(guān)心的只有每一行的數(shù)據(jù)。

同時(shí)我們做了行和列合并算法,可以實(shí)現(xiàn)每一張表的每一列都能定制寬度。

可以將上面兩個(gè)例子結(jié)合起來,導(dǎo)出到一個(gè) sheet里,就實(shí)現(xiàn)了一個(gè)sheet中放多張表的需求。

結(jié)語
除了導(dǎo)出 xlsx,ExcelJS[12] 還支持導(dǎo)出 csv格式。此外還有設(shè)置頁眉頁腳、操作視圖、添加公式、使用富文本等功能,非常的強(qiáng)大。

官方的文檔也很詳細(xì),不懂的地方直接看文檔即可。

源碼地址:github.com/cachecats/e…[13]

來源;solocoder, https://juejin.cn/post/7071882317953761316,

同時(shí)收錄于小程序-互聯(lián)網(wǎng)小兵,不止于前端,收各平臺(tái)優(yōu)質(zhì)熱門的技術(shù)文章(后端、移動(dòng)端、算法、人工智能...),大家支持支持,點(diǎn)擊前往體驗(yàn)!

作者:solocoder



歡迎關(guān)注微信公眾號(hào) :前端晚間課

更多文章,收錄于小程序-互聯(lián)網(wǎng)小兵