前端复杂表格一键导出看这篇就够了(附源码)

大厂技术  高级前端  Node进阶

点击上方 程序员成长指北,关注公众号

回复1,加入高级Node交流群

前端导出 excel 的需求很多,但市面上好用的库并不多,讲明白复杂使用场景的文章更少。

本文将以文字 + demo 源码的形式,力求讲清楚满足 99% 使用场景的终极 excel 导出方案。

如果项目中用到了 AntD,那就更简单了,因为 Table 本身已经设置好了 column 和 dataSource,只需解析 column 和 dataSource 即可快速导出 Excel。

实现功能:

  • 简单表格导出

  • 为表格添加样式(更改背景色、更换字体、字号、颜色)

  • 设置行高、列宽

  • 解析 ant-design 的 Table 直接导出excel,根据 antd 页面中设置的列宽动态计算 excel 中的列宽

  • 多级表头(行合并、列合并)

  • 一个 sheet 中放多张表,并实现每张表的列宽不同

源码地址:github.com/cachecats/excel-export-demo[1]

第二篇文章:js 批量导出 excel 为zip压缩包:https://juejin.cn/post/7080169896209809445/[2], 对导出方法进行了封装,还实现了使用 exceljsfile-saverjszip实现下载包含多层级文件夹、多个 excel、每个 excel 支持多个 sheet 的 zip 压缩包。

一、技术选型

xlsx

呼声最高的是 xlsx[3],又叫 SheetJS,也是下载量最高和 star最多的库。试用了一下很强大,但是!默认不支持改变样式,想要支持改变样式,需要使用它的收费版本。

本着勤俭节约的原则,很多人使用了另一个第三方库:xlsx-style[4],但是使用起来极其复杂,还需要改 node_modules 源码,这个库最后更新时间也定格在了 6年前。还有一些其他的第三方样式拓展库,质量参差不齐。

使用成本和后期的维护成本很高,不得不放弃。

ExcelJS

ExcelJS[5] 周下载量 450k,github star 9k,并且拥有中文文档,对国内开发者很友好。虽然文档是以README 的形式,可读性不太好,但重在内容,常用的功能基本都有覆盖。

最近更新时间是6个月内,试用了一下,集成很简单,再加之文档丰富,就选它了。

安装:

npm install exceljs
复制代码

下载到本地还需要另一个库:file-saver

npm install file-saver
复制代码

二、基本概念

先了解下基本概念,更详细的介绍参考官方文档:github.com/exceljs/exc…[6]

workbook

workbook:工作簿,可以理解为整个 excel 表格。

通过 const workbook = new ExcelJS.Workbook() 创建工作簿,还可以设置工作簿的属性:

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')创建工作表,每个 workbook 可添加多个 worksheet。

使用 addWorksheet 函数的第二个参数来指定工作表的选项。

// 创建带有红色标签颜色的工作表
const sheet = workbook.addWorksheet('My Sheet', {properties:{tabColor:{argb:'FFC0000'}}});

// 创建一个隐藏了网格线的工作表
const sheet = workbook.addWorksheet('My Sheet', {views: [{showGridLines: false}]});

// 创建一个第一行和列冻结的工作表
const sheet = workbook.addWorksheet('My Sheet', {views:[{xSplit: 1, ySplit:1}]});

// 使用A4设置的页面设置设置创建新工作表 - 横向
const worksheet =  workbook.addWorksheet('My Sheet', {
  pageSetup:{paperSize: 9, orientation:'landscape'}
});

// 创建一个具有页眉页脚的工作表
const sheet = workbook.addWorksheet('My Sheet', {
  headerFooter:{firstHeader: "Hello Exceljs", firstFooter: "Hello World"}
});

// 创建一个冻结了第一行和第一列的工作表
const sheet = workbook.addWorksheet('My Sheet', {views:[{state: 'frozen', xSplit: 1, ySplit:1}]});
复制代码

columns

列,通过 worksheet.columns可设置表头。

// 添加列标题并定义列键和宽度
// 注意:这些列结构仅是构建工作簿的方便之处,除了列宽之外,它们不会完全保留。
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的列号访问单个列
const idCol = worksheet.getColumn('id');
const nameCol = worksheet.getColumn('B');
const dobCol = worksheet.getColumn(3);

// 设置列属性

