前端文件导出

32 阅读8分钟

分享一下 xlsx 这个库的常见使用场景,仅代表个人使用习惯,不喜勿喷

一、useExcel Hook封装

// src/hooks/useExcel.ts
import { getTypeOf, isAvailableArr } from '@/utils'
import { message } from 'antd'
import type { BookType, ColInfo, RowInfo, WorkBook, Range } from 'xlsx'
import * as XLSX from 'xlsx'

// 导出文件配置
export type IExportConfig = {
  name?: string // 导出文件名称
  bookType?: BookType // 导出文件类型
  sheetName?: string // sheet名称
  errorMsg?: string // 错误提示
  headers?: Record<string, string> // 自定义表头,导出的文件里面只有在定义中的字段,并且如果数据为空的话,只生成一个表头。示例:{id: 'ID', name: '链接名称', site_type: '官网类型'}
  merges?: Range[] // 单元格合并
  colInfo?: ColInfo[] // 列属性
  rowInfo?: RowInfo[] // 行属性
}

// 多sheet导出
export type IExtraSheetConfig = {
  name?: string // 导出文件名称
  bookType?: BookType // 导出文件类型
  sheets: ({ json: any[] } & Omit<
    IExportConfig,
    'name' | 'bookType'
  >)[]
}

/**
 * @desc 自定义导出文件hook
 */
const useExcel = () => {
  // 一维数组导出
  function exportJson2Excel<T = any>(json: T[], config?: IExportConfig) {
    const {
      name = '导出',
      sheetName = 'Sheet1',
      bookType = 'xlsx',
      headers,
      merges,
      colInfo,
      rowInfo,
    } = config || {}
    let lists = [...json]
    if (
      headers &&
      getTypeOf(headers) === 'Object' &&
      isAvailableArr(Object.keys(headers))
    ) {
      // 没有数据的时候用header去生成一个空表头
      if (!isAvailableArr(lists)) {
        const headersField = Object.values(headers)
        const headerObj: Record<string, any> = {}
        headersField.forEach((f) => {
          headerObj[f] = null
        })
        lists = [headerObj as T]
      } else {
        // 有数据的时候根据header去生成
        const headerFields = Object.entries(headers)
        lists = json.map((j) => {
          const obj: Record<string, any> = {}
          for (const [key, value] of headerFields) {
            obj[value] = j[key as keyof T] ?? null
          }
          return obj
        }) as T[]
      }
    }

    const wb = XLSX.utils.book_new()
    const ws = XLSX.utils.json_to_sheet(lists)
    if (merges) {
      ws['!merges'] = merges
    }
    if (colInfo) {
      ws['!cols'] = colInfo
    }
    if (rowInfo) {
      ws['!rows'] = rowInfo
    }
    XLSX.utils.book_append_sheet(wb, ws, sheetName)
    XLSX.writeFile(wb, `${name}.${bookType}`, { bookType })
  }

  // 一维数组多sheet导出
  function exportJson2ExcelSheets<T = any>(params: IExtraSheetConfig) {
    const { name = '导出', bookType = 'xlsx', sheets } = params || {}
    const wb = XLSX.utils.book_new()
    if (isAvailableArr(sheets)) {
      sheets?.forEach((s) => {
        const { json, headers, merges, colInfo, rowInfo, sheetName } = s || {}
        let lists = [...json]
        if (
          headers &&
          getTypeOf(headers) === 'Object' &&
          isAvailableArr(Object.keys(headers))
        ) {
          // 没有数据的时候用header去生成一个空表头
          if (!isAvailableArr(lists)) {
            const headersField = Object.values(headers)
            const headerObj: Record<string, any> = {}
            headersField.forEach((f) => {
              headerObj[f] = null
            })
            lists = [headerObj as T]
          } else {
            // 有数据的时候根据header去生成
            const headerFields = Object.entries(headers)
            lists = json.map((j) => {
              const obj: Record<string, any> = {}
              for (const [key, value] of headerFields) {
                obj[value] = j[key] ?? null
              }
              return obj
            }) as T[]
          }
        }

        const ws = XLSX.utils.json_to_sheet(lists)
        if (merges) {
          ws['!merges'] = merges
        }
        if (colInfo) {
          ws['!cols'] = colInfo
        }
        if (rowInfo) {
          ws['!rows'] = rowInfo
        }
        XLSX.utils.book_append_sheet(wb, ws, sheetName)
      })
    } else {
      const ws = XLSX.utils.json_to_sheet([])
      XLSX.utils.book_append_sheet(wb, ws)
    }

    XLSX.writeFile(wb, `${name}.${bookType}`, { bookType })
  }

  // 二维数组导出
  function exportAoa2Excel<T = any>(aoas: T[][], config?: IExportConfig) {
    const {
      name = '导出',
      sheetName = 'Sheet1',
      bookType = 'xlsx',
      merges,
      colInfo,
      rowInfo,
    } = config || {}

    const wb = XLSX.utils.book_new()
    const ws = XLSX.utils.aoa_to_sheet(aoas)
    if (merges) {
      ws['!merges'] = merges
    }
    if (colInfo) {
      ws['!cols'] = colInfo
    }
    if (rowInfo) {
      ws['!rows'] = rowInfo
    }

    XLSX.utils.book_append_sheet(wb, ws, sheetName)
    XLSX.writeFile(wb, `${name}.${bookType}`, { bookType })
  }

  // 读取本地excel文件
  function readWorkbookFromLocalFile(
    file: File | Blob,
  ): Promise<WorkBook | false> {
    return new Promise((resolve) => {
      const reader = new FileReader()
      reader.readAsBinaryString(file)
      reader.onload = function (e) {
        const data = (e.target as any)?.result
        const workbook = XLSX.read(data, {
          type: 'binary',
          raw: true,
          cellNF: true,
        })
        resolve(workbook)
      }
      reader.onerror = function () {
        resolve(false)
      }
    })
  }

  // 文件流导出
  async function exportBuffer2Excel(file: File | Blob, config?: IExportConfig) {
    const {
      name = '导出',
      bookType = 'xlsx',
      errorMsg = '下载失败',
    } = config || {}
    const wb: WorkBook | false = await readWorkbookFromLocalFile(file)
    if (wb) {
      XLSX.writeFile(wb, `${name}.${bookType}`, { bookType })
    } else {
      message.error(errorMsg)
    }
  }

  // 文件完整路径导出
  async function exportUrl2Excel(url: string, config?: IExportConfig) {
    fetch(url)
      .then((response) => response.blob())
      .then((blob) => {
        exportBuffer2Excel(new Blob([blob]), config)
      })
      .catch((error) => {
        throw error
      })
  }

  return {
    exportJson2Excel,
    exportAoa2Excel,
    exportBuffer2Excel,
    exportJson2ExcelSheets,
    exportUrl2Excel,
  }
}

