SheetJS -- 前端(浏览器)导出Excel文件

2,394 阅读6分钟

SheetJS -- 前端(浏览器)导出Excel文件

项目中有需求导出业务报表为Excel文件,并且需要有一定的样式和格式要求,后端导出的Excel样式设置比较困难于是只好让前端整了┓( ´∀` )┏

需求调研

前端导出Excel的库基本上大家用的都是SheetJS,我也不找其他了,直接整个SheetJS社区版开干了(SheetJS CE)

SheetJS的功能

SheetJS可以实现数据导入/导出/处理,文档上有一些实际的示例,比如将文档流中的table标签导出为Excel文件, 读取Excel文件生成table标签,将CSV文件转换为HTML表格或者Excel文件. 社区版(CE)的功能相较专业版(Pro)主要是阉割了CSS样式和富文本。

引入/安装方式

可以使用Script标签引入:

<script lang="javascript" src="https://cdn.sheetjs.com/xlsx-0.18.9/package/dist/xlsx.full.min.js"></script>

也可以使用Web Worker,这个没怎么研究,感兴趣可以看下

也可以使用ESM导入方式:

<script type="module"> import { read, writeFileXLSX } from "https://cdn.sheetjs.com/xlsx-0.18.9/package/xlsx.mjs"; </script>

如果需要XLS支持,需要手动导入cpexcel.full.mjs,这部分主要是为了导出旧版的EXCEL格式(.xls,较新的文件格式是.xlsx)

<script type="module"> /* load the codepage support library for extended support with older formats */ import { set_cptable } from "https://cdn.sheetjs.com/xlsx-0.18.9/package/xlsx.mjs"; import * as cptable from 'https://cdn.sheetjs.com/xlsx-0.18.9/package/dist/cpexcel.full.mjs'; set_cptable(cptable); </script>

当然npm库也是有的,因为我们需要额外的样式定制,所以还引入了xlsx-style:

 "xlsx": "^0.17.5",
 "xlsx-style": "^0.8.13"

使用npm库后,上面兼容XLS部分的标签换成npm库ESM导入的方式如下:

/* load the codepage support library for extended support with older formats  */
import { set_cptable } from "xlsx";
import * as cptable from 'xlsx/dist/cpexcel.full.mjs';
set_cptable(cptable);

基础概念

SheetJS工作的数据基础格式是二维数组(Array of Array) 或者对象数组 (Array of Object).

  • Array of Array:
[
  ["Jamie","1990-10-11","Engineer"],
  ["Tracy","1991-10-11","Designer"],
]
  • Array of Object:
[
  { name: "George Washington", birthday: "1732-02-22" },
  { name: "John Adams", birthday: "1735-10-19" },
]

导出示例

JSON/javascript Array -> Excel

首先我们需要创建一个Workbook,也就是Excel中的工作簿,在SheetJS中创建工作簿很简单: XLSX.utils.book_new,如名字所示,可以创建新的工作薄

const workbook = XLSX.utils.book_new();

XLSX.utils.json_to_sheet,如名字所示,可以将json转换为工作表,我们先新建一个worksheet,也就是Excel中的工作表:

const worksheet = XLSX.utils.json_to_sheet(rows);

再将工作簿放进工作表,并且将工作簿命名为Empoyees:

XLSX.utils.book_append_sheet(workbook, worksheet, "Empoyees");

导出Excel文件,并将导出Excel命名为Empoyees.xlsx:

XLSX.writeFile(workbook, "Empoyees.xlsx");

在浏览器环境下会直接提示用户下载该Excel.

工具函数

SheetJS的工具函数放在导出XLSX.utils下,包括新建工作表,工作表添加工作簿等:

拷贝了一份utils的导出类型声明:

/** General utilities */
export interface XLSX$Utils {
    /* --- Import Functions --- */

    /** Converts an array of arrays of JS data to a worksheet. */
    aoa_to_sheet<T>(data: T[][], opts?: AOA2SheetOpts): WorkSheet;
    aoa_to_sheet(data: any[][], opts?: AOA2SheetOpts): WorkSheet;

    /** Converts an array of JS objects to a worksheet. */
    json_to_sheet<T>(data: T[], opts?: JSON2SheetOpts): WorkSheet;
    json_to_sheet(data: any[], opts?: JSON2SheetOpts): WorkSheet;

    /** BROWSER ONLY! Converts a TABLE DOM element to a worksheet. */
    table_to_sheet(data: any,  opts?: Table2SheetOpts): WorkSheet;
    table_to_book(data: any,  opts?: Table2SheetOpts): WorkBook;
    sheet_add_dom(ws: WorkSheet, data: any, opts?: Table2SheetOpts): WorkSheet;

    /* --- Export Functions --- */

    /** Converts a worksheet object to an array of JSON objects */
    sheet_to_json<T>(worksheet: WorkSheet, opts?: Sheet2JSONOpts): T[];
    sheet_to_json(worksheet: WorkSheet, opts?: Sheet2JSONOpts): any[][];
    sheet_to_json(worksheet: WorkSheet, opts?: Sheet2JSONOpts): any[];

    /** Generates delimiter-separated-values output */
    sheet_to_csv(worksheet: WorkSheet, options?: Sheet2CSVOpts): string;

    /** Generates UTF16 Formatted Text */
    sheet_to_txt(worksheet: WorkSheet, options?: Sheet2CSVOpts): string;

    /** Generates HTML */
    sheet_to_html(worksheet: WorkSheet, options?: Sheet2HTMLOpts): string;

    /** Generates a list of the formulae (with value fallbacks) */
    sheet_to_formulae(worksheet: WorkSheet): string[];

    /** Generates DIF */
    sheet_to_dif(worksheet: WorkSheet, options?: Sheet2HTMLOpts): string;

    /** Generates SYLK (Symbolic Link) */
    sheet_to_slk(worksheet: WorkSheet, options?: Sheet2HTMLOpts): string;

    /** Generates ETH */
    sheet_to_eth(worksheet: WorkSheet, options?: Sheet2HTMLOpts): string;

    /* --- Cell Address Utilities --- */

    /** Converts 0-indexed cell address to A1 form */
    encode_cell(cell: CellAddress): string;

    /** Converts 0-indexed row to A1 form */
    encode_row(row: number): string;

    /** Converts 0-indexed column to A1 form */
    encode_col(col: number): string;

    /** Converts 0-indexed range to A1 form */
    encode_range(s: CellAddress, e: CellAddress): string;
    encode_range(r: Range): string;

    /** Converts A1 cell address to 0-indexed form */
    decode_cell(address: string): CellAddress;

    /** Converts A1 row to 0-indexed form */
    decode_row(row: string): number;

    /** Converts A1 column to 0-indexed form */
    decode_col(col: string): number;

    /** Converts A1 range to 0-indexed form */
    decode_range(range: string): Range;

    /** Format cell */
    format_cell(cell: CellObject, v?: any, opts?: any): string;

    /* --- General Utilities --- */

    /** Creates a new workbook */
    book_new(): WorkBook;

    /** Append a worksheet to a workbook */
    book_append_sheet(workbook: WorkBook, worksheet: WorkSheet, name?: string): void;

    /** Set sheet visibility (visible/hidden/very hidden) */
    book_set_sheet_visibility(workbook: WorkBook, sheet: number|string, visibility: number): void;

    /** Set number format for a cell */
    cell_set_number_format(cell: CellObject, fmt: string|number): CellObject;

    /** Set hyperlink for a cell */
    cell_set_hyperlink(cell: CellObject, target: string, tooltip?: string): CellObject;

    /** Set internal link for a cell */
    cell_set_internal_link(cell: CellObject, target: string, tooltip?: string): CellObject;

    /** Add comment to a cell */
    cell_add_comment(cell: CellObject, text: string, author?: string): void;

    /** Assign an Array Formula to a range */
    sheet_set_array_formula(ws: WorkSheet, range: Range|string, formula: string): WorkSheet;

    /** Add an array of arrays of JS data to a worksheet */
    sheet_add_aoa<T>(ws: WorkSheet, data: T[][], opts?: SheetAOAOpts): WorkSheet;
    sheet_add_aoa(ws: WorkSheet, data: any[][], opts?: SheetAOAOpts): WorkSheet;

    /** Add an array of JS objects to a worksheet */
    sheet_add_json(ws: WorkSheet, data: any[], opts?: SheetJSONOpts): WorkSheet;
    sheet_add_json<T>(ws: WorkSheet, data: T[], opts?: SheetJSONOpts): WorkSheet;


    consts: XLSX$Consts;
}

我们应该会用到的几个API:

  1. book_new: 新建工作表,无参数返回WorkBook对象
参数名称解释类型
无参数
返回值工作表对象WorkBook
  1. book_append_sheet: 向工作表追加工作簿
参数名称解释类型
workbook工作表WorkBook
worksheet工作簿WorkSheet
name工作簿名称String
返回值void
  1. table_to_sheet: HTML文档流table转换为Sheet
参数名称解释类型
dataDOMany
opts?配置选项Table2SheetOpts
返回值工作簿WorkSheet

xlsx-style

xlsx-style其实是从SheetJS项目中fork出来的,能够提供单元格格式的定制功能,基本API和xlsx差不多. 支持的读取格式:

  • Excel 2007+ XML Formats (XLSX/XLSM)
  • Excel 2007+ Binary Format (XLSB)
  • Excel 2003-2004 XML Format (XML "SpreadsheetML")
  • Excel 97-2004 (XLS BIFF8)
  • Excel 5.0/95 (XLS BIFF5)
  • OpenDocument Spreadsheet (ODS)

支持的写入格式

  • XLSX
  • CSV (and general DSV)
  • JSON and JS objects (various styles)

xlsx-style工作的基础就是WorkSheet对象上单元格对应的对象CellObject,可以看到CellObject含有一个s字段,代表单元格的style,但是仅仅支持少量的属性,例如border,font,fgColor等:

/** Worksheet Object */
export interface WorkSheet extends Sheet {
    /**
     * Indexing with a cell address string maps to a cell object
     * Special keys start with '!'
     */
    [cell: string]: CellObject | WSKeys | any;

    /** Column Info */
    '!cols'?: ColInfo[];

    /** Row Info */
    '!rows'?: RowInfo[];

    /** Merge Ranges */
    '!merges'?: Range[];

    /** Worksheet Protection info */
    '!protect'?: ProtectInfo;

    /** AutoFilter info */
    '!autofilter'?: AutoFilterInfo;
}
/** Worksheet Cell Object */
export interface CellObject {
    /** The raw value of the cell.  Can be omitted if a formula is specified */
    v?: string | number | boolean | Date;

    /** Formatted text (if applicable) */
    w?: string;

    /**
     * The Excel Data Type of the cell.
     * b Boolean, n Number, e Error, s String, d Date, z Empty
     */
    t: ExcelDataType;

    /** Cell formula (if applicable) */
    f?: string;

    /** Range of enclosing array if formula is array formula (if applicable) */
    F?: string;

    /** Rich text encoding (if applicable) */
    r?: any;

    /** HTML rendering of the rich text (if applicable) */
    h?: string;

    /** Comments associated with the cell */
    c?: Comments;

    /** Number format string associated with the cell (if requested) */
    z?: NumberFormat;

    /** Cell hyperlink object (.Target holds link, .tooltip is tooltip) */
    l?: Hyperlink;

    /** The style/theme of the cell (if applicable) */
    s?: any;
}

我们的基本思路就是先用xlsx同table表格生成一个WorkBook对象,然后对WorkBook的每个CellObject设置显示样式,再使用xlsx-style的writeAPI将WorkBook对象写入为二进制流(binary),再将该流转换为Blob,通过file-saver导出Excel文件.

  //生成工作簿
  const workSheet = XLSX.utils.table_to_sheet(dom, { raw: true });
  //每个单元格加边框,列序数从0开始,行序数从1开始
  for (let columnIndex = 0; columnIndex < getTotalColumns(headers); columnIndex++) {
    for (let rowIndex = 1; rowIndex <= items.length + headerRows; rowIndex++) {
      const key = `${getColumnKeyFromIndex(columnIndex)}${rowIndex}`;
      // console.log(columnIndex, rowIndex, key);
      if (!workSheet[key]) {
        workSheet[key] = {
          t: "z", // b Boolean, n Number, e error, s String, d Date, z Stub
          v: "",  // cell原始值 string | number | boolean | Date
          s: {
            //边框
            border: {
              top: { style: "thin", color: { auto: 1 } },
              left: { style: "thin", color: { auto: 1 } },
              bottom: { style: "thin", color: { auto: 1 } },
              right: { style: "thin", color: { auto: 1 } },
            },
          },
        };
      }
    }
  }
  //其他样式,略
  //通用样式
  Object.keys(workSheet).forEach((key) => {
    if (!key.startsWith("!")) {
      try {
        const [column, row] = getExcelColumnRowFromKey(key);

        const rowIndex = row - headerRows - 1;
        const columnIndex = column - 1;
        const rowItem = rowIndex >= 0 ? props.items[rowIndex] : {};
        //excel的列号是从1开始的,需要减一
        const columnItem = columnIndex >= 0 ? getTreeLeafs(headers)[columnIndex] : {};
        const styleObject = getCellStyle({
          row: rowItem,
          column: columnItem,
          rowIndex: rowIndex,
          columnIndex: columnIndex,
        });
        const bgColorHex = colorToHex(styleObject.backgroundColor).replace("#", "");
        const colorHex = "";
        workSheet[key].s = {
          font: {
            sz: 7, //7号字体
            bold: row === headerRows, //第三行加粗
            color: {
              rgb: colorHex ?? "FF333333", //字体颜色
            },
          },
          fill: {
            fgColor: {
              rgb: bgColorHex,
            },
          },
          alignment: {
            horizontal: "center", //水平居中对其
            vertical: "center", //垂直居中对其
          },
          //border 对合并的单元格不起作用
          border: {
            right: { style: "thin", color: { auto: 1 } },
            bottom: { style: "thin", color: { auto: 1 } },
            left: { style: "thin", color: { auto: 1 } },
            top: { style: "thin", color: { auto: 1 } },
          },
        };
      } catch (error) {
        console.log(error);
      }
    }
  });
  //设置列宽
  workSheet["!cols"] = filteredHeaders
    .flatMap((it) => (it.children ? [it, ...it.children] : [it]))
    .map((head) => ({
      wpx: head.minWidth,
  }));
  //...
  //工作表
  const workBook = XLSX.utils.book_new();
  XLSX.utils.book_append_sheet(workBook, workSheet, sheetName);

  //生成二进制对象
  const WithStyle = XlsxStyle.write(workBook, {
    bookType: "xlsx",
    bookSST: false,
    type: "binary",
  });
  //导出 file-saver.js 的 saveAs
  saveAs(
    new Blob([sheetAsArrayBuffer(WithStyle)], {
      type: "application/octet-stream",
    }),
    `${fileName}.xlsx`
  );

用到的二进制转ArrayBuffer方法:

function sheetAsArrayBuffer(s) {
  const buf = new ArrayBuffer(s.length);
  const view = new Uint8Array(buf);
  for (let i = 0; i != s.length; ++i) {
    view[i] = s.charCodeAt(i) & 0xff;
  }
  return buf;
}

列序数转Excel列名(A,B, ... AA, AB这种格式):

//根据index获取excel列索引 例如 0->A, 1->B, 26->AA, 27->AB
export function getColumnKeyFromIndex(index) {
  //根据index获取excel列索引
  let quotient = index + 1;
  let reminder = 0;
  let column = ""

  do {
    reminder = quotient % 26;
    //console.log(`reminder = ${reminder}`)
    quotient = Math.floor(quotient / 26);
    //console.log(`quotient = ${quotient}`)
    if (reminder === 0) {
      reminder = 26
      quotient -= 1
    }
    column = numberToAlphabet(reminder) + column;
  } while (quotient !== 0)

  return column;
}

从Excel单元格名称获取行列数:

/**
 * 从A1格式的字符串获取行列数[1,1](从1开始的)
 * @param {String} key 
 */
export function getExcelColumnRowFromKey(key) {
  //从Excel单元格分成列和行
  const column = key.replace(/[0-9]/g, "");
  const excelRow = key.replace(/[A-Z]/g, "");
  //从Excel列名转换成数字
  const excelColumn = column.split("").reverse().reduce((sum, item, index) => {
    const value = item.replace(/[A-Z]/g, (match) => {
      return match.charCodeAt(0) - 65 + 1;
    })
    sum += value * Math.pow(26, index);
    return sum
  }, 0)
  return [parseInt(excelColumn), parseInt(excelRow)];
}

代码解释

code1.jpg

这部分代码通过列号,行号生成Excel单元格名称(A1,B1这样的),先写入通用的格式

code2.jpg

这部分代码对已存在的CellObject写入定制的样式,主要是从业务中获取单元格的样式数据(当前只有背景色),然后设置

TODO

现在的方式要同时维护table的样式和导出的样式,而且需要保持两者一致,还是有一点心智负担的,后面考虑通过table每个cell的computedStyle获取可以映射的样式,设置到导出的对应CellObject.s对象上,这样应该可以做到所见即所得的导出体验..加油吧打工人..

原文链接:SheetJS -- 前端(浏览器)导出Excel文件 · Apm29 Studio)