// 注意:将覆盖 C1 单元格值
dobCol.header = 'Date of Birth';

// 注意:这将覆盖 C1:C2 单元格值
dobCol.header = ['Date of Birth', 'A.K.A. D.O.B.'];

// 从现在开始,此列将以 “dob” 而不是 “DOB” 建立索引
dobCol.key = 'dob';

dobCol.width = 15;

// 如果需要,隐藏列
dobCol.hidden = true;
复制代码

还可对列进行各种操作。

// 遍历此列中的所有当前单元格
dobCol.eachCell(function(cell, rowNumber) {
  // ...
});

// 遍历此列中的所有当前单元格,包括空单元格
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];

// 剪切一列或多列(右边的列向左移动)
// 如果定义了列属性,则会相应地对其进行切割或移动
// 已知问题:如果拼接导致任何合并的单元格移动,结果可能是不可预测的
worksheet.spliceColumns(3,2);

// 删除一列,再插入两列。
// 注意:第4列及以上的列将右移1列。
// 另外:如果工作表中的行数多于列插入项中的值,则行将仍然被插入,就好像值存在一样。
const newCol3Values = [1,2,3,4,5];
const newCol4Values = ['one', 'two', 'three', 'four', 'five'];
worksheet.spliceColumns(3, 1, newCol3Values, newCol4Values);
复制代码

row

行,可以添加一行或者同时添加多行数据,是使用最频繁的属性。

// 通过 json 添加一行数据,需要先设置 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)});
// 通过数组添加一行数据
worksheet.addRow([3, 'Sam', new Date()]);

// 同时添加多行数据
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));
});

// 连续遍历所有非空单元格
row.eachCell(function(cell, colNumber) {
  console.log('Cell ' + colNumber + ' = ' + cell.value);
});

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

三、简单表格导出

本文所有示例都使用 React + AntD。

先看效果,我们用 AntD 的 Table 写个简单的表格页面,并设置不同的列宽:

353a1dd7666b887fa1e060fea66f3c61.png

点击导出 excel,然后打开得到以下结果:

2291cbae17db9f4e7b202220a42e8b7b.png

可以看到,导出的 excel 列宽比例跟在线的表格是一致的。

贴源码:

// 简单 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}号`,
        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() {
    // 创建工作簿
    const workbook = new ExcelJs.Workbook();
    // 添加sheet
    const worksheet = workbook.addWorksheet('demo sheet');
    // 设置 sheet 的默认行高
    worksheet.properties.defaultRowHeight = 20;
    // 设置列
    worksheet.columns = generateHeaders(columns);
    // 添加行
    worksheet.addRows(list);
    // 导出excel
    saveWorkbook(workbook, 'simple-demo.xlsx');
  }

  return (
    <Card>
      <h3>简单表格</h3>
      <Button type={'primary'} style={{marginBottom: 10}} onClick={onExportBasicExcel}>导出excel</Button>
      <Table
        columns={columns}
        dataSource={list}
      />
    </Card>
  );
}

export default SimpleDemo
复制代码

真正导出的代码只有几行,重点看 onExportBasicExcel方法:

  1. 先创建工作簿和 sheet 页,这两行是固定代码。如果需要多 sheet,则创建多个 sheet 即可。后续对表格的所有操作,都是对 worksheet 的操作。

  2. 设置表格的默认行高。这步非必要,但是设置了更美观。否则会出现有内容的行跟没有内容的行行高不一致的情况。

  3. 设置列数据(表头)和每行的数据。

  4. 导出 excel。

解析 AntD Table 的 columns 和 dataSource

因为我们是用 AntD 的 Table,其实已经构造出了表头和具体的表格数据,所以只需解析即可。

generateHeaders()方法是自己封装的,将 Table 的 columns 转换为 ExcelJS的表头格式的方法:

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

const DEFAULT_COLUMN_WIDTH = 20;

// 根据 antd 的 column 生成 exceljs 的 column
export function generateHeaders(columns: any[]) {
  return columns?.map(col => {
    const obj: ITableHeader = {
      // 显示的 name
      header: col.title,
      // 用于数据匹配的 key
      key: col.dataIndex,
      // 列宽
      width: col.width / 5 || DEFAULT_COLUMN_WIDTH,
    };
    return obj;
  })
}
复制代码