export default useExcel

二、使用场景

以下使用示例都是基于上面的 useExcel

模拟的一条导出数据

const exportDatas = [
  {
    id: 6,
    name: '假期/周末',
    uniq_key: 2,
    status: 1,
    admin_name: '王光环',
    last_update_time: 1733900693,
  },
  {
    id: 52,
    name: '12345678901234567890',
    uniq_key: 32141,
    status: 1,
    admin_name: '王光环',
    last_update_time: 1723458626,
  },
  {
    id: 31,
    name: '王林1',
    uniq_key: 43,
    status: 0,
    admin_name: '王光环',
    last_update_time: 1723458305,
  },
  {
    id: 24,
    name: '王林',
    uniq_key: 44,
    status: 0,
    admin_name: '王光环',
    last_update_time: 1723458246,
  },
  {
    id: 50,
    name: 'dsfgj',
    uniq_key: 23,
    status: 0,
    admin_name: '朱宇航',
    last_update_time: 1723457090,
  },
  {
    id: 45,
    name: '泥瓦工个',
    uniq_key: 32,
    status: 0,
    admin_name: '王光环',
    last_update_time: 1723451897,
  },
  {
    id: 44,
    name: '生产制造隔热',
    uniq_key: 123421,
    status: 0,
    admin_name: '王光环',
    last_update_time: 1723451890,
  },
  {
    id: 42,
    name: '哥人',
    uniq_key: 121,
    status: 0,
    admin_name: '王光环',
    last_update_time: 1723451876,
  },
  {
    id: 40,
    name: '亮反而',
    uniq_key: 22,
    status: 0,
    admin_name: '王光环',
    last_update_time: 1723451857,
  },
  {
    id: 39,
    name: '生产制造热',
    uniq_key: 156,
    status: 0,
    admin_name: '王光环',
    last_update_time: 1723451849,
  },
  {
    id: 38,
    name: 'v个人头',
    uniq_key: 21,
    status: 0,
    admin_name: '王光环',
    last_update_time: 1723451840,
  },
  {
    id: 37,
    name: '而非',
    uniq_key: 1232,
    status: 0,
    admin_name: '王光环',
    last_update_time: 1723451832,
  },
  {
    id: 36,
    name: '道路工/公路工/铁路工反而',
    uniq_key: 12343,
    status: 0,
    admin_name: '王光环',
    last_update_time: 1723451826,
  },
  {
    id: 35,
    name: '泥瓦工非',
    uniq_key: 15,
    status: 0,
    admin_name: '王光环',
    last_update_time: 1723451818,
  },
  {
    id: 34,
    name: '道路工/公路工/铁路工',
    uniq_key: 14,
    status: 0,
    admin_name: '王光环',
    last_update_time: 1723451811,
  },
  {
    id: 33,
    name: '淡而无味',
    uniq_key: 11,
    status: 0,
    admin_name: '王光环',
    last_update_time: 1723451802,
  },
  {
    id: 4,
    name: '一日一结',
    uniq_key: 1,
    status: 1,
    admin_name: '王光环',
    last_update_time: 1723449520,
  },
  {
    id: 12,
    name: '在线兼职',
    uniq_key: 8,
    status: 1,
    admin_name: '朱宇航',
    last_update_time: 1723445457,
  },
  {
    id: 7,
    name: '手艺活',
    uniq_key: 3,
    status: 1,
    admin_name: '朱宇航',
    last_update_time: 1723432946,
  },
  {
    id: 10,
    name: '在家兼职',
    uniq_key: 6,
    status: 1,
    admin_name: '朱宇航',
    last_update_time: 1723172619,
  },
]

