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

前言

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

那里复杂

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

传参

既然封装成一个方法,最好就是传入数据,表头,文件名后,就能自动下载一个excel表格,这才是封装的意义。代码并不是人,只能根据你设定好的路去走,所以数据的结构就显得很重要了,这个函数想要接收什么样的数据结构,要怎么去处理这些数据结构。

表头 header

表头接收一个数组,每一项有title,prop,children(如果有子级标题),title即为列名,prop为数据属性绑定名,children为子标题。

js 复制代码
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行,所以需要处理一下。

js 复制代码
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条)

js 复制代码
[
    {
       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的一种主流程。

js 复制代码
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 的一个属性,用于存储工作表中的合并单元格信息,该属性的值是一个数组,其中每个元素都是一个对象,描述了一个合并单元格区域

js 复制代码
// 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 文件名

js 复制代码
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,是一个的结构,所以我们想要知道有多少行和多少列,无非就是去求这颗树的深度和宽度,所以就是两个算法题了。

js 复制代码
// 深度递归函数
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去遍历每一个表头去生成当前这一列的合并信息。假设一个只有二级表头的表头,如果当前这一列有二级标题,便根据子标题去合并主标题那一行所有的列,如果当前这一列没有子标题,便将这一列的第一行和第二行都和合并了。三级表头,四五级表头也是这样的思路。

js 复制代码
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

js 复制代码
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 表头数组

js 复制代码
// 合并行
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处理后就得到了我们想要的数据。

js 复制代码
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第一条是和这一项数据是一样的。

js 复制代码
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 中常见的单元格地址表示形式

js 复制代码
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,也就是也要递归,我有点不好拿捏判断递归的时机,加上本来对递归就是一知半解,搞得有点混乱,大家感兴趣的可以试试。

相关推荐
西柚与蓝莓5 分钟前
报错:{‘csrf_token‘: [‘The CSRF token is missing.‘]}
前端·flask
Pandaconda22 分钟前
【Golang 面试题】每日 3 题(三十九)
开发语言·经验分享·笔记·后端·面试·golang·go
NoneCoder40 分钟前
JavaScript系列(38)-- WebRTC技术详解
开发语言·javascript·webrtc
python算法(魔法师版)1 小时前
html,css,js的粒子效果
javascript·css·html
德迅云安全-小钱1 小时前
跨站脚本攻击(XSS)原理及防护方案
前端·网络·xss
ss2731 小时前
【2025小年源码免费送】
前端·后端
Amy_cx1 小时前
npm install安装缓慢或卡住不动
前端·npm·node.js
gyeolhada1 小时前
计算机组成原理(计算机系统3)--实验八:处理器结构拓展实验
java·前端·数据库·嵌入式硬件
小彭努力中1 小时前
16.在Vue3中使用Echarts实现词云图
前端·javascript·vue.js·echarts
flying robot1 小时前
React的响应式
前端·javascript·react.js