• xlsx库实现纯前端导入导出Excel


    前言

    最近做了前端导入、导出 Excel 的需求,用到了js-xlsx这个库,该库文档提供的用例很少,并不是很友好。本文总结一下我是如何实现需求的。

    需求

    • 提供一个 Excel 文件,将里面的内容转成 JSON 导入数据
    • 提供一个 JSON 文件,生成 Excel 文件并导出

    导入与导出既可以前端做,也可以后端做。本文主要探讨前端通过SheetJS/js-xlsx这个库实现 Excel 导入、导出功能。

    技术选型

    市面上的报表类产品大抵可以分为以下两种:

    1. 云文档类型产品
    2. 控件类型产品

    像 SheetJS/js-xlsx、LuckySheet、Handsontable、SpreadJS 都是标准的纯前端表格控件且都支持 Excel 的功能特性和 JSON 数据绑定。

    最后选择 SheetJS/js-xlsx 这个库主要因为以下两个原因:

    1. 社区版开源免费。也可选择性能增强的专业版,专业版提供样式和专业支持的附加功能。
    2. 有 30k star,维护频率高,笔者在写这篇文章时(5 月 10 日)该项目的上一次提交在 5 月 9 日。

    基础知识

    新建一个 Excel 文档,这个文档就是workbook,而一个workbook 下可以有多个sheet

    SheetJS/js-xlsx

    安装

    1. $ yarn add xlsx@0.16.9
    2. 复制代码

    建议跟上版本号,我第一次装的时候没跟上版本号没有安装成功。

    常用的数据表格式(Common Spreadsheet Format)

    js-xlsx符合常用的数据表格式(CSF)。

    一般结构

    单元格地址对象的存储格式为{c:C, r:R},其中CR分别代表的是 0 索引列和行号。例如单元格地址B5用对象{c:1, r:4}表示。

    单元格范围对象存储格式为{s:S, e:E},其中S是第一个单元格,E是最后一个单元格。范围是包含关系。例如范围 A3:B7用对象{s:{c:0, r:2}, e:{c:1, r:6}}表示。

    单元格对象

    单元格对象是纯粹的 JS 对象,它的 keys 和 values 遵循下列的约定:

    KeyDescription
    v原始值(查看数据类型部分获取更多的信息)
    w格式化文本(如果可以使用)
    t内行: b Boolean, e Error, n Number, d Date, s Text, z Stub
    f单元格公式编码为 A1 样式的字符串(如果可以使用)
    F如果公式是数组公式,则包围数组的范围(如果可以使用)
    r富文本编码 (如果可以使用)
    h富文本渲染成 HTML (如果可以使用)
    c与单元格关联的注释
    z与单元格关联的数字格式字符串(如果有必要)
    l单元格的超链接对象 (.Target 长联接, .Tooltip 是提示消息)
    s单元格的样式/主题 (如果可以使用)

    如果w文本可以使用,内置的导出工具(比如 CSV 导出方法)就会使用它。要想改变单元格的值,在打算导出之前确保删除cell.w(或者设置 cell.wundefined)。工具函数会根据数字格式(cell.z)和原始值(如果可用)重新生成w文本。

    真实的数组公式存储在数组范围中第一个单元个的f字段内。此范围内的其他单元格会省略f字段。

    更多详细信息请查看文档

    前端导入 Excel 数据

    1. /**
    2. * 将 file 转为一个 CSF 的 JSON
    3. * @param {File} file
    4. * @returns sheet
    5. */
    6. const analyseExcelToJson = (file) => {
    7.  return new Promise((resolve, reject) => {
    8.    if (file instanceof File) {
    9.      const reader = new FileReader();
    10.      reader.onloadend = (progressEvent) => {
    11.        const arrayBuffer = reader.result;
    12.        const options = { type: 'array' };
    13.        const workbook = XLSX.read(arrayBuffer, options);
    14.        const sheetName = workbook.SheetNames;
    15.        const sheet = workbook.Sheets[sheetName];
    16.        resolve(sheet);
    17.     };
    18.      reader.readAsArrayBuffer(file);
    19.   } else {
    20.      reject(new Error('入参不是 File 类型'));
    21.   }
    22. });
    23. };
    24. 复制代码

    这里先用FileReaderfile转换成ArrayBuffer,再用xlsx.read()转换成workbook。由于FileReader是异步读取,所以用promise处理了一下。最终可以看到 Excel 处理后生成了这样的一

    所以需要对analyseExcelToJson这个方法做一些修改,修改后如下:

    1. /**
    2. * 将 file 转为一个 CSF 的 JSON
    3. * @param {File} file
    4. * @returns sheets
    5. */
    6. const analyseExcelToJson = (file) => {
    7.  return new Promise((resolve, reject) => {
    8.    if (file instanceof File) {
    9.      const reader = new FileReader();
    10.      reader.onloadend = (progressEvent) => {
    11.        const arrayBuffer = reader.result;
    12.        const options = { type: 'array' };
    13.        const workbook = XLSX.read(arrayBuffer, options);
    14.        const sheetNames = workbook.SheetNames;
    15.        const result = sheetNames.map((sheetName) => workbook.Sheets[sheetName]);
    16.        resolve(result);
    17.     };
    18.      reader.readAsArrayBuffer(file);
    19.   } else {
    20.      reject(new Error('入参不是 File 类型'));
    21.   }
    22. });
    23. };
    24. 复制代码

    读取数据按钮方法如下:

    由于我用了Promise.all用来处理读取多个 Excel,所以看到外面又用数组包了一层。至此,简单的前端导入 Excel 数据已经全部实现了。

    顺带一提,如果想要在页面中展示sheet,可以使用XLSX.utils.sheet_to_html

    前端导出 Excel 文件

    导出一般分为两种:

    1. 数据导出 Excel
    2. 页面表格导出 Excel

    数据导出 Excel

    前端在写前端导入 Excel 数据方法,最后返回的其实是workbooksheet的集合。那么导出 Excel 文件便是将sheet拼成一个workbook导出即可。另外,导出的难点在于写成 Excel 之后要立马下载,而XLSX.writeFile直接帮我们实现这一步了。

    1. /**
    2. *
    3. * @param {Array} sheets sheet的集合
    4. * @param {String} fileName 下载时文件名称
    5. */
    6. const exportExcelBySheets = (sheets, fileName = 'example.xlsx') => {
    7.  const SheetNames = [];
    8.  const Sheets = {};
    9.  const workbook = { SheetNames, Sheets };
    10.  sheets.forEach((sheet, i) => {
    11.    const name = `sheet${i + 1}`;
    12.    SheetNames.push(name);
    13.    Sheets[name] = sheet;
    14. });
    15.  return XLSX.writeFile(workbook, fileName, { type: 'binary' });
    16. };
    17. 复制代码

    假设数据并非CSF而是如下的二维数组:

    1. const ddArray = [
    2. ['S', 'h', 'e', 'e', 't', 'J', 'S'],
    3. [1, 2, 3, 4, 5],
    4. ];
    5. 复制代码

    可以使用方法如下:

    1. /**
    2. *
    3. * @param {Array} workSheetData 二维数组
    4. * @param {String} fileName 下载时文件名称
    5. */
    6. const exportExcelByDoubleDimensArray = (workSheetData, fileName = 'example.xlsx') => {
    7.  const ws = XLSX.utils.aoa_to_sheet(workSheetData);
    8.  const workSheetName = 'MySheet';
    9.  const workbook = XLSX.utils.book_new();
    10.  XLSX.utils.book_append_sheet(workbook, ws, workSheetName);
    11.  return XLSX.writeFile(workbook, fileName, { type: 'binary' });
    12. };
    13. 复制代码

    页面表格导出 Excel

    将页面中的表格导出 Excel,应该是更加常见的情况。我们增加一个 Element-ui 的基础表格如下:

    导出方法如下:

    1. /**
    2. * 将 table 转换成 Excel 导出
    3. * @param {*} el table 的根 dom 元素
    4. * @param {*} fileName 下载时文件名称
    5. */
    6. const exportExcelByTable = (el, fileName = 'example.xlsx') => {
    7.  if (!el) {
    8.    throw new Error('没有获取到表格的根 dom 元素');
    9. }
    10.  const options = { raw: true };
    11.  const workbook = XLSX.utils.table_to_book(el, options);
    12.  return XLSX.writeFile(workbook, fileName, { type: 'binary' });
    13. };
    14. 复制代码

    页面中使用的话,通过ref拿到组件实例,将$elVue 实例的根 DOM 元素作为入参即可。

    1. exportExcelByTable(this.$refs.table.$el);
    2. 复制代码

    踩坑

    只用简单表格作为示例的话,似乎一切都很完美。然而,我在使用Element-ui table做复杂表格时,踩了一些坑。

    1. 当且不仅当表的内容为inputselect这类组件而非普通的数据时,导出的 Excel 内容为空
    2. 将表头合并后,导出 Excel 仍能看到被合并的表头那一列。
    3. 使用fixed属性固定列时,导出的 Excel 数据会重复。

    由于XLSX.utils.table_to_book这个方法实际上是将dom元素转化为workbook,这些坑都可以归类为获取到的 dom元素不对。

    表头合并

    为了更好理解,我先讲表头合并的问题。由于Element-ui table并没有提供表头合并的方法,我实际是通过修改rowspancolspan来实现跨行跨列,再使用display: none;这个css属性将原先位置的元素隐藏。如下图所示:

    图中“ID”的colspan为 2,“姓名”被我设置了display: none;。如果直接用我们之前表格导出 Excel 的方法,会发现虽然导出"ID"正确地变为了两列,但是“姓名”列并没有隐藏。由此可以得出结论:display: none;并不会影响 Excel 的获取。

    所以我在项目中对于被隐藏的表头会添加cell-hide这个css类来隐藏被合并的表头。

    1. .cell-hide {
    2. display: none;
    3. }
    4. 复制代码

    然后在下载报表前,将合并的表头dom删除。

    1. document.querySelectorAll('.cell-hide').forEach((item) => {
    2. item.parentNode.removeChild(item);
    3. });
    4. // 下面就可以正常下载了
    5. 复制代码

    内容为组件

    同样利用**display: none;并不会影响 Excel 的获取**的特性可以解决问题 1,只需在table-column中通过插槽增加被隐藏的dom,就可以正常拿到值了。代码如下:

    1. <el-table ref="table" :data="tableData" style="width: 600px; margin: 0 auto">
    2. <el-table-column prop="date" label="日期" width="180"> </el-table-column>
    3. <el-table-column prop="name" label="姓名" width="180"> </el-table-column>
    4. <el-table-column prop="address" label="地址">
    5. <template slot="header">
    6. <span>地址</span>
    7. </template>
    8. <template slot-scope>
    9. <el-input :value="123" />
    10. <span style="display: none">123</span>
    11. </template>
    12. </el-table-column>
    13. </el-table>
    14. 复制代码

    使用fixed属性固定表格列

    先来看下,如果完全不处理,直接使用导出会是什么结果。以下面的 table2 为例,“日期”列被固定,导出的 excel 内容重复。

    1. exportExcelByTable(this.$refs.table2.$el);
    2. 复制代码

    原因还是出在dom上,打印出 table 和 table2 的dom比较发现,table2 多了css类为el-table__fixed的这个节点。

    我的处理方法是先克隆节点,确保后续操作不会影响页面中的 table2。通过遍历克隆出的新节点,找到.el-table__fixed这个节点并删除,最后返回新节点,发现可以输出正常的 Excel 文件。具体代码如下:

    1. exportExcelByTable2() {
    2. const newEl = this.removeFixedDom(this.$refs.table2.$el);
    3. exportExcelByTable(newEl);
    4. }
    5. removeFixedDom(el) {
    6. const newEl = el.cloneNode(true);
    7. newEl.childNodes.forEach((node) => {
    8. if (node.className === 'el-table__fixed') {
    9. node.parentNode.removeChild(node);
    10. }
    11. });
    12. return newEl;
    13. }
    14. 复制代码

    总结

    js-xlsx这个库功能很强大且使用简单,足以应付一般的导出导出需求,如果有美化导出 Excel 样式的需求需要选择 pro 版本。开发的难度主要在于阅读提供用例不足且冗长的文档。使用时注意维护好WorkbookSheet对象即可,LuckySheet、SpreadJS 也是类似的思路。 

  • 相关阅读:
    JDK1.8新特性
    【C++练级之路】【Lv.25】智能指针
    git clone单个文件/文件夹、wget下载单文件
    springboot配置swagger
    ol(openlayers)使用记录
    Hexagon_V65_Programmers_Reference_Manual(17)
    软考 系统架构设计师系列知识点之软件质量属性(3)
    浅谈双指针技巧(二)---通过双指针判断链表成环问题
    将DataFrame中符合指定条件的数据替换为指定的值:mask()函数
    Redis系列:Geo 类型赋能亿级地图位置计算
  • 原文地址:https://blog.csdn.net/BASK2312/article/details/127979045