基于Vue3+ElementPlus+xlsx的通用表格導(dǎo)出方案
摘要:在前端開發(fā)中,將復(fù)雜的 el-table 表格導(dǎo)出為 Excel 是一個(gè)高頻需求。特別是當(dāng)表格包含多級(jí)表頭、合并單元格以及自定義計(jì)算列時(shí),傳統(tǒng)的導(dǎo)出方案往往不理想。本文將分享一套基于 Vue3 + Element Plus + xlsx 的通用解決方案,完美還原表格結(jié)構(gòu),支持單元格樣式調(diào)整與動(dòng)態(tài)數(shù)據(jù)格式化。
一. 前言
在日常開發(fā)中,經(jīng)常會(huì)提出這樣的需求:“把這個(gè)表格導(dǎo)出來,格式要和頁(yè)面上一樣,表頭要合并,數(shù)據(jù)要居中。” 如果是扁平的表格,使用 json2excel 或簡(jiǎn)單的 xlsx 寫入即可。但一旦遇到如下場(chǎng)景:
- 多級(jí)表頭(例如:分類 1 > 水果 > 蘋果)
- 單元格合并(表頭需要自動(dòng)計(jì)算 rowspan/colspan)
- 非數(shù)據(jù)源字段(例如:表格中顯示的“合計(jì)”是前端計(jì)算的,不在原始數(shù)據(jù)里)
- 樣式保留(導(dǎo)出的 Excel 需要居中、邊框等)
這就需要我們深入解析 el-table 的列配置,并手動(dòng)構(gòu)建 Excel 的合并區(qū)域。今天,我就把這套經(jīng)過生產(chǎn)環(huán)境驗(yàn)證的代碼分享給大家。
二. 效果預(yù)覽
- 頁(yè)面展示:包含多級(jí)表頭(分類 1/2,水果/蔬菜等),最后一列為前端計(jì)算的“合計(jì)”。

- 導(dǎo)出效果:Excel 文件完美復(fù)刻表頭層級(jí),合并單元格正確,所有單元格內(nèi)容水平垂直居中,且“合計(jì)”列數(shù)據(jù)準(zhǔn)確。

