资讯动态

Vue 3 + Element Plus 前端导出 Excel 完整指南:多级表头、样式与性能优化

发布时间:2026/10/18 13:49:40 来源:尧图企业网站定制
最近在做一套中后台管理系统表格清一色用的 Element Plus 的 el-table。接到需求时我第一反应是交给后端生成 Excel 文件再回传下载链接结果后端排期直接排到了下个月需求方又催得紧。后来我索性把导出逻辑整个挪到前端用 xlsx 库在浏览器里直接生成 .xlsx 文件。做完之后发现这个方案不仅甩开了后端依赖响应速度快连多级表头、列宽、合并单元格这些定制需求都能自己掌控。这篇内容就围绕 Vue 3 Element Plus 场景下的前端导出 Excel 做一个完整拆解覆盖方案选型、核心 API 理解、多级表头处理、带样式导出、大列表性能优化以及一串我用实际案例踩出来的坑。适合正在做管理后台、数据报表页的开发者参考。1. 导出方案选型为什么我在生产环境选了纯前端方案1.1 三种常见方案的横向对比处理“前端表格导出 Excel”这个需求团队里通常会出现三条路线后端生成文件前端拿 URL 或 Blob 下载。优点是完全不需要前端关心 Excel 格式大数据量下后端机器算力也有保障缺点是每改一次导出行列结构都要走接口、等排期而且文件生成期间用户只能干等。前端把表格数据 POST 给一个中间服务由服务端转换成 Excel。本质还是后端方案只是把压力转移到一个“转换服务”上多维护一个服务不说数据出网反而增加了安全风险。纯前端方案在浏览器内存里完成数据到 .xlsx 文件的转换然后触发浏览器下载。这个方案响应最快、不占用后端资源适合数据量在几万行以内、结构相对固定的中后台报表。我最后选的是第三条路线。这里有个很重要的判断标准导出功能的核心痛点很多时候不是“能不能导出”而是“表头能不能按我的样式来”“这个临时列要不要加”“合并单元格怎么搞”。这些问题一旦落到前端开发自己就能闭环解决不需要跨团队沟通。1.2 xlsx 库的核心 APIjson_to_sheet 与 aoa_to_sheet纯前端实现 Excel 导出绕不开一个库SheetJS 社区版也就是大家常说的 xlsx。它是目前浏览器端解析和生成 xlsx 文件最流行的工具npm 安装量巨大API 稳定社区问答资料也全。npm install xlsx这个库给我最大的感受是它把“二维表格”这个概念映射得非常直接。两个最常用的 APIXLSX.utils.json_to_sheet(jsonArray)传入对象数组字段名自动成为表头。适合数据结构规整、表头就是英文 key 的极简场景。XLSX.utils.aoa_to_sheet(arrayOfArrays)传入二维数组第一行是表头后面是数据。这就是我主力使用的 API因为它把表头控制权完全交给了我多级表头、自定义标题行、空行都能自由编排。还有配套的XLSX.utils.book_new()、XLSX.utils.book_append_sheet(wb, ws, Sheet名)和XLSX.writeFile(wb, 文件名.xlsx)组合起来就是一次完整的导出。这几个 API 在后面实操里都会用到。1.3 理解 .xlsx 结构导出问题不再靠猜很多人在导出遇到“文件打不开”“样式没了”“内容乱码”时完全不知道从哪排查其实只要知道 .xlsx 的底层结构问题方向就清晰了。.xlsx 文件本质上是一个 ZIP 压缩包里面是一堆 XML 文件xl/workbook.xml记录工作簿和 sheet 关系xl/worksheets/sheet1.xml是实际单元格数据xl/styles.xml存颜色、边框、字体等样式定义。xlsx 库在浏览器端做的事情就是通过 JS 生成并打包这些 XML。这个认知特别有用社区版 xlsx 的aoa_to_sheet只负责生成 sheet1.xml 里的单元格文本不会帮你写 styles.xml所以它导出的文件天然没有样式支持。后面讲样式方案时你就能理解为什么需要xlsx-js-style或者exceljs来补位而不是对着社区版 API 干瞪眼。2. 核心实现拆解从 el-table 数据到 .xlsx 文件2.1 整个导出流程的模块划分明确了技术路线后我把导出功能拆成了五个模块列配置定义维护一份和 el-table 列对应的配置数组包含 label、prop、children。表头展开把多级表头递归展开成扁平的表头数组同时记录需要合并的单元格范围。数据映射根据列配置从业务数据里取出每行对应的值。工作表构建用 aoa_to_sheet 生成工作表再设置列宽、合并单元格、行高。文件下载构建 workbook触发浏览器下载。这样拆的好处是每个环节都能单独测试。比如表头展开逻辑我可以先打印展开后的数组确认无误再做数据映射避免把所有逻辑堆在一个函数里出问题了不好定位。2.2 表头处理多级表头的递归展开Element Plus 的 el-table-column 支持嵌套渲染出来的效果就是多级表头。导出时很多人直接遍历el-table实例上的 column但我更推荐自己维护一份配置数组。原因有两点一是表格列经常带自定义模板、插槽、fixed、sortable 这类只在渲染层有意义的属性把它们混进导出逻辑会让代码很脏二是自己维护配置可以精确控制导出表头和页面显示表头不完全一致的情况比如导出时某些列要改名、某些列要排除。列配置我通常这样定义const columns [ { label: 基本信息, children: [ { label: 姓名, prop: name, width: 20 }, { label: 年龄, prop: age, width: 10 } ] }, { label: 成绩, children: [ { label: 语文, prop: chinese, width: 10 }, { label: 数学, prop: math, width: 10 } ] }, { label: 总分, prop: total, width: 12 } ];需要把这种嵌套结构展开成一维表头数组同时记住每个叶子列从根到叶的完整路径方便后续做合并单元格function flattenColumns(columns, depth 0, result [], mergeMap []) { columns.forEach(col { if (col.children col.children.length) { flattenColumns(col.children, depth 1, result, mergeMap); } else { result.push({ ...col, depth }); mergeMap.push({ ...col, rowspan: depth 1 }); } }); return { headerList: result, mergeMap }; }这段代码里depth表示这个叶子列上方还有几层父级后续计算合并单元格范围时会用到。注意我没在 flatten 过程中直接生成 merge 配置而是先摊平再统一计算逻辑更清晰。2.3 数据整理与通用导出工具封装表头搞定后数据映射就很简单了遍历业务数据数组按照列配置里的 prop 取值拼成二维数组。但这里有一个新手容易踩的坑直接row[prop]取出来的值可能是数字、布尔值、Date 对象甚至是 null。Excel 对数字和文本的处理逻辑差异很大所以我会在数据映射环节做一次类型归一化function normalizeCellValue(value) { if (value null || value undefined) return ; if (value instanceof Date) { return formatDate(value, YYYY-MM-DD HH:mm:ss); } if (typeof value number Math.abs(value) 1000000000000) { return String(value); } return value; }这里补两个经验之谈。第一长数字不转字符串导出后大概率变成科学计数法第二Date 对象直接交给 xlsx它会把日期序列化成 Excel 内部的时间数字用户打开表格看到 45000 这种数字当场懵掉。所以统一在源头格式化成字符串是最省心的做法。完整的导出工具函数长这样import * as XLSX from xlsx; export function exportTableToExcel(columns, data, filename 导出数据) { const { headerList, mergeMap } flattenColumns(columns); const headerRow []; // 这里先只处理第一行表头多级表头的跨行合并下面单独讲 headerList.forEach(col { headerRow.push(col.label); }); const bodyRows data.map(row headerList.map(col normalizeCellValue(row[col.prop])) ); const sheetData [headerRow, ...bodyRows]; const ws XLSX.utils.aoa_to_sheet(sheetData); ws[!cols] headerList.map(col ({ wch: col.width || 16 })); const wb XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, Sheet1); XLSX.writeFile(wb, ${filename}.xlsx); }这段代码是一个能直接跑起来的最小实现。但注意它还没有处理多级表头的合并单元格下面单独聊。2.4 合并单元格与列宽控制多级表头导出后直接摊平会遇到一个问题上层父级表头在每一列都重复出现比如“基本信息”会显示两遍“成绩”也会显示两遍。正确的做法是让父级表头跨列合并上面一行显示一个“基本信息”下面两列分别是“姓名”“年龄”。这个场景在 xlsx 里通过ws[!merges]控制ws[!merges] [ { s: { r: 0, c: 0 }, e: { r: 0, c: 1 } }, // 第一行前两列合并 { s: { r: 0, c: 2 }, e: { r: 0, c: 3 } }, // 第一行第3、4列合并 ];s是合并起止单元格e是结束单元格r是行号从0开始c是列号从0开始。要动态生成这些配置需要对原始的列配置做一次更精细的遍历记录父级列的起点列索引和它覆盖的子列数量function calcMergeRange(columns) { const merges []; let leafIndex 0; function walk(list, parentStart, depth) { const start parentStart ?? leafIndex; let totalWidth 0; list.forEach(col { if (col.children col.children.length) { const childStart leafIndex; walk(col.children, childStart, depth 1); totalWidth leafIndex - childStart; // 父级所在的行跨列数为 totalWidth merges.push({ s: { r: depth, c: childStart }, e: { r: depth, c: leafIndex - 1 } }); } else { leafIndex; totalWidth; } }); } walk(columns, 0, 0); return merges; }这段代码的思路是深度优先遍历列配置用leafIndex累计叶子列的全局索引。每次进入一个父级列时记录它第一个子叶子的列号遍历完子列后再把这一整段标记为合并区域。行号depth就是父级所在的层级。这个方案实测下来能稳定处理三层以内的多级表头。列宽控制相对简单ws[!cols]是一个数组第 i 个元素对应第 i 列的宽度单位是字符宽度近似值ws[!cols] headerList.map(col ({ wch: col.width || 16 }));如果你希望表头行更高、看起来更透气可以再加ws[!rows]ws[!rows] [{ hpt: 30 }];需要在导出函数里把这两个配置同步设置好再 append 到 workbook。2.5 文件名与下载触发的细节XLSX.writeFile(wb, 文件名.xlsx)在浏览器端会自动完成 Blob 创建和下载触发看起来一行代码就结束了但有几个细节值得注意文件名后缀必须带.xlsx如果写掉了生成的 MIME 类型不对文件打开很容易损坏。文件名里的特殊字符要留意比如/、\在某些操作系统里会被当作路径分隔符。官方 API 也支持XLSX.write(wb, { type: array })返回 ArrayBuffer再手动通过 Blob 下载这个方案适合需要统一走公司内部封装的上传/下载逻辑的场景。我封装了一个手动下载版本给公司内部下载器用export function customDownload(workbook, filename) { const arrayBuffer XLSX.write(workbook, { type: array, bookType: xlsx }); const blob new Blob([arrayBuffer], { type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet }); const url URL.createObjectURL(blob); const link document.createElement(a); link.href url; link.download ${filename}.xlsx; link.click(); URL.revokeObjectURL(url); }URL.revokeObjectURL别漏掉不然大列表多次导出会产生内存压力。3. 带样式导出xlsx 社区版的边界与三条进阶路线3.1 社区版 xlsx 的功能边界最常被问到的问题是我用 xlsx 导出的表格没有背景色、没有边框、字体也不加粗怎么加样式答案是社区版 xlsx 不支持因为它在解析阶段只关注单元格值和地址映射。前面说过样式信息需要写进 styles.xml而社区版源码里根本没有生成 styles.xml 的完整逻辑。准确地说aoa_to_sheet生成的工作表对象里不存在样式结构后续写文件时自然也不会输出样式。了解这个边界后带样式导出一般有三条路线改用 xlsx-js-style 这个 fork 版导出后用 exceljs 做二次美化或者直接在 exceljs 里完成整个导出动作。我全试过分别说下使用感受。3.2 xlsx-js-style 实现单元格样式xlsx-js-style 是 SheetJS 社区版的一个 fork在保留原 API 的基础上允许通过单元格的s属性写入样式对象。安装方式npm install xlsx-js-style使用方式几乎无缝迁移import XLSX_STYLE from xlsx-js-style; const ws XLSX_STYLE.utils.aoa_to_sheet(sheetData); ws[A1] { ...ws[A1], s: { font: { bold: true, sz: 12 }, alignment: { horizontal: center, vertical: center }, fill: { fgColor: { rgb: FFEDEDED } }, border: { top: { style: thin, color: { rgb: FF000000 } }, bottom: { style: thin, color: { rgb: FF000000 } }, left: { style: thin, color: { rgb: FF000000 } }, right: { style: thin, color: { rgb: FF000000 } } } } };这个方案最适合在现有 aoa_to_sheet 代码上做增量改造先正常生成二维数组和合并单元格再遍历需要美化的单元格把s对象赋值进去。它最大的坑是样式对象一定要写完整特别是rgb前面的FF不要省略否则某些版本的 Excel 会静默忽略填充色。我因为这一个小问题排查了整整一下午。3.3 exceljs 模板法真正意义上的“套模板”如果你的导出需求是“在固定模板里填入数据”比如带企业 LOGO、固定表头、固定签名区域的报表xlsx-js-style 不是最合适的工具。exceljs 可以直接解析已有的 .xlsx 文件作为模板定位到指定单元格写入数据再保留模板原有的样式输出。exceljs 在浏览器端也可以跑只是体积相对大。核心思路是设计好模板文件放到项目的静态资源目录里。前端通过 fetch 获取模板 ArrayBuffer。exceljs 读取模板定位目标单元格写入数据。调整样式、页边距、页眉页脚然后导出。这个方案的优点是样式控制粒度极细和 Excel 原生能力几乎一致缺点是 API 比 xlsx 复杂得多学习成本高而且单元格定位靠行列号模板一改代码就要跟着改。如果你的团队有专门的报表设计岗位这个方案能做出非常精致的导出文件。3.4 从 el-table 渲染结果导出能但没必要顺便提一个高频提问能不能直接把 el-table 页面上看到的、经过格式化后的内容原样导出可以但不推荐从 DOM 下手。遍历tableRef.value.store.states.data拿到的是组件内部数据源这些数据已经经过 el-table 的列格式化逻辑但 DOM 里渲染的文本可能还有插值、模板函数甚至图标按钮这类组件。真要完全还原渲染结果得遍历 DOM 节点提取 textContent性能差且极度脆弱改一个列模板就崩。更可控的做法是把渲染逻辑单独抽成纯函数导出数据时和渲染用同一套格式化函数两边效果天然一致。4. 大数据量导出性能瓶颈与优化手段4.1 性能瓶颈到底卡在哪有同行遇到导出 10 万行数据页面直接白屏第一反应是换更快的库。实际上瓶颈可能不在“转换”而在三处数据量过大会让aoa_to_sheet的二维数组在内存中占用大量空间这取决于浏览器内存上限。XLSX.writeFile在做 ZIP 压缩时是 CPU 密集操作数据越大越耗时期间页面主线程会被长时间占用。表格页面本身已经渲染了大量 DOM导出逻辑再挤占主线程交互自然卡死。所以优化思路应该是先降数据量再降主线程压力。4.2 分批导出的工程化实践最直接的方案是限制单次导出数据量。我见过两种落地做法做法一是按页导出用户勾选当前页或指定页范围再导出Excel 行数天然受限用户体验也说得通但需求方往往要求“全量导出”。做法二是全量数据分批写入。社区版 xlsx 对同一 sheet 增量写入不算友好我常用的妥协策略是拆成多个 sheet每 5 万行一个 sheet最后合并成一个 workbook 下载。代码上就是把数据数组切片后逐个aoa_to_sheetconst CHUNK_SIZE 50000; const sheetNames []; for (let i 0; i data.length; i CHUNK_SIZE) { const chunk data.slice(i, i CHUNK_SIZE); const chunkSheetData [headerRow, ...chunk.map(row mapRowToCells(row))]; const ws XLSX.utils.aoa_to_sheet(chunkSheetData); ws[!cols] colWidths; XLSX.utils.book_append_sheet(wb, ws, 数据${Math.floor(i / CHUNK_SIZE) 1}); }这种方式实现成本低用户打开文件后多看到几个 sheet 也能接受。最好在界面上加一个 loading 提示导出一开始就全屏遮罩避免用户多次点击。4.3 Web Worker 方案的取舍如果连分批切 sheet 都觉得卡第二个思路是把转换丢到 Web Worker。Worker 线程可以承担XLSX.write的 ArrayBuffer 生成过程主线程就不会卡住。用 Worker 时注意两点一是 xlsx 库文件要能通过 importScripts 或 ESM 方式在 Worker 环境加载二是 Worker 返回 ArrayBuffer 后主线程上 Blob 下载逻辑照旧。实测下来5 万行以上数据时能明显感知主线程不再冻结但总耗时并没有缩短太多因为压缩本身是 CPU 密集任务Worker 只是把阻塞从界面上挪走了。我不会一上来就上 Worker更推荐的判断标准是数据量超过 10 万行或者页面本身已经重度渲染再考虑 Worker10 万行以内分批方案加上 loading 遮罩已经能覆盖大部分管理后台场景。5. 避坑实录前端导出 Excel 常见问题排查速查表5.1 一张速查表这一节把我在实际项目里遇到的典型问题、现象和解决方案整理成一张表适合直接收藏。问题现象排查方向/解决方案导出文件提示损坏双击文件报错或 Excel 提示修复检查文件名是否带 .xlsx检查写入顺序是否先 book_new 再 book_append_sheet确认数据里没有非法特殊字符长数字变科学计数法身份证、订单号显示 4.52213E18在数据映射阶段把超过 15 位的数字转成字符串或在 cell 上设置z: 文本格式日期变成数字日期列显示 45000 这种序列号数据源提前用 formatDate 转成YYYY-MM-DD字符串中文文件名乱码下载后文件名是乱码一般a.download对中文支持良好若走接口下载对 filename 做 encodeURIComponent 解码样式丢失背景色、边框全部消失社区版 xlsx 不支持样式切换到 xlsx-js-style 或 exceljs数据量大时页面卡死导出按钮点击后页面无响应参考第 4 章分批 sheet loading 遮罩必要时 Web Worker合并单元格错位多级表头合并区域偏移检查列配置是否加了 hidden 列、v-if 动态列列索引会被这些条件打乱列宽不生效设置了 wch 但打开还是默认宽度确认ws[!cols]数组长度和总列数一致且设置在 append 之前5.2 三个值得单独展开的坑速查表之外的三个坑我单独说一下因为它们都花了我不少时间。第一个是动态列导致合并错位。项目里有个页面会根据用户权限动态隐藏某些列我在导出前过滤了 column 配置但换算合并单元格时忘了基于过滤后的数组重新计算leafIndex结果表头合并区域整体偏移数据全对不上。解决办法是所有参与合并计算的列配置必须是最终展示的那一份过滤、排序处理必须在计算 merge 之前完成。第二个是writeFile在部分浏览器下的兼容性。正常情况下它在 Chrome 和 Edge 里表现良好但我在某个内部系统上遇到过下载无反应的情况最后定位到是 iframe 环境限制了自动下载。这种情况不要犹豫直接用XLSX.write 手动 Blob URL.createObjectURL那套代码替换掉writeFile。第三个是导出时间字段的时区问题。用户在前端选了一个时间存到后端再回显前端拿到的字符串可能是 UTC 格式“2025-01-01T08:00:00.000Z”直接塞进 Excel 用户看不懂。我在 normalize 函数里不仅做了格式化还强制指定用本地时区解析避免出现日期差 8 小时的诡异问题。还有一个小技巧导出前先console.log一下sheet_to_json(ws, { header: 1 })把工作表再读一遍可以直接看到最终写入的二维数组。这相当于给自己留了一面“镜子”很多低级错误在这一步就能暴露。前端导出 Excel 这个功能看着像是表格场景里的一个“边角料”真正落地时涉及到的细节一点也不少。从最初的方案选型到多级表头的合并计算再到大数据量下的性能取舍每层都有值得打磨的空间。我在实际项目里把这套逻辑沉淀成了工具函数之后后续其他页面要加导出能力基本只需要配置一份 columns 数组再传入业务数据就行。最后再分享一个小经验导出功能务必在联调阶段就用真实数据跑一遍边界测试全空数据、长字段、特殊字符这三种情况最容易在交付后被用户发现。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