生活中的Design.

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

字数统计: 2.7k阅读时长: 13 min
2022/07/05 Share

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:

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

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

1
2
3
4
/* 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:
    1
    2
    3
    4
    [
    ["Jamie","1990-10-11","Engineer"],
    ["Tracy","1991-10-11","Designer"],
    ]
  • Array of Object:
    1
    2
    3
    4
    [
    { 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,如名字所示,可以创建新的工作薄

1
const workbook = XLSX.utils.book_new();

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

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

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

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

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

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

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

工具函数

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
/** 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
参数名称 解释 类型
data DOM any
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等:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/** 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;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/** 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文件.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
//生成工作簿
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方法:

1
2
3
4
5
6
7
8
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这种格式):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//根据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单元格名称获取行列数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 从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对象上,这样应该可以做到所见即所得的导出体验..加油吧打工人..

CATALOG
  1. 1. SheetJS – 前端(浏览器)导出Excel文件
    1. 1.1. 需求调研
    2. 1.2. SheetJS的功能
    3. 1.3. 引入/安装方式
    4. 1.4. 基础概念
    5. 1.5. 导出示例
    6. 1.6. 工具函数
    7. 1.7. xlsx-style
      1. 1.7.1. 代码解释
    8. 1.8. TODO