三. 核心依賴
我們需要以下庫(kù)來處理 Excel 文件和樣式:
npm install xlsx xlsx-style-vite file-saver # 或者 yarn add xlsx xlsx-style-vite file-saver
注意:標(biāo)準(zhǔn)的 xlsx 庫(kù)對(duì)樣式支持有限,這里使用 xlsx-style-vite 來支持單元格樣式(如居中)。
四. 核心工具函數(shù) (exportExcelFun.js)
這是本方案的靈魂所在。我們需要遞歸解析 el-table 的 columns 配置,計(jì)算表頭的深度、跨行跨列數(shù),并生成對(duì)應(yīng)的合并規(guī)則。
請(qǐng)將以下代碼保存為 ./utils/exportExcelFun.js:
import * as XLSX from 'xlsx'
import * as XLSXStyleVite from 'xlsx-style-vite'
import FileSaver from 'file-saver'
import { ElNotification } from 'element-plus'
/**
* 計(jì)算列配置的最大嵌套深度(跳過 selection 列)
* @param columns 列配置數(shù)組
* @param currentDepth 當(dāng)前深度(從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)
}
/**
* 為列配置計(jì)算 rowSpan 和 colSpan(深拷貝避免污染原始數(shù)據(jù))
* @param columns 列配置
* @param maxDepth 表頭總行數(shù)
* @param currentDepth 當(dāng)前深度(從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) {
// 非葉子節(jié)點(diǎn):跨列 = 子節(jié)點(diǎn)跨列之和,跨行 = 1
const childColSpan = traverse(col.children, depth + 1)
col.colSpan = childColSpan
col.rowSpan = 1
totalColSpan += childColSpan
} else {
// 葉子節(jié)點(diǎn):跨列 = 1,跨行 = 剩余行數(shù)
col.colSpan = 1
col.rowSpan = maxDepth - depth
totalColSpan += 1
}
})
return totalColSpan
}
traverse(processed, currentDepth)
return processed
}
/**
* 生成表頭行數(shù)據(jù)及字段映射
* @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
// 記錄字段名(僅葉子節(jié)點(diǎn))
if (!col.children?.length && col.property) {
keyArr.push(col.property)
}
// 填充當(dāng)前行
headerRows[rowIndex][currentCol] = col.label || ''
// 遞歸處理子列
if (col.children?.length) {
currentCol = generateRows(col.children, rowIndex + 1, currentCol)
} else {
// 葉子節(jié)點(diǎn):向下填充空單元格
for (let r = rowIndex + 1; r < depth; r++) {
headerRows[r][currentCol] = ''
}
currentCol += 1
}
})
return currentCol
}
generateRows(columns, 0, 0)
return { headerRows, keyArr }
}
/**
* 生成合并區(qū)域配置
* @param columns 處理后的列配置(含 rowSpan/colSpan)
* @returns 合并區(qū)域數(shù)組
*/
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區(qū)域
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
)
}
/**
* 字符串轉(zhuǎn) 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
}
/**
* 導(dǎo)出 Excel 文件
* @param options 導(dǎo)出配置
*/
export function prepareForExport ({
tableRef, // 表格ref
exportLoading, // loading
tableData = [], // 表格數(shù)據(jù)
fieldFormatters = {}, // 定義字段處理函數(shù)映射,沒有可不傳
sheetName = 'Sheet1', // 標(biāo)簽頁(yè)名稱
fileName = 'export.xlsx' // 文件名
}) {
// 健壯性校驗(yàn)
if (!tableRef?.value) {
ElNotification.error({ title: '錯(cuò)誤', message: '表格引用無效' })
return
}
if (!tableData.value?.length) {
ElNotification.warning({ title: '提示', message: '無數(shù)據(jù)可導(dǎo)出' })
return
}
exportLoading.value = true
try {
const rawColumns = tableRef.value.columns || []
if (!rawColumns.length) throw new Error('表格列配置為空')
// 計(jì)算 rowSpan/colSpan
const maxDepth = getMaxDepth(rawColumns)
const processedColumns = calculateSpan(rawColumns, maxDepth, 0)
const { headerRows, keyArr } = calcTableHeaderArray(processedColumns)
// 構(gòu)建數(shù)據(jù)行
const dataRows = tableData.value.map((row, index) => {
const rowData = [index + 1] // 序號(hào)列
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])
// 設(shè)置全局樣式
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
// 僅設(shè)置缺失樣式的單元格
ws[cellRef].s = {
...ws[cellRef].s || {},
alignment: {
horizontal: 'center',
vertical: 'center',
wrapText: true
}
}
}
}
}
// 應(yīng)用合并區(qū)域
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: '導(dǎo)出成功' })
}, 1000)
} catch (error) {
exportLoading.value = false
ElNotification.error({
title: '導(dǎo)出失敗',
message: error instanceof Error ? error.message : '未知錯(cuò)誤'
})
}
}
五. 頁(yè)面組件實(shí)現(xiàn) (Vue3)
在業(yè)務(wù)頁(yè)面中,使用非常簡(jiǎn)單。重點(diǎn)在于:
- 給
el-table綁定ref。 - 通過
fieldFormatters處理非原始數(shù)據(jù)的列(如合計(jì)列)。
<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
// 生成測(cè)試數(shù)據(jù)
const data = Array.from({ length: 10 }, (_, i) => ({
name: '測(cè)試名稱' + (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 = () => {
// 定義字段處理函數(shù)映射
const fieldFormatters = {
totalNum: (row) => {
return calcTotalNum(row)
}
}
const currentYear = year0.value.toString()
prepareForExport({
tableRef,
exportLoading,
tableData,
fieldFormatters,
sheetName: '標(biāo)簽頁(yè)1',
fileName: `清單_${currentYear}年.xlsx`
})
}
</script>
<template>
<div class="serviceBudgetSummaryIndex">
<div class="topWrap">
<div class="formTit">
多級(jí)表頭表格導(dǎo)出excel示例
</div>
<div class="opBtn">
<el-button type="primary" @click="exportToExcel" :loading="exportLoading">導(dǎo)出</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="序號(hào)" 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="堅(jiān)果" 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="合計(jì)" 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>
六. 關(guān)鍵技術(shù)點(diǎn)解析
1. 自動(dòng)解析表頭層級(jí)
我們并沒有硬編碼 Excel 的表頭,而是直接讀取 tableRef.value.columns。通過 getMaxDepth 遞歸計(jì)算表頭有多少行,再通過 calculateSpan 計(jì)算每個(gè)表頭單元格應(yīng)該占據(jù)的行數(shù)(rowSpan)和列數(shù)(colSpan)。這使得代碼與模板解耦,你修改 el-table 的結(jié)構(gòu),導(dǎo)出邏輯無需變動(dòng)。
2. 處理非數(shù)據(jù)源字段
注意代碼中的 fieldFormatters。 在表格中,“合計(jì)”列是通過 template #default 前端計(jì)算的,原始 tableData 中并沒有 totalNum 字段。
我們?cè)试S傳入一個(gè)映射對(duì)象,如果某個(gè)字段有對(duì)應(yīng)的 formatter 函數(shù),則執(zhí)行函數(shù)計(jì)算值,否則直接取數(shù)據(jù)。這完美解決了計(jì)算列導(dǎo)出的問題。
3. 樣式與合并
- 合并:利用
ws['!merges']屬性,傳入之前計(jì)算好的ranges數(shù)組,Excel 會(huì)自動(dòng)合并單元格。 - 居中:遍歷生成的工作表所有單元格,設(shè)置
alignment: { horizontal: 'center', vertical: 'center' }。這比在 HTML 中寫 CSS 要繁瑣,但在xlsx-style-vite的幫助下變得可行。
注意事項(xiàng)
- Selection 列處理:代碼中自動(dòng)跳過了
type='selection'的列,因?yàn)?Excel 中不需要復(fù)選框。 - 大數(shù)據(jù)量:當(dāng)前方案會(huì)遍歷所有單元格設(shè)置樣式。如果表格行數(shù)超過 1000 行,生成速度可能會(huì)變慢。對(duì)于超大數(shù)據(jù)量,建議移除全量樣式設(shè)置,或采用后端導(dǎo)出。
- 依賴版本:
xlsx-style-vite是專門為 Vite 構(gòu)建工具優(yōu)化的帶樣式版本,如果你使用的是 Webpack,可能需要尋找其他支持樣式的 xlsx 分支或插件。
七. 總結(jié)
通過解析 Element Plus 表格的列配置,結(jié)合 xlsx 庫(kù)的底層能力,我們可以實(shí)現(xiàn)高度還原的 Excel 導(dǎo)出功能。這套方案不僅支持多級(jí)表頭合并,還靈活支持前端計(jì)算列,能夠覆蓋絕大部分el-table表格的導(dǎo)出需求。
本文代碼已在 Vue3 + Element Plus 環(huán)境下實(shí)測(cè),可直接復(fù)制使用。
以上就是基于Vue3+ElementPlus+xlsx的通用表格導(dǎo)出方案的詳細(xì)內(nèi)容,更多關(guān)于Vue3 ElementPlus通用表格導(dǎo)出的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
vue分頁(yè)組件table-pagebar使用實(shí)例解析
這篇文章主要為大家詳細(xì)解析了vue分頁(yè)組件table-pagebar使用實(shí)例,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2016-08-08
axios的interceptors多次執(zhí)行問題解決
這篇文章主要為大家介紹了axios中interceptors多次執(zhí)行問題解決,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-06-06

