Vue3 + Element Plus | el-table 多级表头表格导出 Excel(含合并单元格、单元格居中)第二版

摘要 :在前端开发中,将复杂的 el-table 表格导出为 Excel 是一个高频需求。特别是当表格包含多级表头合并单元格 以及自定义计算列 时,传统的导出方案往往不理想。本文将分享一套基于 Vue3 + Element Plus + xlsx 的通用解决方案,完美还原表格结构,支持单元格样式调整与动态数据格式化。

一. 前言

在日常开发中,经常会提出这样的需求:"把这个表格导出来,格式要和页面上一样,表头要合并,数据要居中。" 如果是扁平的表格,使用 json2excel 或简单的 xlsx 写入即可。但一旦遇到如下场景:

  1. 多级表头(例如:分类 1 > 水果 > 苹果)
  2. 单元格合并(表头需要自动计算 rowspan/colspan)
  3. 非数据源字段(例如:表格中显示的"合计"是前端计算的,不在原始数据里)
  4. 样式保留(导出的 Excel 需要居中、边框等)

这就需要我们深入解析 el-table 的列配置,并手动构建 Excel 的合并区域。今天,我就把这套经过生产环境验证的代码分享给大家。

二. 效果预览

  • 页面展示:包含多级表头(分类 1/2,水果/蔬菜等),最后一列为前端计算的"合计"。
  • 导出效果 :Excel 文件完美复刻表头层级,合并单元格正确,所有单元格内容水平垂直居中,且"合计"列数据准确。

三. 核心依赖

我们需要以下库来处理 Excel 文件和样式:

bash 复制代码
npm install xlsx xlsx-style-vite file-saver
# 或者
yarn add xlsx xlsx-style-vite file-saver

注意 :标准的 xlsx 库对样式支持有限,这里使用 xlsx-style-vite 来支持单元格样式(如居中)。

四. 核心工具函数 (exportExcelFun.js)

这是本方案的灵魂所在。我们需要递归解析 el-tablecolumns 配置,计算表头的深度、跨行跨列数,并生成对应的合并规则。

请将以下代码保存为 ./utils/exportExcelFun.js

javascript 复制代码
import * as XLSX from 'xlsx'
import * as XLSXStyleVite from 'xlsx-style-vite'
import FileSaver from 'file-saver'
import { ElNotification } from 'element-plus'

/**
 * 计算列配置的最大嵌套深度(跳过 selection 列)
 * @param columns 列配置数组
 * @param currentDepth 当前深度(从1开始)
 * @returns 最大深度
 */
export function getMaxDepth (columns, currentDepth = 1) {
  return columns.reduce((max, col) => {
    if (col.type === 'selection') return max
    return col.children?.length
      ? Math.max(max, getMaxDepth(col.children, currentDepth + 1))
      : max
  }, currentDepth)
}

/**
 * 为列配置计算 rowSpan 和 colSpan(深拷贝避免污染原始数据)
 * @param columns 列配置
 * @param maxDepth 表头总行数
 * @param currentDepth 当前深度(从0开始)
 * @returns 处理后的列配置(含 rowSpan/colSpan)
 */
export function calculateSpan (columns, maxDepth, currentDepth = 0) {
  // 安全深拷贝
  const clone = (obj) => {
    if (obj === null || typeof obj !== 'object') return obj
    if (Array.isArray(obj)) return obj.map(clone)
    const copy = {}
    Object.keys(obj).forEach(key => {
      if (typeof obj[key] !== 'function') {
        copy[key] = clone(obj[key])
      }
    })
    return copy
  }
  const processed = clone(columns)
  const traverse = (cols, depth) => {
    let totalColSpan = 0
    cols.forEach(col => {
      if (col.type === 'selection') return
      if (col.children?.length) {
        // 非叶子节点:跨列 = 子节点跨列之和,跨行 = 1
        const childColSpan = traverse(col.children, depth + 1)
        col.colSpan = childColSpan
        col.rowSpan = 1
        totalColSpan += childColSpan
      } else {
        // 叶子节点:跨列 = 1,跨行 = 剩余行数
        col.colSpan = 1
        col.rowSpan = maxDepth - depth
        totalColSpan += 1
      }
    })
    return totalColSpan
  }
  traverse(processed, currentDepth)
  return processed
}

/**
 * 生成表头行数据及字段映射
 * @param columns 处理后的列配置(含 rowSpan/colSpan)
 * @returns { headerRows: string[][], keyArr: string[] }
 */
