news 2026/6/10 23:12:30

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Vue3 + Element Plus | el-table多级表头表格导出Excel(含合并单元格、单元格居中)

本文目标:解决Element Plus多级表头导出Excel的三大痛点——表头结构映射单元格合并样式控制,已在生产环境验证无误。

适用场景:含复杂嵌套表头的业务报表导出(如财务报表、商品分类统计等)。

技术栈:Vue3 + Element Plus + xlsx + xlsx-style-vite + file-saver。

本文示例需求:

示例需求导出效果:

一、痛点解析:为什么多级表头导出这么难?

在开发中,我们常遇到这类需求:

  • 表头存在2~3级嵌套(如:大分类 → 子类 → 具体指标
  • 需保留Excel合并单元格效果
  • 需支持动态计算列(如合计行)
  • 要求样式统一(居中、边框、自动换行)

核心难点:

  1. 结构转换:Element的树形表头(el-table-column嵌套)无法直接转为Excel二维结构
  2. 合并计算:合并区域需精确计算行列坐标(从0开始计数!)
  3. 样式缺失:原生xlsx库不支持样式控制
  4. 动态字段:合计列等计算字段需特殊处理

二、技术选型:为什么是 xlsx-style-vite?

方案优点缺点适用性
html-table 转Excel实现简单无法控制合并/样式
xlsx 原生轻量无样式支持
xlsx-style-vite支持样式+Vite优化需手动设置样式
sheetjs-pro功能强大需商业授权

选择 xlsx-style-vite 的关键原因

  • 专为 Vite 生态优化,解决 xlsx-style 在 Vite 中的兼容性问题(Can‘t resolve ‘./cptable‘、Can’t resolve ‘fs’、jszip not a constructor等)
  • 保留完整的 Excel 样式 API(边框、居中等)
  • 完全开源且免费

三、核心实现四步走(附代码)

步骤1:动态解析表头结构

@/utils/globalFun.js中封装通用解析函数:

// 计算表头最大深度(跳过selection列) getMaxDepth(columns, currentDepth = 1) { let max = currentDepth for (const col of columns) { if (col.type === 'selection') continue // 跳过复选框列 if (col.children?.length) { max = Math.max(max, this.getMaxDepth(col.children, currentDepth + 1)) } } return max }, // 递归生成表头二维数组 + 字段key映射 generateHeaderRows(columns, rowIndex, headerRows, keyArr) { columns.forEach(col => { if (col.type === 'selection') return // 填充当前行标签 headerRows[rowIndex].push(col.label || '') // 记录叶子节点字段名(用于后续数据映射) if (col.property) keyArr.push(col.property) // 处理子列:递归下一层 if (col.children?.length) { this.generateHeaderRows(col.children, rowIndex + 1, headerRows, keyArr) } else { // 叶子节点:后续行补空(保证二维数组对齐) for (let i = rowIndex + 1; i < headerRows.length; i++) { headerRows[i].push('') } } }) }, // 主入口:返回{ headerRows: 二维表头数组, keyArr: 字段顺序数组 } calcTableHeaderArray(columns) { const depth = this.getMaxDepth(columns) const headerRows = Array(depth).fill().map(() => []) const keyArr = [] this.generateHeaderRows(columns, 0, headerRows, keyArr) return { headerRows, keyArr } }

关键设计

  • 通过keyArr精准映射数据字段顺序(解决列顺序错乱问题)
  • 递归时自动补空单元格,保证Excel行列对齐
  • 跳过selection列,避免导出无用复选框

步骤2:构建数据行(含自定义字段处理)

const fieldFormatters = { // 特殊字段处理器:合计列需动态计算 totalNum: (row) => row.appleNum + row.bananaNum + ... // 实际用calcTotalNum } const dataRows = tableData.value.map((row, index) => { const arr = [index + 1] // 序号列 keyArr.forEach(field => { // 优先使用格式化函数,否则取原始值 arr.push(fieldFormatters[field] ? fieldFormatters[field](row) : row[field]) }) return arr }) // 合并表头+数据 const exportData = [...headerRows, ...dataRows]

💡技巧:通过fieldFormatters映射,轻松处理模板列、计算列等非原始数据字段。

步骤3:设置样式与合并单元格

在计算ws['!merges']时,建议手动标上数字,方便计算合并,注意行、列都是从0开始的:

// 创建工作表 const ws = XLSX.utils.aoa_to_sheet(exportData) // 【关键】遍历设置全局样式(居中+边框+换行) 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]) ws[cellRef] = { t: 's', v: '' } // 空单元格初始化 ws[cellRef].s = { alignment: { horizontal: 'center', vertical: 'center', wrapText: true }, border: { // 四周边框 top: { style: 'thin' }, bottom: { style: 'thin' }, left: { style: 'thin' }, right: { style: 'thin' } } } } } } // 【重要】合并区域设置(行列索引从0开始!) ws['!merges'] = [ { s: { r: 0, c: 0 }, e: { r: 2, c: 0 } }, // "序号"跨3行 { s: { r: 0, c: 2 }, e: { r: 0, c: 6 } }, // "分类1"跨5列 // ... 其他合并区域(根据实际表头结构调整) ]

⚠️避坑重点

  • 坐标从0开始计数,Excel中A1对应{r:0, c:0}
  • 空单元格需初始化:否则样式设置会失败
  • 合并区域需严格匹配表头结构:建议先打印headerRows确认行列数

🔍动态合并方案提示
真实项目中建议在generateHeaderRows中记录每个节点的rowSpan/colSpan,自动生成!merges。本文为清晰展示采用硬编码,文末提供优化思路。

步骤4:生成并导出文件

const wb = XLSX.utils.book_new() XLSX.utils.book_append_sheet(wb, ws, '业务清单') // 使用xlsx-style-vite写入带样式文件 const wbout = XLSXStyleVite.write(wb, { bookType: 'xlsx', type: 'binary' }) // 转ArrayBuffer(关键!否则文件损坏) FileSaver.saveAs( new Blob([this.s2ab(wbout)], { type: 'application/octet-stream' }), `商品统计_${new Date().getFullYear()}年.xlsx` ) // 字符串转ArrayBuffer(globalFun.js中) 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 }

必须做s2ab转换!否则Excel文件会损坏无法打开


四、高频问题解答

问题原因解决方案
导出文件打不开未做 s2ab 转换严格使用new Blob([s2ab(wbout)])
合并单元格错位行列索引计算错误牢记坐标从0开始
样式不生效未使用 xlsx-style-vite检查导入 import * as XLSXStyleVite
合计列显示 undefined未处理计算字段配置 fieldFormatters 映射
中文乱码编码问题s2ab 中& 0xFF确保编码正确

五、优化方向

1. 动态合并区域生成

generateHeaderRows中记录每个节点的起始/结束行列,自动生成!merges数组:

// 伪代码示例 if (col.children) { merges.push({ s: { r: currentRow, c: currentCol }, e: { r: currentRow, c: currentCol + totalLeafCount - 1 } }) }

2. 性能优化

  • 大数据量:使用 Web Worker 异步生成
  • 样式优化:仅对表头设置复杂样式,数据行简化

3.扩展性增强

  • 封装为通用Composition API:useExportExcel(tableRef, options)
  • 支持列宽自定义:ws['!cols'] = [{ wpx: 100 }, ...]

六、结语

多级表头导出的核心在于:将树形表头结构拍平为二维数组 + 精确控制合并区域。本文方案已在多个生产项目验证,支持多级嵌套表头导出。

  1. 表头解析:通过generateHeader将树形结构转为二维数组
  2. 数据处理:利用fieldFormatters处理计算字段
  3. 导出规范:牢记坐标从0开始 +s2ab转换

七、完整代码示例

@/utils/globalFun.js 工具代码:

const $global = { // 计算列配置的最大嵌套深度(不包括 selection 列) getMaxDepth (columns, currentDepth = 1) { let max = currentDepth for (const col of columns) { if (col.type === 'selection') continue if (col.children && col.children.length > 0) { max = Math.max(max, this.getMaxDepth(col.children, currentDepth + 1)) } } return max }, // 递归生成 headerRows generateHeaderRows (columns, rowIndex = 0, headerRows, keyArr) { columns.forEach(column => { if (column.type === 'selection') return // 填入当前行 if (column.property) { keyArr.push(column.property) } headerRows[rowIndex].push(column.label || '') // 处理 colspan(当前行扩展空单元格) if (column.colSpan && column.colSpan > 1) { const emptyCells = new Array(column.colSpan - 1).fill('') headerRows[rowIndex].push(...emptyCells) } // 如果有子列,递归处理下一层 if (column.children?.length > 0) { this.generateHeaderRows(column.children, rowIndex + 1, headerRows, keyArr) } else { // 没有子列,则在后续所有行补空字符串 for (let i = rowIndex + 1; i < headerRows.length; i++) { headerRows[i].push('') } } }) return headerRows }, // 动态计算行表头 calcTableHeaderArray (columns) { const depth = this.getMaxDepth(columns) const headerRows = Array.from({ length: depth }, () => []) const keyArr = [] // 生成表头 this.generateHeaderRows(columns, 0, headerRows, keyArr) return { headerRows, keyArr } }, 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 } } export default $global

完整页面代码:

<script setup> import { onMounted, ref } from 'vue' import { ElNotification } from 'element-plus' import * as XLSX from 'xlsx' import * as XLSXStyleVite from 'xlsx-style-vite' import FileSaver from 'file-saver' import $global from '@/utils/globalFun' 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 = () => { if (tableData.value.length === 0) { ElNotification.warning({ title: '提示', message: '无数据可导出' }) return } exportLoading.value = true // 获取表头与表头对应的prop const columns = tableRef.value?.columns || [] const { headerRows, keyArr } = $global.calcTableHeaderArray(columns) // 定义字段处理函数映射 const fieldFormatters = { totalNum: (row) => { return calcTotalNum(row) } } // 构建数据行 const dataRows = tableData.value.map((el, j) => { const arr = [j + 1] // 默认带序号 keyArr.forEach(x => { if (typeof fieldFormatters[x] === 'function') { arr.push(fieldFormatters[x](el)) } else { arr.push(el[x]) } }) return arr }) const exportData = [...headerRows, ...dataRows] // 创建工作表 const ws = XLSX.utils.aoa_to_sheet(exportData) // 设置单元格居中 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] && !ws[cellRef].s) { ws[cellRef].s = { alignment: { horizontal: 'center', // 单元格居中 vertical: 'center', wrapText: true // 文字换行 }, // 设置边框(可要可不要) border: { top: { style: 'thin' }, bottom: { style: 'thin' }, left: { style: 'thin' }, right: { style: 'thin' } } } } } } } // 定义合并区域 (行列索引从0开始) ws['!merges'] = [ { s: { r: 0, c: 0 }, e: { r: 2, c: 0 } }, // 序号 { s: { r: 0, c: 1 }, e: { r: 2, c: 1 } }, // 名称 { s: { r: 0, c: 2 }, e: { r: 0, c: 6 } }, // 分类1 { s: { r: 1, c: 2 }, e: { r: 1, c: 3 } }, // 分类1-水果 { s: { r: 1, c: 4 }, e: { r: 1, c: 6 } }, // 分类1-蔬菜 { s: { r: 0, c: 7 }, e: { r: 0, c: 10 } }, // 分类2 { s: { r: 1, c: 7 }, e: { r: 1, c: 8 } }, // 分类2-零食 { s: { r: 1, c: 9 }, e: { r: 2, c: 9 } }, // 分类2-坚果 { s: { r: 1, c: 10 }, e: { r: 2, c: 10 } }, // 分类2-饮料 { s: { r: 0, c: 11 }, e: { r: 2, c: 11 } } // 合计 ] // 生成并导出文件 const wb = XLSX.utils.book_new() XLSX.utils.book_append_sheet(wb, ws, '标签页1') // 生成 ArrayBuffer const wbout = XLSXStyleVite.write(wb, { bookType: 'xlsx', type: 'binary' }) // 保存文件 const currentYear = year0.value.toString() FileSaver.saveAs( new Blob([$global.s2ab(wbout)], { type: 'application/octet-stream' }), `清单_${currentYear}年.xlsx` ) ElNotification.success({ title: '成功', message: '导出成功' }) exportLoading.value = false } </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>
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/10 15:24:26

好写作AI:当屏幕成为“声音的画布”,视障学生的写作革命静悄悄发生

如果你的写作障碍是“不知道写什么”&#xff0c;那么视障同学的障碍是“不知道写的东西长什么样”——直到他们遇见了好写作AI的声音版“导盲写作教练”。凌晨的盲文图书馆里&#xff0c;大三视障学生小雅正面临所有写作者共同的焦虑&#xff1a;明天要交的心理学论文还差一半…

作者头像 李华
网站建设 2026/6/10 16:03:10

定稿前必看!当红之选的降AI率平台 —— 千笔·降AI率助手

在AI技术快速发展的今天&#xff0c;越来越多的学生和研究者开始借助AI工具辅助论文写作&#xff0c;以提升效率和内容质量。然而&#xff0c;随着学术审查标准的不断提升&#xff0c;AI生成内容的痕迹越来越容易被检测出来&#xff0c;导致论文AI率超标成为困扰无数学生的难题…

作者头像 李华
网站建设 2026/6/10 15:57:45

照着用就行:最强的AI论文工具 —— 千笔·专业论文写作工具

你是否在论文写作中感到力不从心&#xff1f;选题无从下手&#xff0c;框架混乱&#xff0c;文献查找费时费力&#xff0c;查重率高得让人焦虑&#xff0c;格式错误更是令人头疼。面对这些学术写作的“拦路虎”&#xff0c;许多同学都曾陷入深深的困扰。而如今&#xff0c;一款…

作者头像 李华
网站建设 2026/6/10 21:27:25

强烈安利!千笔·降AIGC助手,MBA论文降重首选

在AI技术迅猛发展的当下&#xff0c;越来越多的学生、研究人员和职场人士开始借助AI工具辅助论文写作。然而&#xff0c;随着知网、维普、万方等查重系统不断升级算法&#xff0c;以及Turnitin对AIGC&#xff08;人工智能生成内容&#xff09;的识别愈发严格&#xff0c;AI率超…

作者头像 李华
网站建设 2026/6/10 0:50:09

杨维桢:元末“铁崖体”诗人,凭啥成了顶流?

一、先给结论&#xff1a;他凭什么成为元末顶流&#xff1f; 一句话&#xff1a; **乱世之中&#xff0c;别人求稳、他求真&#xff1b;别人复古、他破局&#xff1b;别人写应酬、他写风骨。** 杨维桢不靠家世、不靠官场、不靠站队&#xff0c;只靠一支**狂、辣、奇、硬**的诗笔…

作者头像 李华
网站建设 2026/6/10 15:52:14

基于大数据技术的智慧旅游数据分析系统爬虫 可视化1500

目录大数据技术在智慧旅游中的应用爬虫技术在旅游数据采集中的作用旅游数据分析的关键技术可视化技术在智慧旅游中的实现智慧旅游系统的架构设计项目技术支持可定制开发之功能亮点源码获取详细视频演示 &#xff1a;文章底部获取博主联系方式&#xff01;同行可合作大数据技术在…

作者头像 李华