ExcelJS中,header 字段表示显示的表头内容,key 是用于匹配数据的 key,width 是列宽。在 Table 的 column 中都有对应的字段,取出来赋值即可。
注意设置列宽的时候,在线表格和 excel 的单位可能不一致,需要除以一个系数才不至于太宽。至于具体除多少,可以不断试验得出个最佳值,我试的除以 5 效果比较好。

通过 worksheet.addRows()方法可以为工作表添加多行数据,因为上面我们已经设置了表头,程序知道了每列数据应该匹配哪个字段,所以这里直接传入 Table 的 dataSource 即可。

也可以通过 worksheet.addRow()逐行添加数据。

下载 excel

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

下载是使用 file-saver库。

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

export function saveWorkbook(workbook: Workbook, fileName: string) {
  // 导出文件
  workbook.xlsx.writeBuffer().then((data => {
    const blob = new Blob([data], {type: ''});
    saveAs(blob, fileName);
  }))
}
复制代码

到此,可以通过短短几行代码实现 AntD 的 Table 导出啦。

四、修改样式

单元格,行和列均支持一组丰富的样式和格式,这些样式和格式会影响单元格的显示方式。

通过分配以下属性来设置样式:

  • numFmt[7]

  • font[8]

  • alignment[9]

  • border[10]

  • fill[11]

添加背景色

我们先给表头添加背景。因为表头是第一行,可以通过 getRow(1) 来获取表头这一行:

// 给表头添加背景色
let headerRow = worksheet.getRow(1);
headerRow.fill = {
  type: 'pattern',
  pattern: 'solid',
  fgColor: {argb: 'dff8ff'},
}
复制代码

可以直接用 row.fill为整行设置背景色,这样的话这一行没有内容的单元格也会有颜色,如图:

3ebe951c9627df2eb996a58a8222a5df.png

从 E 列开始其实就没有数据了,如果只想给非空单元格设置背景呢?

很遗憾 row 暴露的方法不支持直接这样设置,但可以曲线救国,遍历本行的所有非空单元格,再给每个单元格设置背景即可。

// 通过 cell 设置背景色,更精准
headerRow.eachCell((cell, colNum) => {
  cell.fill = {
    type: 'pattern',
    pattern: 'solid',
    fgColor: {argb: 'dff8ff'},
  }
})
复制代码
0c5f8e9c69fb8a03e760cfbaf23214f1.png

使用单元格控制会更加的精准,可以看到空的单元格已经没有背景色了。

修改字体样式

可以设置文字的字体、字号、颜色等属性,支持的属性如下表:

字体属性描述示例值
name字体名称。'Arial', 'Calibri', etc.
family备用字体家族。整数值。1 - Serif, 2 - Sans Serif, 3 - Mono, Others - unknown
scheme字体方案。'minor', 'major', 'none'
charset字体字符集。整数值。1, 2, etc.
size字体大小。整数值。9, 10, 12, 16, etc.
color颜色描述,一个包含 ARGB 值的对象。{ argb: 'FFFF0000'}
bold字体 粗细true, false
italic字体 倾斜true, false
underline字体 下划线 样式true, false, 'none', 'single', 'double', 'singleAccounting', 'doubleAccounting'
strike字体 删除线true, false
outline字体轮廓true, false
vertAlign垂直对齐'superscript', 'subscript'

与设置背景色相同,可以通过 row 或 cell 来设置。示例将通过 cell 设置。

修改表头的字体为微软雅黑,字号12号,颜色为红色,加粗斜体。

// 通过 cell 设置样式,更精准
headerRow.eachCell((cell, colNum) => {
  // 设置背景色
  cell.fill = {
    type: 'pattern',
    pattern: 'solid',
    fgColor: {argb: 'dff8ff'},
  }
  // 设置字体
  cell.font = {
    bold: true,
    italic: true,
    size: 12,
    name: '微软雅黑',
    color: {argb: 'ff0000'},
  };
})
复制代码
4fcad05fdff8c3f527ba1ff3ecff6b4f.png

设置对齐方式

有效的对齐属性:

horizontalverticalwrapTextshrinkToFitindentreadingOrdertextRotation
lefttoptruetrueintegerrtl0 to 90
centermiddlefalsefalse
ltr-1 to -90
rightbottom



vertical
filldistributed




justifyjustify




centerContinuous





distributed





