和后端大战三百回合后,卑微前端还是选择了自己写excel导出
前言
对于一个sass项目,或者一个中后台项目来说,导出excel表格
应该是家常便饭,本来最简单的实现方式是后端去做表格,前端请求接口拿到一个地址下载就行了。但是,因为我们这个项目之前就是前端做表格,加上这个表格相对比较复杂需要合并行和列,后端不会加上又有别的项目堆积没有时间研究,所以就是后端提供数据,前端来做表格。
那里复杂
可以看到,有二级标题,还有行的合并,如果仅仅是二级标题,倒是可以直接写死,但是行的合并是根据数据去计算该合并那些行的
,再比如后面如果有三级标题,四级标题的需求呢?那不是又寄了,所以我选择将这个封装成一个方法,当然也是在网上找大佬们的解决方案实现的,东抄抄西抄抄就实现了我想要的功能。
传参
既然封装成一个方法,最好就是传入数据,表头,文件名后,就能自动下载一个excel表格,这才是封装的意义。代码并不是人,只能根据你设定好的路去走,所以数据的结构就显得很重要了,这个函数想要接收什么样的数据结构,要怎么去处理这些数据结构。
表头 header
表头接收一个数组,每一项有title,prop,children(如果有子级标题),title即为列名,prop为数据属性绑定名,children为子标题。
const header = [
{
'title': '券商(轉出方)',
'prop': 'orgName',
'width': '100px'
},
{
'title': '存入股票',
'children': [
{
'title': '存入股票名稱/代碼',
'prop': 'stockNameCode',
'width': '100'
},
{
'title': '股票數量(股)',
'prop': 'stockNum',
'width': '100'
},
{
'title': '成本價(HKD)',
'prop': 'stockPrice',
'width': '100'
}
]
}
]
数据 dataSource
数据也是接收一个数组,但是这里需要做一个处理,因为每一项的children是一个数组,可能会有多个值,换句话来说,下面只有两条数据,分别是id为1和id为2,但实际上在excel表格中需要显示3行,所以需要处理一下。
const dataSource = [
{
id:1,
orgName:‘a’,
children:[
{
stockNameCode:'A1',
stockNum:'A2',
stockPrice:'A3'
},
{
stockNameCode:'B1',
stockNum:'B2',
stockPrice:'B3'
},
]
},
{
id:2,
orgName:'b',
children:[
{
stockNameCode:'A1',
stockNum:'A2',
stockPrice:'A3'
}
]
},
]
处理后的数据(也就是将children解构了,变成3条)
[
{
id:1,
orgName:‘a’,
stockNameCode:'A1',
stockNum:'A2',
stockPrice:'A3'
},
{
stockNameCode:'B1',
stockNum:'B2',
stockPrice:'B3'
},
{
id:2,
orgName:‘b’,
stockNameCode:'A1',
stockNum:'A2',
stockPrice:'A3'
}
]
sheetjs前置知识
对于我们前端生成excel,基本都是使用基于sheetjs
封装的第三包,最经常使用的是xlsx,我这里因为对表格做了一些样式所以使用的xlsx-js-style,xlsx-js-style是提供了很多样式的,比如字体,居中,填充,具体大家可以去看官网。因为可能有些人是没做过excel的需求的,所以这里简单说一下生成excel的一种主流程。
import XLSX from 'xlsx-js-style'
// 需要一个二维数组
var aoa = [
["S", "h", "e", "e", "t", "J", "S"],
[ 1, 2, , , 5, 6, 7],
[ 2, 3, , , 6, 7, 8],
[ 3, 4, , , 7, 8, 9],
[ 4, 5, 6, 7, 8, 9, 0]
];
// 将二维数组转成工作表
var ws = XLSX.utils.aoa_to_sheet(aoa);
// 创建一个工作簿
var wb = XLSX.utils.book_new();
// 将工作表添加到工作簿
XLSX.utils.book_append_sheet(wb, ws, "Sheet1");
// 生成excel
XLSX.writeFile(wb, "SheetJSExportAOA.xlsx");
导出的表格,这是官网的demo: xlsx.nodejs.cn/docs/api/ut…
所以封装这个函数,主要流程也是和这个一样的,只不过我们要做的时候,将传入的参数处理成我们想要的二维数组,以及在这基础做一些合并,样式的操作,下面介绍了一些属性的作用,具体大家还是需要去官网查看的。
ws['!merges']
ws['!merges']
是工作表对象 ws
的一个属性,用于存储工作表中的合并单元格信息,该属性的值是一个数组,其中每个元素都是一个对象,描述了一个合并单元格区域
// s是start e是end合并单元格区域的起始位置和结束位置,
// r是行 c是列
ws['!merges'] = [
{ s: { r: startRow, c: startCol }, e: { r: endRow, c: endCol } }
];
比如{ s: { r: 0, c: 0 }, e: { r: 0, c: 1 } }
表示合并从 A1
(第 1 行第 1 列)到 B1
(第 1 行第 2 列)的单元格。
ws['!ref']
ws['!ref']
是工作表对象 ws
的一个属性,用于表示该工作表中数据的范围引用。这个范围引用是一个字符串,遵循 Excel 的单元格范围表示法,格式通常为 A1:B10
,其中 A1
是范围的左上角单元格,B10
是范围的右下角单元格
ws['!cols']
ws['!cols']
是工作表对象 ws
的一个属性,它用于存储工作表中列的相关信息,比如列的宽度、隐藏状态等
主函数
有了这些前置知识,相信你肯定是能看懂这个主函数的,我们先从主线上来看,不去研究这个函数做了什么,只需要看他得到了什么,某一个函数的细节我们后面会有介绍。
header 表头
dataSource 数据
fileName 文件名
import XLSX from 'xlsx-js-style'
function exportExcel (header, dataSource, fileName) {
// 根据表头数组去计算行数和列数
const {row: ROW, col: COL} = excelRoWCol(header)
const aoa = []
const mergeArr = []
// 根据表头初始化aoa 二维数组
for (let rowNum = 0; rowNum < ROW; rowNum++) {
aoa[rowNum] = []
for (let colNum = 0; colNum < COL; colNum++) {
aoa[rowNum][colNum] = ''
}
}
// 根据表头以及数据生成,去合并列和行,会处理mergeArr
mergeArrFn(mergeArr, header, aoa, dataSource, ROW, COL)
// 最后往aoa中 添加表格数据
aoa.push(...jsonDataToArray(header, dataSource))
const ws = XLSX.utils.aoa_to_sheet(aoa)
// 添加样式
ExcelStyle(ws, header, ROW)
// 合并
ws['!merges'] = mergeArr
// 创建一个工作簿
const wb = XLSX.utils.book_new()
// // 将工作表添加到工作簿
XLSX.utils.book_append_sheet(wb, ws, 'sheet1')
// 生成excel
XLSX.writeFile(wb, fileName + '.xlsx')
}
export default exportExcel
相对前面那个下载excel的demo来说,无非就多了根据传入的header和dataSource去初始化生成aoa以及mergeArr,aoa就是前面demo的二维数组,mergeArr表示我们需要合并的单元格,也就是前面提到的ws['!merges']
,我们得到这个mergeArr也是为了赋值给它,还有就是给它添加样式了。
excelRoWCol
这个函数是根据表头去确认这个excel的表头有多少行,有多少列,因为我们传入的column,有children,children里可能还有chidren,是一个树
的结构,所以我们想要知道有多少行和多少列,无非就是去求这颗树的深度和宽度
,所以就是两个算法题了。
// 深度递归函数
function treeDeep (root) {
if (root) {
if (root.children && root.children.length !== 0) {
let maxChildrenLen = 0
for (const child of root.children) {
maxChildrenLen = Math.max(maxChildrenLen, treeDeep(child))
}
return 1 + maxChildrenLen
} else {
return 1
}
} else {
return 0
}
}
// 宽度递归函数
function treeWidth (root) {
if (!root) return 0
if (!root.children || root.children.length === 0) return 1
let width = 0
for (const child of root.children) {
width += treeWidth(child)
}
return width
}
function excelRoWCol(header) {
let row = 0
let col = 0
for (const item of header) {
row = Math.max(treeDeep(item), row)
col += treeWidth(item)
}
return {
row,
col
}
}
mergeArrFn
mergeArr 这个函数就是在修改这个值
header 表头
aoa 二维数组数
dataSource 数据
headerRowLen 表头行数
headerColLen 表头列数
这个函数有两个作用,第一就是将我们初始化的二维数组,用header进行赋值。第二,就是根据表头以及数据去生成mergeArr(赋值给ws['!merges'])。首先,对于header去遍历每一个表头去生成当前这一列的合并信息。假设一个只有二级表头的表头,如果当前这一列有二级标题,便根据子标题去合并主标题那一行所有的列,如果当前这一列没有子标题,便将这一列的第一行和第二行都和合并了。三级表头,四五级表头也是这样的思路。
function mergeArrFn(mergeArr, header, aoa, dataSource, headerRowLen) {
// 根据header去生成一部分的 mergeArr
let temCol = 0
for (const item of header) {
generateExcelColumn(aoa, 0, temCol, item, mergeArr)
temCol += treeWidth(item)
}
// 根据dataSource去生成一部分的 mergeArr
let rowStartIndex = headerRowLen
for (const item of dataSource) {
generateExcelRow(rowStartIndex, item, mergeArr, header)
rowStartIndex += treeWidth(item)
}
}
generateExcelColumn
这个函数简单来说就是前面所说的,假设一个只有二级表头的表头,如果当前这一列有二级标题,便根据子标题去合并主标题那一行所有的列,如果当前这一列没有子标题,便将这一列的第一行合第二行都和合并了。三级表头,四五级表头也是这样的思路。具体还是得自己理解代码,都有写注释。
aoa 就是那个aoa
row 就是行数
col 就是列数
curHeader 就是当前那一列
mergeArr 就是那个mergeArr
function generateExcelColumn(aoa, row, col, curHeader, mergeArr) {
// 当前列的宽度
const curHeaderWidth = treeWidth(curHeader)
// 赋值
aoa[row][col] = curHeader.title
// 如果有子标题也就是说当前这一行就需要合并了
if (curHeader.children) {
// 举个例子,假设有一个表头两行两列,需要把他变成第一行只有一列,第二行依然是两列
// 就需要变成 {s : { r:0,c:0 }, e : { r:0, c: 0+2-1 }}
mergeArr.push({s: {r: row, c: col}, e: {r: row, c: col + curHeaderWidth - 1}})
// 如果子标题还有子标题,就是递归了,要注意更新列数就行
let tempCol = col
for (const child of curHeader.children) {
generateExcelColumn(aoa, row + 1, tempCol, child, mergeArr)
tempCol += treeWidth(child)
}
} else {
// 这里的逻辑就是 如果没有子标题,就正常显示
// 举个例子,假设整个表头是有三级表头,三级表头也就是有3行,如果第5列是没有任何子级表头的那应该是
// {s:{r:0,c:5},e:{r:2,c:5}}
if (row !== aoa.length - 1) {
mergeArr.push({s: {r: row, c: col}, e: {r: aoa.length - 1, c: col}})
}
}
}
generateExcelRow
这个函数是根据datasource去生成mergeArr,从mergeArrFn看我们去遍历datasource的每一项,在外层维护rowStartIndex这个变量,我们假设某一项数据的children是一个长度为3的数组,那么通过treeWidth方法(寻找树的宽度)得到的数据就是3,也就是说这一项数据应该占表格3行,但是并不是所有列都是需要3行数据的,所以我们需要去获取到一个不用合并的列prop数组,我们通过这项数据的children的key值去获取,所以这就需要对数据格式有要求了!然后再通过header和getgetLeafProp去获取所有prop,最后遍历判断是否需要去合并行。合并的逻辑是这样的,还是以那个children是一个长度为3的数组为例,如果要合并肯定是3行合并成一行。以第一列为例子,就是 { s : { r : 0, c : 0 }, e : { r : 2 , c : 0 }}
,下面去遍历props时,下标刚好就是当前的列数。
rowStartIndex 就是从表头的下一行开始
curitem 就是遍历dataSource当前的行
mergeArr 就是mergeArr
header 表头数组
// 合并行
function generateExcelRow(rowStartIndex, curitem, mergeArr, header) {
// 当前行的高度
const curHeaderWidth = treeWidth(curitem)
// 不需要合并的列prop
const noMerge = (curitem.children && curitem.children.length > 0) ? Object.keys(curitem.children[0]) : []
// 找到所有prop
const props = []
for (const item of header) {
props.push(...getLeafProp(item))
}
// 遍历props
props.forEach((item, index) => {
// 不是子元素就要合并
if (!noMerge.includes(item)) {
mergeArr.push({s: {r: rowStartIndex, c: index}, e: {r: rowStartIndex + curHeaderWidth - 1, c: index}})
}
})
}
jsonDataToArray
这个函数就是为了生成一个二维数组,因为有子标题,所以可能需要递归。逻辑上也比较简单,假设表头是header,数据源是data,header经过处理后变成了props数组,而data根据props处理后就得到了我们想要的数据。
const header = [
{
title: 'a'
prop: 'aprop'
},
{
title: 'b',
children:[
{
title:'c',
prop:'cprop'
},
{
title:'d',
prop:'dprop'
}
]
},
{
title:'e',
prop:'eprop'
}
]
const data = [
{
aprop:'a1',
b:{
cprop:'c1',
dprop:'d1'
},
e:'e1'
},
{
aprop:'a2',
b:{
cprop:'c2',
dprop:'d2'
},
eprop:'e2'
},
]
// 得到的porps
['aprop','cprop','dprop','eprop']
// 最后得到的是这个
[
['a1','c1','d1','e1']
['a2','c2','d2','e2']
]
getLeafProp其实就是去找所有叶子节点的算法题,recursiveChildrenData就是根据我们得到的props去从data中拿到对应的值,然后如果遇到children就递归去拿,要注意的是就是children要第一条是不要的,children第一条是和这一项数据是一样的。
function jsonDataToArray (header, data) {
const props = []
for (const item of header) {
props.push(...getLeafProp(item))
}
return recursiveChildrenData(props, data)
}
// 获取叶子节点所有的prop,也就是excel表格每一列的prop
function getLeafProp(root) {
const result = []
if (root.children) {
for (const child of root.children) {
result.push(...getLeafProp(child))
}
} else {
result.push(root.prop)
}
return result
}
// 从数据中获取对应porps的值
function recursiveChildrenData(props, data) {
const result = []
for (const rowData of data) {
const row = []
for (const index of props) {
row.push(rowData[index])
}
result.push(row)
if (rowData.children) {
result.push(...recursiveChildrenData(props, rowData.children).slice(1))
}
}
return result
}
ExcelStyle
这个方法倒是简单,这里其实还可以将表头以及单元格样式抽离出去成为主函数exportExcel的配置项。这个函数干了啥呢,首先就是从columns中拿到每一列的宽度,处理成 ws['!cols']想要的格式,ws['!cols']这个就是sheetJS的配置表格列宽的一个属性。然后就是一些单元格样式,具体去看xslx-js-style的官网。decode_range和encode_cell这两个方法有简单介绍,具体大家去看sheetJS官网吧。
ws 就是 那个表格数据实例
columns 是表头数组
ROW 是表头有多少行
XLSX.utils.decode_range: 用于解析 Excel 工作表中的范围字符串并将其转换为结构化的对象
XLSX.utils.encode_cell:是将一个包含行号和列号的对象编码为 Excel 中常见的单元格地址表示形式
function ExcelStyle (ws, header, ROW) {
// 列宽
const widthes = []
for (const item of header) {
widthes.push(...getLeafwidth(item))
}
// 处理成 ws['!cols'] 想要的格式
const wsCOLS = widthes.map(item => {
return {
wpx: item || 100
}
})
ws['!cols'] = wsCOLS
// 定义所需的单元格格式
const cellStyle = {
font: { name: '宋体', sz: 11, color: { auto: 1 } },
// 单元格对齐方式
alignment: {
// / 自动换行
wrapText: 1,
// 水平居中
horizontal: 'center',
// 垂直居中
vertical: 'center'
}
}
// 定义表头
const headerStyle = {
border: {
top: { style: 'thin', color: { rgb: '000000' } },
left: { style: 'thin', color: { rgb: '000000' } },
bottom: { style: 'thin', color: { rgb: '000000' } },
right: { style: 'thin', color: { rgb: '000000' } }
},
fill: {
patternType: 'solid',
fgColor: { theme: 3, 'tint': 0.3999755851924192, rgb: 'DDD9C4' },
bgColor: { theme: 7, 'tint': 0.3999755851924192, rgb: '8064A2' }
}
}
// 添加样式
const range = XLSX.utils.decode_range(ws['!ref'])
for (let row = range.s.r; row <= range.e.r; row++) {
for (let col = range.s.c; col <= range.e.c; col++) {
// 找到属性名
const cellAddress = XLSX.utils.encode_cell({ c: col, r: row })
if (ws[cellAddress]) {
// 前几行是表头,添加表头样式
if (row < ROW) {
ws[cellAddress].s = headerStyle
}
ws[cellAddress].s = {
...ws[cellAddress].s,
...cellStyle
}
}
}
}
}
// 和getLeafProp类似,只是找的字段不一样
function getLeafwidth(root) {
const result = []
if (root.children) {
for (const child of root.children) {
result.push(...getLeafwidth(child))
}
} else {
result.push(root.width)
}
return result
}
总结
其实这次也是我第一次自己前端导出excel的需求,之前基本都是后端干的,给个地址直接模拟a标签下载就行了。本来呢,我看项目中也是有封装导出excel的方法的,但是有点晦涩难懂啊,看了下导出的效果,也并不能实现需求。我一直觉得在原有基础的去添加一些相似的功能逻辑,真不如直接重新封装一个方法。然后我测试过了将所有代码赋值到同一个js文件,正常引入传对应的数据结构是能跑通的。其实是有点问题的,就是在根据数据行合并的时候,如果是children里面还children,也就是也要递归,我有点不好拿捏判断递归的时机,加上本来对递归就是一知半解,搞得有点混乱,大家感兴趣的可以试试。
来源:juejin.cn/post/7447368539936587776