多级表头表格转excel记录

317 阅读6分钟

  因为需求要导出的excel中,有多张表格,直接用dom去转反而有些麻烦,因此尝试直接输用数据生成表格,然而多级表头的表格生成有点点复杂,网上也没找到比较好的例子,因此自己写了一个方法,以供自己和他人使用

首先理一下思路

  1. 首先,我们要将整个树在脑海里展开,将其化为一个表格,树的每层深度,代表每一行,树的最下级子节点,代表每一列
  2. 在多级表头中,表头单元可能占有多行或多列,因此,我们需要合并单元格,所以我们需要记录每个表头单元起点的行数/列数(s: { r: 起始行, c: 起始列 }),和终点的行数/列数(e: { r: 终点行, c: 终点列 })
  3. 然后,我们需要理解表格中,每个表头单元的所占范围是怎么来的
    a. 首先是行,当单元格存在下级单元格时,他只占一行,否则,他将占据余下所有行
    b. 然后是列,当单元格存在下级单元格时,他将占据他所有最下级单元格数量的列,否则,他只占一列

理解了基本转换原理之后,我们来设计函数

首先,我们要确定使用什么逻辑去处理数据

因为表头数据是树形结构,所以适合递归处理

function forEach(columns) {
    if (!columns || !columns.length) return
    columns.forEach((column) => {
        forEach(column.children) // 递归
    }
}
forEach(data)

其次,从上面单元格占据范围原理中,我们知道,获取表头单元所占行数,分两种情况:

  1. 当单元格 存在 下级单元格时,他只占一行.这个很简单,开始/结束行都为当前行即可,而获取当前行,我的想法是,初始传入currentRow参数,使其为0,因为每次递归时,相当于行数下降一格,所以在递归调用时,传入currentRow + 1,这样在函数里拿到的currentRow就是当前行
  2. 当单元格 不存在 下级单元格时,他会占据余下所有行,也就是说,他的结束行为表格的最大行数,所以一开始,我们就需要知道表格的最大行数,作为函数的参数
// 获取行的数量
function getDepth(arr, childrenProp = 'children') {
    if (!Array.isArray(arr)) {
        return 0 // 如果不是数组,返回0表示不是层级结构
    }
    let maxDepth = 0
    for (const item of arr) {
        const depth = getArrayDepth(item[childrenProp])
        maxDepth = Math.max(maxDepth, depth)
    }
    return maxDepth + 1
}

const depth = getDepth(columns) - 1 // 获取最大行的角标

// 递归遍历每个单元格,并处理每个单元格的 行 的起始终点位置
function forEach(columns, currentRow = 0) {
    if (!columns || !columns.length) return
    columns.forEach((column) => {
        forEach(column.children, currentRow + 1) // 递归移动当前行指针
        let endRow = currentRow // 默认 结束行 为当前行
        if (!column?.children?.length) {
            endRow = depth // 当不存在children时,结束行 为最大行
        }
    }
}

然后,我们来看列数,同样分为两种情况

  1. 当单元格 不存在 下级单元格时,即当前单元格就是最下级单元格,它的开始/结束列数都为开始列,此时,只需要有开始列的数据即可,而开始列的角标为该节点第一个最下级子节点前的 所有最下级子节点的数量
  2. 当单元格 存在 下级单元格时,他的起始位置没啥说的,结束位置的值应该是开始位置加自己的所有最下级子节点的数量

由分析可以得知,我们需要

  1. 表格单元最下级子节点数量
  2. 表格单元之前所有最下级子节点数量

由此,我们可以想到:

  1. 设置当前列为currentCol,初始为0,当递归到底层时,即当前节点为undefined,返回 currentCol + 1,使currentCol计数加一
  2. currentCol + 1,可以理解为,起始位置加上最下级子节点数量, 似乎就是结束位置,但仔细一想,只有一个单元,它的列的起始结束位置应该相同,所以这里的结束位置,其实理解为下一个节点的起始位置更准确,这样我们起点和终点都有了,最后,把终点位置赋值给起点,进入下一次循环
  3. 现在我们知道,我们需要先处理当前节点的子节点,当子节点处理完毕后,处理当前节点,然后到同级下一个节点的子节点,没错,这就是中序遍历,到此整个逻辑完毕

进入代码

// 递归遍历每个单元格,并处理每个单元格的 列 的起始终点位置
function forEach(columns, currentRow = 0, currentCol = 0) {
    if (!columns || !columns.length) return currentCol + 1 // 处理最下级子节点
    columns.forEach((column) => {
        const endCol = forEach(column.children, currentRow + 1) // 接收结束位置(下一个节点起始位置)
        // 行的代码先不管
        let endRow = currentRow // 默认 结束行 为当前行
        if (!column?.children?.length) {
            endRow = depth // 当不存在children时,结束行 为最大行
        }
        
        // 处理节点的开始结束位置
        const merge = { e: { r: endRow, c: endCol - 1 }, s: { r: currentRow, c: currentCol } }
    }
}

现在数据都有了,很轻松就能获取每个单元格的起始和结束位置,接下来比较简单,要把表头和数据处理成xlsxjs的aoa格式,就是excel中每行一个数组,每列对应数组中固定的角标,然后把merge对象丢进表格对象的[!merge]属性里,他自己会合并.因为比较简单,我就直接上完整代码了,应当从exportMoreHeaderExcel方法开始看

// 获取行的数量
function getDepth(arr, childrenProp = 'children') {
    if (!Array.isArray(arr)) {
        return 0 // 如果不是数组,返回0表示不是层级结构
    }
    let maxDepth = 0
    for (const item of arr) {
        const depth = getArrayDepth(item[childrenProp])
        maxDepth = Math.max(maxDepth, depth)
    }
    return maxDepth + 1
}

function flattenHeaders(columns, rowMax) {
    if (!Array.isArray(columns)) return
    let merges = [],
      headers = [],
      props = []

    function forEach(columns, currentRow = 0, currentCol = 0) {
      if (!columns || !columns.length) return currentCol + 1 // 如果递归到最后,没有children,
      columns.forEach((column) => {
        const endCol = forEach(column.children, currentRow + 1, currentCol)
        let endRow = currentRow
        if (!column?.children?.length) {
          props.push(column.prop)
          endRow = rowMax
        }
        const merge = { e: { r: endRow, c: endCol - 1 }, s: { r: currentRow, c: currentCol } }
        headers.push({
          label: column.label,
          merge
        })
        if (endRow !== currentRow || endCol - 1 !== currentCol) {
          merges.push(merge)
        }
        currentCol = endCol
      })
      return currentCol
    }

    const width = forEach(columns)
    // 返回处理后的表头数组和合并单元格记录
    return { headers, props, merges, width }
  }
  
  // 获取表格模版数组
  function getHeaderTemplate(depth, width) {
    const arr = []
    for (let i = 0; i <= depth; i++) {
      arr.push(new Array(width).fill(''))
    }
    return arr
  }
  
  // 将缓冲区转换为 Blob 并保存为文件
  function export(excelBuffer) {
    const data = new Blob([excelBuffer], {
      type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8'
    })
    const url = URL.createObjectURL(data)
    const downloadWindow = window.open(url)

    // 这里可以设置一个定时器,确保下载窗口有足够的时间开始下载
    setTimeout(() => {
      // 一旦下载开始,就关闭下载窗口
      if (downloadWindow) {
        downloadWindow.close()
      }

      // 释放 URL 对象,以避免内存泄漏
      URL.revokeObjectURL(url)
    }, 100)
  }
  
  // 从这里开始看,data为数据,columns为表头配置
  function exportMoreHeaderExcel(data, columns, mergeCallback) {
    // 获取深度
    const depth = getArrayDepth(columns) - 1
    const { headers, props, merges, width } = flattenHeaders(columns, depth)
    console.log(headers)
    // 获取非表头部分aoa数据
    const info = data.map((row) => {
      return props.map((prop) => {
        return row[prop] || 0
      })
    })

    // 创建一个新的工作簿
    const workbook = XLSX.utils.book_new()

    // 获取表头的aoa数据
    const headerDic = getHeaderTemplate(depth, width)
    headers.forEach((item) => {
      const { label, merge } = item
      const { s } = merge
      headerDic[s.r][s.c] = label
    })
    console.log(headerDic)
    // 合并数据和表头,如果你希望多张表放在一个sheet里,可以继续向aoa数组添加数据
    const aoa = [...headerDic, ...info]
    // 创建一个sheet,根据需求,可以使用多个aoa数据创建多个,并添加到一个workbook中,导出时就是带有多张表的excel
    const worksheet = XLSX.utils.aoa_to_sheet(aoa)
    // 向sheet实例添加合并配置,通过mergeCallback设置额外的合并单元
    worksheet['!merges'] = mergeCallback ? mergeCallback(merge) : merges
    // 将sheet添加到工作簿中
    XLSX.utils.book_append_sheet(workbook, worksheet, 'BOOK_NAME')
    // 写入文件,并下载
    const excelBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' })
    export(excelBuffer)
  }