前端复杂表格一键导出看这篇就够了(附源码)_前端瓶子君的博客-程序员宅基地

技术标签: python  webgl  html  poi  javascript  

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

402e1b12abdae9791554f4bf64088d18.png

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

b30b991ec1a40b4d7312a596f134480c.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为整行设置背景色,这样的话这一行没有内容的单元格也会有颜色,如图:

6626cb9a72143a161d63432a5382bc2d.png

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

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

// 通过 cell 设置背景色,更精准
headerRow.eachCell((cell, colNum) => {
  cell.fill = {
    type: 'pattern',
    pattern: 'solid',
    fgColor: {argb: 'dff8ff'},
  }
})
复制代码
febe0472d39a5b33ccc8a59ad5a9fba1.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'},
  };
})
复制代码
941a768058c4ee345d4f11de95d78fba.png

设置对齐方式

有效的对齐属性:

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 进行设置,效果是一样的。

6f066df766de2b992c70376ed68aebb5.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');
  }
复制代码

五、行合并&列合并

先看在线表格的效果:

1e46cbe83619d4dfa26cd3f0e073792e.png

导出的 excel:

5e2ab5138b5e023baf58553fe6a681de.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;
  })
}
复制代码

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

d5333ea2783c49361f102641d48caadd.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

最后

欢迎关注【前端瓶子君】✿✿ヽ(°▽°)ノ✿

回复「算法」,加入前端编程源码算法群,每日一道面试题(工作日),第二天瓶子君都会很认真的解答哟!

回复「交流」,吹吹水、聊聊技术、吐吐槽!

回复「阅读」,每日刷刷高质量好文!

如果这篇文章对你有帮助,「在看」是最大的支持

 》》面试官也在看的算法资料《《

“在看和转发”就是最大的支持

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/lunahaijiao/article/details/125056921

智能推荐

从0到1的CTF之旅————Web(1)_ctf web1_周周的奇妙编程的博客-程序员宅基地