export function calcTableHeaderArray (columns) {
  const depth = getMaxDepth(columns)
  const headerRows = Array.from({ length: depth }, () => [])
  const keyArr = []
  const generateRows = (cols, rowIndex, colIndex) => {
    let currentCol = colIndex
    cols.forEach(col => {
      if (col.type === 'selection') return
      // 记录字段名(仅叶子节点)
      if (!col.children?.length && col.property) {
        keyArr.push(col.property)
      }
      // 填充当前行
      headerRows[rowIndex][currentCol] = col.label || ''
      // 递归处理子列
      if (col.children?.length) {
        currentCol = generateRows(col.children, rowIndex + 1, currentCol)
      } else {
        // 叶子节点:向下填充空单元格
        for (let r = rowIndex + 1; r < depth; r++) {
          headerRows[r][currentCol] = ''
        }
        currentCol += 1
      }
    })
    return currentCol
  }
  generateRows(columns, 0, 0)
  return { headerRows, keyArr }
}

/**
 * 生成合并区域配置
 * @param columns 处理后的列配置(含 rowSpan/colSpan)
 * @returns 合并区域数组
 */
export function generateMergeRanges (columns) {
  const ranges = []
  const traverse = (cols, startRow, startCol) => {
    let currentCol = startCol
    cols.forEach(col => {
      if (col.type === 'selection') return
      const endRow = startRow + col.rowSpan - 1
      const endCol = currentCol + col.colSpan - 1
      // 仅合并非1x1区域
      if (col.rowSpan > 1 || col.colSpan > 1) {
        ranges.push({
          s: { r: startRow, c: currentCol },
          e: { r: endRow, c: endCol }
        })
      }
      // 递归处理子列
      if (col.children?.length) {
        traverse(col.children, startRow + col.rowSpan, currentCol)
      }
      currentCol += col.colSpan
    })
    return currentCol
  }
  traverse(columns, 0, 0)
  // 按起始行/列排序(Excel要求)
  return ranges.sort((a, b) =>
    a.s.r !== b.s.r ? a.s.r - b.s.r : a.s.c - b.s.c
  )
}

/**
 * 字符串转 ArrayBuffer
 */
export function s2ab (s) {
  const buf = new ArrayBuffer(s.length)
  const view = new Uint8Array(buf)
  for (let i = 0; i < s.length; i++) {
    view[i] = s.charCodeAt(i) & 0xFF
  }
  return buf
}

/**
 * 导出 Excel 文件
 * @param options 导出配置
 */
export function prepareForExport ({
  tableRef, // 表格ref
  exportLoading, // loading
  tableData = [], // 表格数据
  fieldFormatters = {}, // 定义字段处理函数映射,没有可不传
  sheetName = 'Sheet1', // 标签页名称
  fileName = 'export.xlsx' // 文件名
}) {
  // 健壮性校验
  if (!tableRef?.value) {
    ElNotification.error({ title: '错误', message: '表格引用无效' })
    return
  }
  if (!tableData.value?.length) {
    ElNotification.warning({ title: '提示', message: '无数据可导出' })
    return
  }
  exportLoading.value = true
  try {
    const rawColumns = tableRef.value.columns || []
    if (!rawColumns.length) throw new Error('表格列配置为空')
    // 计算 rowSpan/colSpan
    const maxDepth = getMaxDepth(rawColumns)
    const processedColumns = calculateSpan(rawColumns, maxDepth, 0)
    const { headerRows, keyArr } = calcTableHeaderArray(processedColumns)
    // 构建数据行
    const dataRows = tableData.value.map((row, index) => {
      const rowData = [index + 1] // 序号列
      keyArr.forEach(prop => {
        const formatter = fieldFormatters[prop]
        rowData.push(
          formatter ? formatter(row) : row[prop] ?? ''
        )
      })
      return rowData
    })
    // 生成工作表
    const ws = XLSX.utils.aoa_to_sheet([...headerRows, ...dataRows])
    // 设置全局样式
    if (ws['!ref']) {
      const range = XLSX.utils.decode_range(ws['!ref'])
      for (let R = range.s.r; R <= range.e.r; R++) {
        for (let C = range.s.c; C <= range.e.c; C++) {
          const cellRef = XLSX.utils.encode_cell({ r: R, c: C })
          if (!ws[cellRef]) continue
          // 仅设置缺失样式的单元格
          ws[cellRef].s = {
            ...ws[cellRef].s || {},
            alignment: {
              horizontal: 'center',
              vertical: 'center',
              wrapText: true
            }
          }
        }
      }
    }
    // 应用合并区域
    ws['!merges'] = generateMergeRanges(processedColumns)
    // 生成并下载文件
    const wb = XLSX.utils.book_new()
    XLSX.utils.book_append_sheet(wb, ws, sheetName)
    const wbout = XLSXStyleVite.write(wb, {
      bookType: 'xlsx',
      type: 'binary',
      cellStyles: true
    })
    setTimeout(() => {
      FileSaver.saveAs(
        new Blob([s2ab(wbout)], { type: 'application/octet-stream' }),
          `${fileName.replace(/\.xlsx$/, '')}.xlsx`
      )
      exportLoading.value = false
      ElNotification.success({ title: '成功', message: '导出成功' })
    }, 1000)
  } catch (error) {
    exportLoading.value = false
    ElNotification.error({
      title: '导出失败',
      message: error instanceof Error ? error.message : '未知错误'
    })
  }
}

