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

技术标签: 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

智能推荐

用python处理csv格式文件_python 读取csv 科学计数法数据类型-程序员宅基地

用python处理csv格式文件  在各种平台上获取数据时,我们常常获得的是csv格式的文件。csv格式是一种逗号分隔值的文件格式,它并不是非常reader-friendly。所幸,python标准库中的csv模块可以帮助我们轻松处理csv格式文件。下面将以分析我国2010-2019年gdp为例简单介绍用python处理csv格式文件的过程。数据准备本例中使用的csv格式数据可以在国家统计局官网上获得,有兴趣的小伙伴可以下载下来,实战一下。数据源分析csv文件头为了在python中处理cs_python 读取csv 科学计数法数据类型

IIS 8 开启 GZIP压缩来减少网络请求的消耗-程序员宅基地

什么是gzipgzip是GUNzip的缩写,最早应用于unix系统的文件压缩。HTTP协议上的gzip编码是一种用来改进web应用程序性能的技术,web服务器端和客户端必须同时支持gzip。目前主流的浏览器都支持该协议,常见的服务器Apache,Nginx,IIS也都支持gzip。 gzip通过对文件中相似的部分进行替换压缩,压缩比率通常在3到10倍左右,可以大大的减少服务器的带宽。在实际应...

从UCSC获得gtf文件-程序员宅基地

How to get tRNA/rRNA/mitochondrial gene GTF fileCufflinks/Tophat ask for a GTF file to mask the abundant transcripts (e.g. tRNA/rRNA/chrM). Here is the step to get such a file:Go to UC

pandas数据处理操作大全_pandas 对每行 数据 进行 处理-程序员宅基地

1 创建读取数据2 查看数据3 获取指定行列数据pandas读取行列数据的所有方法https://www.cnblogs.com/wynlfd/p/14024947.html一共3种:用loc和iloc可以解决所有问题了(1) 获取指定行名和列名用loc(2) 获取指定列的所有行用df[[‘省份’, ‘总人数’]],不推荐用它df[‘省份’]) #按列名取列df.省份) # 按列名取列(3) 根据位置索引用iloc4 数据统计5 数据操作..._pandas 对每行 数据 进行 处理

【go/webrtc】使用insertable-streams给每一帧添加额外数据_webrtc insertable stream-程序员宅基地

说在前面操作系统:windows10(别问我为啥在windows下)测试浏览器:chrome86.0、edge(旧版不支持)go版本:1.14.1相关库:pion/webrtc(应该是3.0.0版本)吐槽:我又来整视频流相关的东西,这一篇是简单修改了它的一个demo准备工作go自行安装,不再赘述ffmpeg自行安装,不再赘述pion-webrtc# windowsset GO111MODULE=ongo get github.com/pion/webrtc/v3# 再找_webrtc insertable stream

centos7安装mysql教程(亲测)_centos7安装mysql教程npm-程序员宅基地

CentOS7安装最新版的MySQL(5.7)注意:下面的安装步骤是在服务器上没有安装第三方发布的rpm包情况下。第一步:添加MySQL Yum 仓库从MySQL Yum 仓库http://dev.mysql.com/downloads/repo/yum/下载适合电脑版本的rpm包。查看电脑版本uname -aEL is short for Red Hat Enterprise Lin..._centos7安装mysql教程npm

随便推点

在windows2012 上挂载NAS文件系统_windows2012安装mount命令-程序员宅基地

参考博客:http://www.mamicode.com/info-detail-2341801.html遇到的主要问题,只配置的服务端,客户端配置时没有看清楚,导致mount命令在cmd中‘'mount' 不是内部或外部命令,也不是可运行的程序或批处理文件。’,最后重新配置了一下,就好了。在此特别写封博客记录下,防止下次再遇到这种问题。 ..._windows2012安装mount命令

为什么GPL是更好的开源许可证?_mariadb 授权协议-程序员宅基地

转自 http://www.ruanyifeng.com/blog/2010/02/why_gpl_is_a_better_choice.html作者: 阮一峰日期: 2010年2月27日1.让我从一件新闻讲起。2009年,计算机业界发生了一件大事:甲骨文公司以74亿美元收购SUN公司。消息宣布后,有一个人坚决反对这笔交易。他叫Michael Widenius,是数据库软件MySQL的主要创始人。..._mariadb 授权协议

linux虚拟机添加磁盘并分区_将主分区挂载到mnt/xf2s下-程序员宅基地

首先关闭虚拟机,添加一块磁盘然后打开虚拟机,df -h 显示系统有哪些分区fdisk /dev/sdb 进入输入m查看帮助输入n添加一个新分区e(extended)扩展分区p(primary)主分区输入p建一个主分区此时显示1到4随便按一个都可以,然后回车此时输入分区的大小,这里要输入的是扇面数量还是啥的,我们不管这个,直接回车,然后就出现+size,此时输入+500M,表示给这个分区500M的空间回车w回车保存q退出使用partprobe命令重新加载分区表mkfs.xfs _将主分区挂载到mnt/xf2s下

c语言:输入字符串,仅将字符串中的字母输出,并且统计最后的字母个数。-程序员宅基地

//首先是定义一个字符数组,并且为其赋值char a[100]; gets(a); int i = 0; int count = 0; // count 作为计数器用来统计字母个数 while (a[i]!='\0') //使用while 循环将数组中的有效元素遍历一遍 { if (a[i]>='a' && a[i]

WPF 自定义BarChartControl(可左右滑动的柱状图)-程序员宅基地

原文:WPF 自定义BarChartControl(可左右滑动的柱状图)自定义可左右滑动、拖拽滑动的平面柱状图在做这种样式控件之前,可先浏览我之前预研的控件:A、自定义左右滑动ScrollViewer(可拖动滑动)B、自定义Bar柱状图OK,现在说下控件具体设计过程:1)采用Grid布局,这样可以将Y轴的标题设置平均高度,X轴的柱子也可以..._wpf左右柱状图

小波之小波变换_小波变换原理图-程序员宅基地

基本概念 小波变换(wavelet transform)是指用有限长或快速衰减的“母小波”(mother wavelet)的振荡波形来表示信号。该波形被缩放和平移以匹配输入的信号。小波变化的发展,承袭Gabor transform的局部化思想,并且克服了傅里叶和Gabor transform的部分缺陷,..._小波变换原理图