今天学习了一下web感觉受益匪浅,小做几道题练练手,纯新手勿喷————————————————————————————————————————————第一题:[极客大挑战 2019]EasySQL首先思考的便是最简单的登录绕过。在逻辑上面,当点击登录按钮后会取你输入框的值与数据库中进行对比。登录绕过是最基础的,就是要将这句SQL语句给屏蔽掉,进而让此语句的执行结果始终为真。输入下面万能密码当执行此语句时 ,就会变成`此时的 1=1 恒为真,在 或 状态下有一个为真则始终为真,此时可绕过登录。得到f_ctf web1

Linux协议栈-netfilter(1)-框架_nf_inet_post_routing_me败家懒妞的博客-程序员宅基地

文章转载----------------------点击打开链接1. netfilter框架Netfilter 是Linux内核中进行数据包过滤、连接跟踪、地址转换等的主要实现框架。当我们希望过滤特定的数据包或者需要修改数据包的内容再发送出去,这些动作主要都在netfilter中完成。iptables工具就是用户空间和内核的Netfilter模块通信的手段,iptables命令提供很多选项来实现过..._nf_inet_post_routing

C标准库(The Standard C Library) PDF_weixin_30586085的博客-程序员宅基地

下载地址:网盘下载本书是由世界级c语言专家编写的c标准库经典著作。英文版已经重印十多次,影响了几代程序员。.本书结合c标准的 相关部分,精辟地讲述了每一个库函数的使用方法和实现细节,而这正是一个真正的c程序员所必须掌握的。更重要的是,书中给出了实现和测试这些函数的完整源 代码,可以让你更深入地学习c语言。不仅如此,本书还讨论了一些即使是最有经验的c程序员通常也..._the c standard library pdf

实用算法实现-第 25 篇 最大公约数_两个质数长度相等是什么意思_存储李希的博客-程序员宅基地

最大公约数有经典算法Euclid算法可以求出。而对于正整数a、b,若将它们的最大公约数表示为gcd(a, b),最小公倍数表示为lcm(a, b),那么有公式:a • b = gcd(a, b) • lcm(a, b)。由这个公式,可以求得两个数的最小公倍数。25.1 Euclid算法int Euclid(int a, intb){/*欧几里德算法GCD递归定理:对任意_两个质数长度相等是什么意思

截屏录屏和屏幕颜色抓取_屏幕取色器fixbox加录屏_LGC.绑兔的博客-程序员宅基地

分享截屏录屏和屏幕颜色抓取两个工具这两个工具都是我常用的,觉得还不错下面分享链接以及使用方法截屏录屏取色工具链接: https://pan.baidu.com/s/1GA75-5oX4yuujUr5xvrCGQ 提取码是yzch截屏录屏功能图片:下载解压,找到FSCapture.exe应用程序双击运行就行了,不需要安装。这是一个悬浮工具,包含 多样截屏、录制、屏幕取色等屏幕取色是最..._屏幕取色器fixbox加录屏

使用CANoe的IL层构建总线仿真系统+CAPL学习笔记_weixin_42438100的博客-程序员宅基地

以下内容为自己学习笔记,若看不懂,可以去b站看视频,链接如下。https://www.bilibili.com/video/BV1n7411n7Tm?t=2188其中包括CANdb++创建DBC数据库文件,PANEL设计控制面板,一些简单的CAPL语言介绍。使用软件CANoe10.0,其他版本可能会有点不一样,仅供参考。CANdb++创建DBC数据库文件1、在Tools下打开CANdb++界面。2、创建一个数据库文件创建好后,会弹出下图,选择Vector_IL_……选项。,根据提示保存(我

随便推点

jdk install, Ubuntu_sjyhehe的博客-程序员宅基地

1、到 Oracle 的官网下载http://www.oracle.com/technetwork/java/javase/downloads/jdk7-downloads-1880260.html  选择 accept license ,然后选择适合自己机型的JDK下载。  2、解压文件,修改文件名$ sudo mkdir /usr/lib/jvm$ sudo ta

在python类的析构函数__del__怎么用?_def __del__(self):这个怎么运行_九点澡堂子的博客-程序员宅基地

背景如下,想在析构函数__del__里面做一些退出前的操作:第一版代码如下: def __del__(self): try: if self.monitor_info['IterSpeed'] > 0 and self.monitor_info[ 'FullTime'] > 0: benchmark_inserter = insertdata.DataInseter()#写入数_def __del__(self):这个怎么运行

nyoj-26孪生素数问题_编写程序求出相邻的20对孪生素数_liwei_av的博客-程序员宅基地

孪生素数问题时间限制:3000 ms | 内存限制:65535 KB 难度:3描述 写一个程序,找出给出素数范围内的所有孪生素数的组数。一般来说,孪生素数就是指两个素数距离为2,近的不能再近的相邻素数。有些童鞋一看到题就开始写程序,不仔细看题,咱们为了遏制一下读题不认真仔细的童鞋,规定,两个素数相邻为1的也成为孪生素数。 输入第一行给出N(0#include&amp;amp;lt;iostr..._编写程序求出相邻的20对孪生素数

13-事务&数据库连接池&DBUtiles_diaozongjian9397的博客-程序员宅基地

事务&数据库连接池&DBUtils事务Transaction其实指的一组操作,里面包含许多个单一的逻辑。只要有一个逻辑没有执行成功,那么都算失败。 所有的数据都回归到最初的状态(回滚)为什么要有事务?为了确保逻辑的成功。 例子: 银行的转账。使用命令行方式演示事务。开启事务start transaction;提交或者..._利用命令行窗口分别开启两个事务会话,并分别开启新的事务

荣耀8 android8.0 2018,荣耀8青春版可升级Android 8.0+EMUI 8.0-程序员宅基地

iOS 14发布后,关于桌面小部件在内的诸多功能系借鉴安卓的声音不绝于耳,华为用户似乎也是不吐不快。名为ByYash Mishra的用户撰文指出,iOS 14和Android 11同时“抄袭”了华为EMUI华为EMUI今天官方宣布,EMUI 10. 1 系统已经在Mate30 系列上完成全网推送,同时公布了EMUI 10. 1 的最新适配进度,覆盖华为、荣耀品牌的多达 36 款款不同机型。5月底,..._荣耀8青春版能升级安卓8

java的前端还是后端_java语言是开发前端还是后端的_weixin_39662594的博客-程序员宅基地

java语言是开发前端还是后端的发布时间:2020-06-26 16:01:18来源:亿速云阅读:105作者:Leahjava语言是开发前端还是后端的?很多新手对此不是很清楚,为了帮助大家解决这个难题,下面小编将为大家详细讲解,有这方面需求的人可以来学习下,希望你能有所收获。java不是前端,是后端。Java语言是最常见的后端开发语言之一,Java语言由于自身具备构建多线程的能力,且体系结构比较中..._java是前端还是后端

推荐文章

热门文章

相关标签