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

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

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

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

实现功能:

  • 简单表格导出
  • 为表格添加样式(更改背景色、更换字体、字号、颜色)
  • 设置行高、列宽
  • 解析 ant-design 的 Table 直接导出excel,根据 antd 页面中设置的列宽动态计算 excel 中的列宽
  • 多级表头(行合并、列合并)
  • 一个 sheet 中放多张表,并实现每张表的列宽不同

源码地址:https://github.com/cachecats/excel-export-demo

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

一、技术选型

xlsx

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

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

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

ExcelJS

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

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

安装:

npm install exceljs

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

npm install file-saver

二、基本概念

先了解下基本概念,更详细的介绍参考官方文档:https://github.com/exceljs/exceljs/blob/HEAD/README_zh.md

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 写个简单的表格页面,并设置不同的列宽:

前端复杂表格导出excel,一键导出 Antd Table 看这篇就够了(附源码)_第1张图片

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

前端复杂表格导出excel,一键导出 Antd Table 看这篇就够了(附源码)_第2张图片

可以看到,导出的 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 = () => {

  const [list, setList] = useState([]);

  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 = [
    {
      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 (
    
      

简单表格

); } 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
  • font
  • alignment
  • border
  • fill

添加背景色

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

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

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

前端复杂表格导出excel,一键导出 Antd Table 看这篇就够了(附源码)_第3张图片

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

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

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

前端复杂表格导出excel,一键导出 Antd Table 看这篇就够了(附源码)_第4张图片

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

修改字体样式

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

字体属性 描述 示例值
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'},
  };
})

前端复杂表格导出excel,一键导出 Antd Table 看这篇就够了(附源码)_第5张图片

设置对齐方式

有效的对齐属性:

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


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

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

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

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

前端复杂表格导出excel,一键导出 Antd Table 看这篇就够了(附源码)_第6张图片

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

完整的导出带样式的 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');
  }

五、行合并&列合并

先看在线表格的效果:

前端复杂表格导出excel,一键导出 Antd Table 看这篇就够了(附源码)_第7张图片

导出的 excel:

前端复杂表格导出excel,一键导出 Antd Table 看这篇就够了(附源码)_第8张图片

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

涉及到以下几个重难点:

  • 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 = [
  {
    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 = () => {

  const [list, setList] = useState([]);

  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 (
    
      

多表头表格

); } 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;
  })
}

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

前端复杂表格导出excel,一键导出 Antd Table 看这篇就够了(附源码)_第9张图片

上一节简单表格中我们用 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);
    }
  }
});

这个例子有两级表头,所以需要两行来设置每一级表头,分别命名为 names1names2,它们里面存的是展示出来的 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 还支持导出 csv格式。此外还有设置页眉页脚、操作视图、添加公式、使用富文本等功能,非常的强大。

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

源码地址:https://github.com/cachecats/excel-export-demo

你可能感兴趣的:(前端,前端,react.js,antd,excel)