表格默认的对齐方式是靠下对齐,一般都会设置为垂直方向居中对齐,文本靠左对齐,数字靠右对齐。这里为了方便都设置为水平方向靠左对齐,垂直方向居中对齐。

// 添加行
let rows = worksheet.addRows(list);
rows?.forEach(row => {
  // 设置字体
  row.font = {
    size: 11,
    name: '微软雅黑',
  };
  // 设置对齐方式
  row.alignment = {vertical: 'middle', horizontal: 'left', wrapText: false,};
})
复制代码

addRows()的返回值是被添加的行的数组,然后循环对每行设置字体和对齐方式,就完成了对整个 excel 的样式自定义。

当然也可以对每个 cell 进行设置,效果是一样的。

fb4c72937c9c086c221f173011d438ef.png

设置边框也是同样的方法,这里不做介绍啦。

完整的导出带样式的 excel 代码:

// 导出
  function onExportBasicExcelWithStyle() {
    // 创建工作簿
    const workbook = new ExcelJs.Workbook();
    // 添加sheet
    const worksheet = workbook.addWorksheet('demo sheet');
    // 设置 sheet 的默认行高
    worksheet.properties.defaultRowHeight = 20;
    // 设置列
    worksheet.columns = generateHeaders(columns);
    // 给表头添加背景色。因为表头是第一行,可以通过 getRow(1) 来获取表头这一行
    let headerRow = worksheet.getRow(1);
    // 直接给这一行设置背景色
    // headerRow.fill = {
    //   type: 'pattern',
    //   pattern: 'solid',
    //   fgColor: {argb: 'dff8ff'},
    // }
    // 通过 cell 设置样式,更精准
    headerRow.eachCell((cell, colNum) => {
      // 设置背景色
      cell.fill = {
        type: 'pattern',
        pattern: 'solid',
        fgColor: {argb: 'dff8ff'},
      }
      // 设置字体
      cell.font = {
        bold: true,
        italic: true,
        size: 12,
        name: '微软雅黑',
        color: {argb: 'ff0000'},
      };
      // 设置对齐方式
      cell.alignment = {vertical: 'middle', horizontal: 'left', wrapText: false,};
    })
    // 添加行
    let rows = worksheet.addRows(list);
    // 设置每行的样式
    rows?.forEach(row => {
      // 设置字体
      row.font = {
        size: 11,
        name: '微软雅黑',
      };
      // 设置对齐方式
      row.alignment = {vertical: 'middle', horizontal: 'left', wrapText: false,};
    })
    // 导出excel
    saveWorkbook(workbook, 'simple-demo.xlsx');
  }
复制代码

五、行合并&列合并

先看在线表格的效果:

ef07dc0750af5c3cfc7b26aca8cd6199.png

导出的 excel:

0e752aefa035d00a73ab5fb4b7c1b156.png

这个表格涉及到多级表头、行合并、列合并。

涉及到以下几个重难点:

  • Table 表头的解析。多级表头有 children,要解析 Table 的 columns 为想要的数据结构。

  • 列合并。一块内容占用了多个单元格,要进行一行中多个列的列合并,如成绩和老师评语列。

  • 行合并。表头其实是占了两行,除了成绩外,其他的列都应该把两行合并为一行。

  • 行和列同时合并。如果一个单元格合并过一次,就不能再合并,所以如果有行和列都需要合并的单元格,必须一次性同时进行行和列合并,不能拆开为两步。如老师评语列。

  • 表头和数据的样式调整。