const headers = {
  id: 'ID',
  name: '链接名称',
  uniq_key: '唯一标识',
  status: '状态',
  admin_name: '编辑人',
  last_update_time: '最新编辑时间',
}

1、一维数组导出

/**
   * @name: exportJson2ExcelHandler
   * @description: 一维数组导出
   */
  const exportJson2ExcelHandler = () => {
    exportJson2Excel(exportDatas, {
      headers,
      name: '一维数组导出数据',
      sheetName: '一维数组导出数据',
    })
  }

2、一维数组多sheet导出

/**
* @name: exportJson2ExcelSheetsHandler
* @description: 一维数组多Sheet导出
*/
const exportJson2ExcelSheetsHandler = () => {
const tip = `模板说明:\n
1、“企业id”填写说明:必填,需填写正确\n
2、“企业名称”填写说明:选填\n
3、“第三方平台”&“该企业的第三方id”填写说明:选填,目前第三方平台限填纷享销客`
const template = [
  {
    企业名称: null,
    企业id: null,
    第三方平台: null,
    该企业的第三方id: null,
    该企业的第三方名称: null,
    模板说明: tip,
  },
]
const merges = [{ s: { r: 1, c: 5 }, e: { r: 9, c: 5 } }]
const colInfo = [
  { width: 20 },
  { width: 40 },
  { width: 20 },
  { width: 20 },
  { width: 20 },
  { width: 40 },
]

exportJson2ExcelSheets({
  name: '一维数组多Sheet导出数据',
  sheets: [
    { json: template, sheetName: '模板', merges, colInfo },
    {
      headers,
      json: exportDatas,
      sheetName: '数据',
    },
  ],
})
}

3、简单的模板下载