五. 页面组件实现 (Vue3)

在业务页面中,使用非常简单。重点在于:

  1. el-table 绑定 ref
  2. 通过 fieldFormatters 处理非原始数据的列(如合计列)。
javascript 复制代码
<script setup>
import { onMounted, ref } from 'vue'
import { prepareForExport } from '@/utils/exportExcelFun'

onMounted(() => {
  getTableData()
})

const year0 = ref(new Date().getFullYear()) // 今年
const tableData = ref([])
const loading = ref(false)
const getTableData = () => {
  loading.value = true
  // 生成测试数据
  const data = Array.from({ length: 10 }, (_, i) => ({
    name: '测试名称' + (i + 1),
    appleNum: Math.floor(Math.random() * 50) + 10, // 10--59
    bananaNum: Math.floor(Math.random() * 40) + 5, // 5--44
    eggplantNum: Math.floor(Math.random() * 30) + 8, // 8--37
    celeryNum: Math.floor(Math.random() * 35) + 12, // 12--46
    spinachNum: Math.floor(Math.random() * 25) + 15, // 15--39
    chipsNum: Math.floor(Math.random() * 60) + 20, // 20--79
    sausageNum: Math.floor(Math.random() * 45) + 10, // 10--54
    nutNum: Math.floor(Math.random() * 20) + 5, // 5--24
    beveragesNum: Math.floor(Math.random() * 80) + 30 // 30--109
  }))
  tableData.value = data
  loading.value = false
}

// 表格选中
const multipleSelection = ref([])
const handleSelectionChange = (val) => {
  multipleSelection.value = val
}

const calcTotalNum = (row) => {
  return row.appleNum + row.bananaNum + row.eggplantNum + row.celeryNum + row.spinachNum + row.chipsNum + row.sausageNum + row.nutNum + row.beveragesNum
}

const tableRef = ref(null)
const exportLoading = ref(false)
const exportToExcel = () => {
  // 定义字段处理函数映射
  const fieldFormatters = {
    totalNum: (row) => {
      return calcTotalNum(row)
    }
  }
  const currentYear = year0.value.toString()
  prepareForExport({
    tableRef,
    exportLoading,
    tableData,
    fieldFormatters,
    sheetName: '标签页1',
    fileName: `清单_${currentYear}年.xlsx`
  })
}
</script>

<template>
  <div class="serviceBudgetSummaryIndex">
    <div class="topWrap">
      <div class="formTit">
        多级表头表格导出excel示例
      </div>
      <div class="opBtn">
        <el-button type="primary" @click="exportToExcel" :loading="exportLoading">导出</el-button>
      </div>
    </div>
    <div class="tableBox">
      <el-table
        v-loading="loading"
        :data="tableData"
        border
        stripe
        height="100%"
        style="width: 100%"
        @selection-change="handleSelectionChange"
        ref="tableRef"
      >
        <el-table-column fixed="left" type="selection" width="80" align="center" />
        <el-table-column fixed="left" type="index" label="序号" align="center" min-width="80"></el-table-column>
        <el-table-column fixed="left" prop="name" label="名称" align="center" min-width="100"></el-table-column>
        <el-table-column label="分类1" align="center">
          <el-table-column label="水果" align="center">
            <el-table-column prop="appleNum" label="苹果" align="center" min-width="100"></el-table-column>
            <el-table-column prop="bananaNum" label="香蕉" align="center" min-width="100"></el-table-column>
          </el-table-column>
          <el-table-column label="蔬菜" align="center">
            <el-table-column prop="eggplantNum" label="茄子" align="center" min-width="100"></el-table-column>
            <el-table-column prop="celeryNum" label="芹菜" align="center" min-width="100"></el-table-column>
            <el-table-column prop="spinachNum" label="菠菜" align="center" min-width="100"></el-table-column>
          </el-table-column>
        </el-table-column>
        <el-table-column label="分类2" align="center">
          <el-table-column label="零食" align="center">
            <el-table-column prop="chipsNum" label="薯片" align="center" min-width="100"></el-table-column>
            <el-table-column prop="sausageNum" label="香肠" align="center" min-width="100"></el-table-column>
          </el-table-column>
          <el-table-column prop="nutNum" label="坚果" align="center" min-width="100"></el-table-column>
          <el-table-column prop="beveragesNum" label="饮料" align="center" min-width="100"></el-table-column>
        </el-table-column>
        <el-table-column prop="totalNum" label="合计" align="center" min-width="100">
          <template #default="{ row }">
            {{ calcTotalNum(row) }}
          </template>
        </el-table-column>
      </el-table>
    </div>
  </div>