先贴出完整的代码

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: '成绩',
    children: [
      {
        width: 80,
        dataIndex: 'english',
        key: 'english',
        title: '英语',
      },
      {
        width: 80,
        dataIndex: 'math',
        key: 'math',
        title: '数学',
      },
      {
        width: 80,
        dataIndex: 'physics',
        key: 'physics',
        title: '物理',
      },
    ]
  },
  {
    width: 250,
    dataIndex: 'comment',
    key: 'comment',
    title: '老师评语',
  },
];


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}号`,
        age: 8+i,
        gender: i % 2 === 0 ? '男' : '女',
        english: 80 + i,
        math: 60 + i,
        physics: 70 + i,
        comment: `小明${i}号同学表现非常好,热心助人,成绩优秀,是社会主义接班人`
      })
    }
    setList(arr);
  }

  function onExportMultiHeaderExcel() {
    // 创建工作簿
    const workbook = new ExcelJs.Workbook();
    // 添加sheet
    const worksheet = workbook.addWorksheet('demo sheet');
    // 设置 sheet 的默认行高
    worksheet.properties.defaultRowHeight = 20;
    // 解析 AntD Table 的 columns
    const headers = generateHeaders(columns);
    console.log({headers})
    // 第一行表头
    const names1: string[] = [];
    // 第二行表头
    const names2: string[] = [];
    // 用于匹配数据的 keys
    const headerKeys: string[] = [];
    headers.forEach(item => {
      if (item.children) {
        // 有 children 说明是多级表头,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);
    // 添加数据
    addData2Table(worksheet, headerKeys, headers);
    // 给每列设置固定宽度
    worksheet.columns = worksheet.columns.map(col => ({ ...col, width: DEFAULT_COLUMN_WIDTH }));
    // 导出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) {
      // 加表头数据
      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;
    }
    // 加表头数据
    const rowHeader = worksheet.addRow(names1);
    // 表头根据内容宽度合并单元格
    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;
      // 设置行样式, wrapText: 自动换行
      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}>导出excel</Button>
      </Space>
      <Table
        key={'id'}
        columns={columns}
        dataSource={list}
      />
    </Card>
  );
}

export default MultiHeader
复制代码

前面几步创建 workbook 和 worksheet 都是一样的,从解析表头 generateHeaders() 开始逻辑会有所不同。

表头解析

我们修改上一节的generateHeaders()方法,添加有 children 时的逻辑。多级表头时我们也构造出 children。

// 根据 antd 的 column 生成 exceljs 的 column
export function generateHeaders(columns: any[]) {
  return columns?.map(col => {
    const obj: ITableHeader = {
      // 显示的 name
      header: col.title,
      // 用于数据匹配的 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;
  })
}
复制代码

构造出来的数据结构如下:

1ad9bd3a85b8430899e38cd7fa6960d7.png

上一节简单表格中我们用 worksheet.columns = generateHeaders(columns)设置每一个表头列所要显示的信息和应该匹配的 key,但是它无法设置多级表头,所以需要换一种思路,摒弃列(表头)的概念,把表头也当成一行数据来自己写入。下面的每行数据,也都自己通过计算匹配出应该在什么位置显示什么内容。

先来看这段代码:

// 解析 AntD Table 的 columns
const headers = generateHeaders(columns);
// 第一行表头
const names1: string[] = [];
// 第二行表头
const names2: string[] = [];
// 用于匹配数据的 keys
const headerKeys: string[] = [];
headers.forEach(item => {
  if (item.children) {
    // 有 children 说明是多级表头,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);
    }
  }
});
复制代码

这个例子有两级表头,所以需要两行来设置每一级表头,分别命名为 names1和 names2,它们里面存的是展示出来的 name,如:ID、姓名、年龄等。还需要一个headerKeys用来存储每一列需要匹配的 key,如:id、name、age 等 json 的 key。

注意一点,headerKeys是以第二行表头为准,因为第二行才是真正显示的内容。

构造出了 names1names2headerKeys,就可以开始生成真正的表头了:

function handleHeader(
    worksheet: Worksheet,
    headers: ITableHeader[],
    names1: string[],
    names2: string[],
  ) {
    // 判断是否有 children, 有的话是两行表头
    const isMultiHeader = headers?.some(item => item.children);
    if (isMultiHeader) {
      // 加表头数据
      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;
    }
    // 加表头数据
    const rowHeader = worksheet.addRow(names1);
    // 表头根据内容宽度合并单元格
    mergeRowCell(headers, rowHeader, worksheet);
    // 添加表头样式
    addHeaderStyle(rowHeader, {color: 'dff8ff'});
  }
复制代码

先判断有没有多级表头,单行表头和多行表头执行的逻辑不同。

通过 worksheet.addRow()将表头添加为一行数据,多行表头就添加两次。然后通过 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'));

// ...合并的单元格共享相同的样式对象
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');

// 按开始行,开始列,结束行,结束列合并(相当于 K10:M12)
worksheet.mergeCells(10,11,12,13);
复制代码

先看合并同一行多列的算法,核心在于先设置一个索引,从1开始,代表第一列。然后循环 headers,如果当前 header 有 children,则每个子级占一列,然后索引值加1。如果没有 children,计算这一个数据的宽度将会占用几个单元格,也就是几列,这个列数就是需要合并的列数,合并完之后索引值加1。

// 行合并单元格
export function mergeRowCell(headers: ITableHeader[], row: Row, worksheet: Worksheet) {
  // 当前列的索引
  let colIndex = 1;
  headers.forEach(header => {
    const { width, children } = header;
    if (children) {
      children.forEach(child => {
        colIndex += 1;
      });
    } else {
      // 需要的列数,四舍五入
      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) {
  // 需要的列数,四舍五入
  return Math.round(width / DEFAULT_COLUMN_WIDTH);
}
复制代码

合并单元格的方法是:

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

四个参数分别是合并的开始行、开始列、结束行、结束列。

通过 row.number得到当前行的行数,因为是同一行的多列合并,所以开始结束行一致,开始列是索引值 colIndex,结束列是 colIndex + colNum \- 1

同时合并行和列

如果是多级表头,需要同时处理行和列合并,用到了封装的 mergeColumnCell方法。

基本思路是先判断合并的类型,一共有三种情况:

  • 只有行合并

  • 只有列合并

  • 同时进行行和列合并

然后计算出起始的行和列,以及结束的行和列。

// 合并行和列,用于处理表头合并
export function mergeColumnCell(
  headers: ITableHeader[],
  rowHeader1: Row,
  rowHeader2: Row,
  nameRow1: string[],
  nameRow2: string[],
  worksheet: Worksheet,
) {
  // 当前 index 的指针
  let pointer = -1;
  nameRow1.forEach((name, index) => {
    // 当 index 小于指针时,说明这一列已经被合并过了,不能再合并
    if (index <= pointer) return;
    // 是否应该列合并
    const shouldVerticalMerge = name === nameRow2[index];
    // 是否应该行合并
    const shouldHorizontalMerge = index !== nameRow1.lastIndexOf(name);
    pointer = nameRow1.lastIndexOf(name);
    if (shouldVerticalMerge && shouldHorizontalMerge) {
      // 两个方向都合并
      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' };
    }
  });
}
复制代码

添加数据行

在计算表头时,已经得到了每列的 key 值列表 headerKeys,通过headerKeys可以取出每一列对应的具体数据。

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;
    // 设置行样式, wrapText: 自动换行
    row.alignment = { vertical: 'middle', wrapText: false, shrinkToFit: false };
    row.font = { size: 11, name: '微软雅黑' };
  })
}
复制代码

先循环数据列表,然后循环 headerKeys取出对应的值,再通过 worksheet.addRow将这一行数据添加进表格中。由于可能出现一个字段占用多列的情况,所以还需要进行合并单元格操作,可以复用 mergeRowCell()方法。最后设置每行的样式,即可得到最终的数据。

一个 sheet 中放多张表

在导出多级表头表格的时候,我们写表头和数据行都是用的worksheet.addRow方法,而没有用 worksheet.column设置表格的表头,这样更加灵活,每一列想显示什么内容完全自己控制。

处理多个表格时,也可以用同样的方法。因为每一行数据都是自己写入的,所以不管有几张表都没有关系,我们关心的只有每一行的数据。

同时我们做了行和列合并算法,可以实现每一张表的每一列都能定制宽度。

可以将上面两个例子结合起来,导出到一个 sheet里,就实现了一个sheet中放多张表的需求。

结语

除了导出 xlsx,ExcelJS[12] 还支持导出 csv格式。此外还有设置页眉页脚、操作视图、添加公式、使用富文本等功能,非常的强大。

官方的文档也很详细,不懂的地方直接看文档即可。

源码地址:github.com/cachecats/excel-export-demo[13]

关于本文

作者:solocoder

https://juejin.cn/post/7071882317953761316

Node 社群


我组建了一个氛围特别好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你对Node.js学习感兴趣的话(后续有计划也可以),我们可以一起进行Node.js相关的交流、学习、共建。下方加 考拉 好友回复「Node」即可。

如果你觉得这篇内容对你有帮助,我想请你帮我2个小忙:

1. 点个「在看」,让更多人也能看到这篇文章2. 订阅官方博客 www.inode.club 让我们一起成长

点赞和在看就是最大的支持
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值