/**
* @name: downloadTemplate1
* @description: 简单的模板下载
*/
const downloadTemplate1 = async () => {
const name = '切词导入模板'
const sheetName = '导入模板'
const bookType = 'xlsx'
const merges = [{ s: { r: 1, c: 3 }, e: { r: 7, c: 3 } }]
const colInfo = [{ width: 20 }, { width: 20 }, { width: 20 }, { width: 50 }]
const tip = `1.“所属词包”填写说明:词包名称需与“词表词包管理”模块中的词包名称一致;\n
2. “状态”中,选项有待启用,已启用 \n(1)“待启用”状态的切词,将不被用户端取用 \n(2)“已启用”状态的切词,将被用户端取用`
const template = [
  {
    '切词(必填)': null,
    '所属词包(选填)': null,
    '状态(必填)': null,
    模板使用说明: tip,
  },
]
exportJson2Excel(template, { name, sheetName, merges, colInfo, bookType })
}

4、复杂的模板下载

// 工种没获取到的时候
  const withoutWorksTemplate = () => {
    const name = '搜索热词导入模板'
    const sheetName = '导入模板'
    const bookType = 'xlsx'
    const merges = [{ s: { r: 1, c: 2 }, e: { r: 7, c: 2 } }]
    const colInfo = [{ width: 20 }, { width: 20 }, { width: 40 }, { width: 30 }]
    const tip = `1.“适用工种”填写说明:\n
(1)最右侧为最新的二级工种名称,请以此在“适用工种”中,录入正确的二级工种名称。\n
(2)录入多个工种时,需使用英文逗号隔开`
    const template = [
      {
        '工种定向热词(必填)': '测试',
        '适用工种(必填)': '二级工种1,二级工种2',
        模板使用说明: tip,
      },
    ]
    exportJson2Excel(template, { name, sheetName, merges, colInfo, bookType })
  }

  /**
   * @name: downloadTemplate
   * @description: 复杂的模板下载
   */
  const downloadTemplate = () => {
    const name = '搜索热词导入模板'
    const sheetName = '导入模板'
    const bookType = 'xlsx'
    const merges = [{ s: { r: 1, c: 2 }, e: { r: 7, c: 2 } }]
    const colInfo = [{ width: 20 }, { width: 20 }, { width: 40 }, { width: 30 }]
    const tip = `1.“适用工种”填写说明:\n
(1)最右侧为最新的二级工种名称,请以此在“适用工种”中,录入正确的二级工种名称。\n
(2)录入多个工种时,需使用英文逗号隔开`

    toggleLoading()

    getAllWorks({ level: 2, online: 1 })
      .then((res) => {
        const { code, result } = res || {}
        if (code !== 0) return

        if (isAvailableArr(result.list)) {
          const header = [
            '工种定向热词(必填)',
            '适用工种(必填)',
            '模板使用说明',
            '最新二级工种列表',
          ]
          const workNames = result.list.map((l: any) => l.name)
          const contents = workNames.map((w: string, index: number) => {
            if (index === 0) {
              return ['测试', '二级工种1,二级工种2', tip, w]
            }
            return [null, null, null, w]
          })
          const template = [header, ...contents]
          exportAoa2Excel(template, {
            name,
            sheetName,
            merges,
            colInfo,
            bookType,
          })
        } else {
          withoutWorksTemplate()
        }
      })
      .catch(() => {
        withoutWorksTemplate()
      })
      .finally(toggleLoading)
  }
  
  // getAllWorks为一个接口,不用在意,我们最终只是想取到里面的值而已,没有该接口我们也可以模拟一个对象数组 ,只需要里面有name属性就行

5、文件流导出

/**
   * @name: exportCardData
   * @description: 导出统计明细(文件流导出)
   */
  const exportCardData = async () => {
    toggleLoading()
    try {
      const res = await exportCardDatas({ pg: 1, pagesize: 100, export: 1 })
      if (!res.code) {
        exportBuffer2Excel(new Blob(['\ufeff', res as any]), {
          name: '卡片统计明细',
          bookType: 'xlsx',
        })
      } else {
        message.error('导出统计明细失败')
      }
    } catch {
      //
    } finally {
      toggleLoading()
    }
  }
  
  // exportCardDatas是一个返回值为文件流的接口,可以自己模拟

6、文件地址下载

/**
   * @name: downloadTempUrl
   * @description: 文件地址下载(网络请求导出)
   */
  const downloadTempUrl = () => {
    const temp_url =
      'https://jgj-store.oss-cn-beijing.aliyuncs.com/operation/manual_rule/demo.xlsx'
    exportUrl2Excel(temp_url, { name: '手动触达推送人群导入模板' })
  }