注册
web

和后端大战三百回合后,卑微前端还是选择了自己写excel导出

前言


对于一个sass项目,或者一个中后台项目来说,导出excel表格应该是家常便饭,本来最简单的实现方式是后端去做表格,前端请求接口拿到一个地址下载就行了。但是,因为我们这个项目之前就是前端做表格,加上这个表格相对比较复杂需要合并行和列,后端不会加上又有别的项目堆积没有时间研究,所以就是后端提供数据,前端来做表格。


那里复杂


image-20241209095449488.png


可以看到,有二级标题,还有行的合并,如果仅仅是二级标题,倒是可以直接写死,但是行的合并是根据数据去计算该合并那些行的,再比如后面如果有三级标题,四级标题的需求呢?那不是又寄了,所以我选择将这个封装成一个方法,当然也是在网上找大佬们的解决方案实现的,东抄抄西抄抄就实现了我想要的功能。


传参


既然封装成一个方法,最好就是传入数据,表头,文件名后,就能自动下载一个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…


image-20241210090608300.png


所以封装这个函数,主要流程也是和这个一样的,只不过我们要做的时候,将传入的参数处理成我们想要的二维数组,以及在这基础做一些合并,样式的操作,下面介绍了一些属性的作用,具体大家还是需要去官网查看的。


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

0 个评论

要回复文章请先登录注册