摘要 :在前端开发中,将复杂的
el-table表格导出为 Excel 是一个高频需求。特别是当表格包含多级表头 、合并单元格 以及自定义计算列 时,传统的导出方案往往不理想。本文将分享一套基于Vue3+Element Plus+xlsx的通用解决方案,完美还原表格结构,支持单元格样式调整与动态数据格式化。
一. 前言
在日常开发中,经常会提出这样的需求:"把这个表格导出来,格式要和页面上一样,表头要合并,数据要居中。" 如果是扁平的表格,使用 json2excel 或简单的 xlsx 写入即可。但一旦遇到如下场景:
- 多级表头(例如:分类 1 > 水果 > 苹果)
- 单元格合并(表头需要自动计算 rowspan/colspan)
- 非数据源字段(例如:表格中显示的"合计"是前端计算的,不在原始数据里)
- 样式保留(导出的 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-table 的 columns 配置,计算表头的深度、跨行跨列数,并生成对应的合并规则。
请将以下代码保存为 ./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)
在业务页面中,使用非常简单。重点在于:
- 给
el-table绑定ref。 - 通过
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的帮助下变得可行。
⚠️ 注意事项
- Selection 列处理 :代码中自动跳过了
type='selection'的列,因为 Excel 中不需要复选框。 - 大数据量:当前方案会遍历所有单元格设置样式。如果表格行数超过 1000 行,生成速度可能会变慢。对于超大数据量,建议移除全量样式设置,或采用后端导出。
- 依赖版本 :
xlsx-style-vite是专门为 Vite 构建工具优化的带样式版本,如果你使用的是 Webpack,可能需要寻找其他支持样式的 xlsx 分支或插件。
七. 总结
通过解析 Element Plus 表格的列配置,结合 xlsx 库的底层能力,我们可以实现高度还原的 Excel 导出功能。这套方案不仅支持多级表头合并,还灵活支持前端计算列,能够覆盖绝大部分el-table表格的导出需求。
觉得有用的话,欢迎点赞、收藏、关注!如有问题,请在评论区留言交流。
本文代码已在 Vue3 + Element Plus 环境下实测,可直接复制使用。