</template>

<style lang="scss" scoped>
.serviceBudgetSummaryIndex {
  width: 100%;
  height: 100%;
  .topWrap {
    display: flex;
    justify-content: space-between;
    .el-button--primary {
      background: #356af9;
      border-radius: 4px;
      border-color: #356af9;
    }
  }
  .tableBox {
    height: calc(100% - 105px);
    margin-top: 5px;
  }
}
</style>

六. 关键技术点解析

1. 自动解析表头层级

我们并没有硬编码 Excel 的表头,而是直接读取 tableRef.value.columns。通过 getMaxDepth 递归计算表头有多少行,再通过 calculateSpan 计算每个表头单元格应该占据的行数(rowSpan)和列数(colSpan)。这使得代码与模板解耦 ,你修改 el-table 的结构,导出逻辑无需变动。

2. 处理非数据源字段

注意代码中的 fieldFormatters。 在表格中,"合计"列是通过 template #default 前端计算的,原始 tableData 中并没有 totalNum 字段。

我们允许传入一个映射对象,如果某个字段有对应的 formatter 函数,则执行函数计算值,否则直接取数据。这完美解决了计算列导出的问题。

3. 样式与合并

  • 合并 :利用 ws['!merges'] 属性,传入之前计算好的 ranges 数组,Excel 会自动合并单元格。
  • 居中 :遍历生成的工作表所有单元格,设置 alignment: { horizontal: 'center', vertical: 'center' }。这比在 HTML 中写 CSS 要繁琐,但在 xlsx-style-vite 的帮助下变得可行。

⚠️ 注意事项

  1. Selection 列处理 :代码中自动跳过了 type='selection' 的列,因为 Excel 中不需要复选框。
  2. 大数据量:当前方案会遍历所有单元格设置样式。如果表格行数超过 1000 行,生成速度可能会变慢。对于超大数据量,建议移除全量样式设置,或采用后端导出。
  3. 依赖版本xlsx-style-vite 是专门为 Vite 构建工具优化的带样式版本,如果你使用的是 Webpack,可能需要寻找其他支持样式的 xlsx 分支或插件。

七. 总结

通过解析 Element Plus 表格的列配置,结合 xlsx 库的底层能力,我们可以实现高度还原的 Excel 导出功能。这套方案不仅支持多级表头合并,还灵活支持前端计算列,能够覆盖绝大部分el-table表格的导出需求。

觉得有用的话,欢迎点赞、收藏、关注!如有问题,请在评论区留言交流。


本文代码已在 Vue3 + Element Plus 环境下实测,可直接复制使用。

相关推荐
wuhen_n2 小时前
Vue3 组件生命周期详解
前端·javascript·vue.js
wuhen_n2 小时前
渲染器核心:mount挂载过程
前端·javascript·vue.js
不想秃头的程序员2 小时前
vue3 Pinia 全解析:从入门到实战。
前端·javascript·vue.js
wuhen_n2 小时前
组件渲染:从组件到DOM
前端·javascript·vue.js
zhougl9962 小时前
Composition API 和 Options API
前端·javascript·vue.js
wuhen_n2 小时前
虚拟DOM:VNode的设计与创建
前端·javascript·vue.js
梵得儿SHI2 小时前
Vue3 生态工具实战宝典:UI 组件库 + 表单验证全解析(Element Plus/Ant Design Vue/VeeValidate)
前端·vue.js·ui·elementplus·vue性能优化·antdesignvue·表单验证方案
滕青山2 小时前
MD5在线加密 核心JS实现
前端·javascript·vue.js