写这篇帖子之前,我特意翻了翻之前的聊天记录,发现“前端怎么导出Excel”这个问题几乎每隔几周就会被同事或者群友翻出来问一次。别小看这个功能,它不像写页面样式、调接口那么“有存在感”,但它实实在在地是后台管理系统里的高频刚需——报表导出、数据备份、离线分析,但凡涉及表格展示的页面,几乎躲不过这一关。而“Vue + Element Plus 实现前端导出 Excel 功能”这套组合,恰恰是目前前端方案里成本最低、落地最快的一种做法,不需要后端介入,不需要临时加接口,纯浏览器端就能把页面上渲染出来的数据变成货真价实的 Excel 文件。
这篇内容我会用 Vue 3 + Element Plus 的组合来完整拆解整个实现链路。从最基础的“把表格数据导出来”,到处理日期格式、自定义列宽、追加表头,再到解决大数据量导出时页面卡顿的问题,全部用可以直接抄的代码和步骤来讲。适合正在做后台管理系统、刚接手导出需求、或者想系统了解前端生成 Excel 原理的开发者参考。你不需要已经很熟 xlsx 这个库,跟着走一遍基本就能应付日常工作里 80% 的导出需求。
1. 前端导出 Excel 的方案选型与思路拆解
做前端导出,第一件事不是写代码,而是想清楚走哪条路。技术选型直接决定了后面你会遇到多少坑。
1.1 前端导出与后端导出的边界选择
很多团队一提到“导出Excel”,第一反应是让后端写接口。这种思路没有错,但它适用的是另一类场景:数据量大到百万行级别、导出内容需要服务端权限校验、或者数据源根本不在当前前端页面上。
但如果你面对的场景是“页面上表格里已经有一批数据,用户想一键下载存个档”,这时候再走后端起接口就属于重复造轮子了。前端直接拿到表格的列定义和数据源,用 JavaScript 在浏览器里生成 .xlsx 文件再触发下载,整个流程不需要网络请求,响应速度是毫秒级的,而且不会额外占用服务器资源。
我个人的判断标准很简单:
- 数据量在几万行以内,优先前端导出。
- 需要跨系统汇总数据、或涉及复杂服务端业务逻辑,才考虑后端导出。
- 既想快又想要大能力,可以前后端配合:前端生成模板,后端填充数据。
这个判断在绝大多数管理后台项目里是成立的。因为这类项目里的表格页基本都是分页加载的,一次能展示的数据量天然有限,前端导出是最合身的选择。
1.2 核心库选型:xlsx、exceljs 还是 html 表格转 blob
前端生成 Excel 的库,主流无非三个流派。
第一种是用 SheetJS 的 xlsx 库。它的核心能力是把二维数组、JSON 数组转换成 Excel 能识别的数据格式,优点是轻量、上手快、文档丰富,社区里随便一搜就是大把示例。但它的社区版对样式支持很弱,合并单元格、单元格颜色、边框这些操作,要么得手动操作底层 XML,要么直接不支持。
第二种是 exceljs。它的强项正好是样式和高级特性——合并单元格、条件格式、富文本都能搞定,API 设计得也更接近“操作 Excel 文件”的心智模型。但缺点也很实在:打包体积比 xlsx 大不少,API 复杂度更高,如果你只是导个列表,用它有点杀鸡用牛刀。
第三种思路比较取巧:把 HTML 表格转成 Blob 下载,文件后缀写成 .xls。这招老项目中很常见,优点是代码量极少,缺点是生成的文件本质上是一个 HTML 文件,Excel 打开时会弹格式警告,特殊字符也容易错乱。这属于旁门左道,我不建议在新项目里用了。
我的建议是:日常业务表格导出,直接上 xlsx 库的 community 版本。它的体积和性能是最适合浏览器端场景的,我们对样式的需求用“拼接配置数据”就能覆盖大部分。等哪天真遇到需要花式样式的需求,再在 exceljs 方向单独做技术方案。
2. 基于 Element Plus 表格的快速导出实现
方案定了,就可以动手了。这一节我会从零开始写一个完整的导出流程,用 Element Plus 的 el-table 作为数据展示层,xlsx 作为文件生成层,最终实现一个“点按钮,表格变文件”的效果。
2.1 环境准备与依赖安装
首先,项目必须基于 Vue 3 + Element Plus。如果你还没安装 Element Plus,标准做法是使用官方提供的组件库安装指令,把组件库和图标库一起加进项目。已装过的跳过这块即可。
接着安装核心依赖。这里我建议直接装完整版的 SheetJS 库,因为它的 API 稳定,兼容性也最好,安装命令只需要一行。
注意:SheetJS 官方现在把新版本的稳定分发放到了他们自己的 CDN 上,npm 上的 xlsx 包停留在较旧版本。对绝大多数后台项目来讲,npm 上这个版本完全够用,不是非要追新。
安装完成之后,在需要导出的页面里引入两个模块:一个用于把 HTML 表格结构解析成工作簿,另一个用于触发文件下载。
import * as XLSX from 'xlsx'如果你觉得打包体积敏感,也可以只引入需要的子模块,但按我的经验,xlsx 整体引入的体积并不夸张,开发阶段没必要过早优化它。
2.2 最简导出步骤:从 el-table 到 .xlsx 文件
Element Plus 的 el-table 组件有一个很关键的设计:它会在页面里渲染出一个真实的 table 结构。这意味着 xlsx 库可以直接把这个 table 抓取出来,转成工作簿数据。
这个方案最大的优势是“所见即所得”——页面上表格长什么样,导出的 Excel 里基本就是什么样。
具体实现分三步:
第一步,给需要导出的表格加上 id。确保这个 id 是页面里唯一的,这样选择器才能精确命中。
<el-table :data="tableData" id="exportTable"> <el-table-column prop="name" label="姓名" /> <el-table-column prop="age" label="年龄" /> <el-table-column prop="city" label="城市" /> </el-table>第二步,写导出函数。核心逻辑就是“找到表格 -> 转成工作表 -> 放进工作簿 -> 写入文件”。
const exportExcel = () => { // 1. 从页面里拿到这个表格的真实 DOM const tableDom = document.getElementById('exportTable') // 2. 使用 XLSX 将 DOM 表格解析为工作表对象 const worksheet = XLSX.utils.table_to_sheet(tableDom) // 3. 创建新工作簿,把工作表挂载进去 const workbook = XLSX.utils.book_new() XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1') // 4. 生成并触发下载 XLSX.writeFile(workbook, '用户列表.xlsx') }第三步,在工具栏放一个按钮,绑定这个函数。
<el-button type="primary" @click="exportExcel">导出 Excel</el-button>就这么简单,一个可用的导出功能已经完成了。点击按钮,浏览器就会下载一个包含当前表格全部数据的 Excel 文件。
这套做法的底层逻辑,我拆开说一下。table_to_sheet这一句,做的事情是把 DOM 里的<table>元素逐行遍历,读取每个单元格的文本内容,然后映射成二维数据结构。book_new和book_append_sheet则是把二维数组包装成 Excel 的通用格式。最后writeFile会根据文件扩展名自动选择文件格式,直接把二进制数据以 .xlsx 为后缀保存下来。
整个过程完全没有经过任何服务器,数据始终在浏览器本地流转,既快也安全。
3. 导出功能的核心细节与进阶优化
基础版能跑通后,实战里你就得面对各种“不止那么简单”的需求了。这节我会把导出这个需求拉高一个层次,讲清楚表头、单元格内容处理、文件名、大数据量这几块的实际解法。
3.1 自定义表头与指定数据列的导出方案
直接用 table_to_sheet 有一个局限性:它导出的是页面表格里展示的所有列。但实际需求往往是“表格有10列,导出只要其中5列”,或者“导出的列名要和页面上显示的不一样、要更正式”。
这时候有两种调整思路。
第一种,如果列名需要修改,直接把表格 DOM 里对应表头的文本改掉。但这种做法会污染页面状态,属于临时方案,不推荐。
第二种更靠谱的做法是绕过 DOM,直接从数据源拼接二维数组。这样既控制了导出的列,也控制了导出的表头。
const exportCustomExcel = () => { // 定义要导出的列配置 const columns = [ { prop: 'name', label: '姓名' }, { prop: 'age', label: '年龄' }, { prop: 'city', label: '所在城市' } ] // 第一行放表头 const header = columns.map(col => col.label) // 数据行只保留配置中存在的字段 const rows = tableData.value.map(item => columns.map(col => item[col.prop])) // 把头和数据合并成二维数组 const exportData = [header, ...rows] const worksheet = XLSX.utils.aoa_to_sheet(exportData) const workbook = XLSX.utils.book_new() XLSX.utils.book_append_sheet(workbook, worksheet, '用户列表') XLSX.writeFile(workbook, '用户列表.xlsx') }这里的核心 API 从table_to_sheet换成了aoa_to_sheet,意思是“array of arrays to sheet”,接收一个二维数组作为输入。这是 xlsx 库最通用的数据入口,几乎所有导出需求最后都能归结为“拼一个二维数组,转成工作表”。
这个方案我更喜欢,因为它把导出逻辑和页面 DOM 解耦了。以后哪怕表格组件换成别的库,导出功能依然是自洽的。
3.2 日期格式化、金额保留小数等常见数据处理技巧
Excel 不是一个“宽容”的格式。导入导出时最常翻车的场景,就是日期、数字这类有格式语义的数据。
先说日期。如果你的表格里存的是2025-12-01 10:30:00这种标准字符串,导出后 Excel 大概率把它当作普通文本,而不是日期单元格。如果你存的是时间戳,那导出来就是一串数字,用户根本看不懂。
推荐的做法是在拼二维数组时,就把时间戳格式化成人类可读的字符串。
const formatDate = (timestamp) => { const date = new Date(timestamp) const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0') const hours = String(date.getHours()).padStart(2, '0') const minutes = String(date.getMinutes()).padStart(2, '0') return `${year}-${month}-${day} ${hours}:${minutes}` }再说金额。浮点数在 JS 里天生有精度问题,0.1 + 0.2 不等于 0.3 想必大家都有印象。导出时如果直接拿浮点数写进单元格,Excel 里显示出来的可能是 100.30000000000001 这种让人头皮发麻的值。
所以数值型字段在导出前,最好用toFixed(2)统一处理一下。
const formatMoney = (value) => { return Number(value || 0).toFixed(2) }处理这类问题的原则只有一条:导出的数据必须在进入工作表之前就完成格式化。不要指望 Excel 自己去猜,它猜出来的结果大概率不合你心意。
3.3 设置列宽:从一行一列手动调,到整个工作簿统一配置
直接用aoa_to_sheet生成的工作表,列宽是 Excel 默认的。假如你的数据里有“这个月各组销售额汇总统计表”这种超长标题,导出后列宽不够,文字会被截断或挤成一团,用户体验相当糟糕。
在 xlsx 库里,列宽是一个需要手动配置的字段。准备工作表的列宽配置,然后把这份配置赋给工作表的cols属性就可以生效。
const setColumnWidths = (worksheet, widths) => { worksheet['!cols'] = widths.map(width => ({ wch: width })) }调用也很直接,在生成工作表之后、写入文件之前执行即可。
const worksheet = XLSX.utils.aoa_to_sheet(exportData) setColumnWidths(worksheet, [15, 10, 20]) XLSX.utils.book_append_sheet(workbook, worksheet, '用户列表')这里的wch是 xlsx 库约定的列宽单位,大体上可以理解成“能放下几个英文字符”。中文相比英文要宽一些,所以如果列内容偏中文,宽度最好在预估字符数基础上再加 30% 左右。
如果你觉得一种列宽配置不够灵活,也可以做成动态计算:根据每列最长字符串长度自动生成宽度。但我的经验是,固定宽度配置在绝大多数场景里够用,动态计算反而容易因为中英文混排出现误差。
3.4 带合计行、多级表头的复杂业务表格导出
管理后台的表格很少是“纯数据”的。最常见的情况是:表格底部有一个合计行,或者表头分两级。这种结构下,表格 DOM 方案反而有优势,因为它在页面上渲染成什么样,导出就能还原成什么样。
但如果你走的是“二维数组拼接”这条路,就得自己把这些结构拼出来。
多级表头在二维数组里的实现方式,是让上一层表头占据多列的位置。
const header = [ ['员工信息', '员工信息', '业绩', '业绩'], ['姓名', '工号', '第一季度', '第二季度'] ] const rows = [ ['张三', 'A001', 12000, 15000], ['李四', 'A002', 9800, 13200] ] const worksheet = XLSX.utils.aoa_to_sheet([...header, ...rows]) // 合并第一行的前两列,以及后两列 worksheet['!merges'] = [ XLSX.utils.decode_range('A1:B1'), XLSX.utils.decode_range('C1:D1') ]decode_range做的事情是把类似A1:B1的区间字符串解析成 xlsx 库内部使用的行列范围对象。有这张表,导出后的 Excel 里第一行就会正确合并单元格,形成两级表头效果。
合计行的思路类似,在数据数组末尾追加一行汇总数据。唯一需要注意的是,合计行一般要带醒目的样式,但社区版 xlsx 对样式的支持有限,能做到的也就是“加粗”这种级别。如果你对样式要求很高,建议直接把底部的合计行从表格数据源里独立出来,单独设置,而不是混在数据里。
3.5 文件名动态生成与浏览器兼容性处理
Excel 文件名看起来是个小事,实际这里也有讲究。写死的文件名,比如用户列表.xlsx,在导出一周后再次点击时,浏览器会直接覆盖之前的下载,用户想找历史记录都找不到。
动态文件名的做法是把导出时间拼进去。
const getExportFileName = (prefix) => { const now = new Date() const datePart = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}` const timePart = `${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}` return `${prefix}_${datePart}_${timePart}.xlsx` }浏览器兼容性这块,XLSX.writeFile在主流现代浏览器里都工作正常。但需要注意一点:如果用XLSX.write方法手动控制输出格式,再自己创建 Blob 下载,就需要留意URL.createObjectURL的可用性,并且在下载完成后主动调用URL.revokeObjectURL释放内存。这个问题在老版本浏览器或者特殊 webview 环境里会暴露出来。
const downloadBlob = (blob, fileName) => { const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = fileName link.click() URL.revokeObjectURL(url) }这套手动流程在writeFile出现之前是主流写法,现在虽然大多数场景用不到,但理解了它,你就能明白浏览器下载文件的底层原理。
3.6 大数据量导出的优化策略:分片处理与超时体验
管理后台的表格页默认是分页的,但导出需求往往是“不分页,导全量”。一旦接口把几万、几十万条数据一次性返回,页面就可能卡死。卡死的原因是 JS 主线程在同一时间做了太多计算。
xlsx 库把二维数组转成工作表的过程,本身就是 CPU 密集操作。数据量大了以后,这层转换逻辑会阻塞 UI 渲染,用户能直观感受到“点完导出,页面动不了了”,如果正好还开着 loading 动画,那动画也会卡成幻灯片。
我的做法是分片处理。把大数据量的数组切分成小块,每一块放进宏任务里处理,让浏览器有空隙去刷新 UI。
const exportLargeData = async (dataSource, columns, fileName) => { const header = columns.map(col => col.label) const sheetData = [header] const CHUNK_SIZE = 5000 for (let i = 0; i < dataSource.length; i += CHUNK_SIZE) { const chunk = dataSource.slice(i, i + CHUNK_SIZE) const rows = chunk.map(item => columns.map(col => item[col.prop])) sheetData.push(...rows) // 让出主线程,给渲染留一点时间 await new Promise(resolve => setTimeout(resolve, 0)) } const worksheet = XLSX.utils.aoa_to_sheet(sheetData) const workbook = XLSX.utils.book_new() XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1') XLSX.writeFile(workbook, fileName) }注意这里有两个细节:
- 分割块大小建议控制在 3000 到 5000 行之间,太小了加频繁调度开销,太大了失去分片意义。
setTimeout的 0 延迟并不会真的“等待”,它只是把回调节器放到下一次事件循环里,作用是给浏览器一次执行绘制任务的机会。
如果你的数据量大到超过十万行,那就不建议前端导了,这不是优化策略能解决的瓶颈。前端导出适合的规模是“几万行以内”,再往上,后端流式生成文件再返回下载链接,才是正确赛道。
4. 常见问题排查与避坑实录
这个功能看似简单,但真到工作里,你可能会遇到一系列“为什么我导出之后打不开?”“为什么导出内容一片乱码?”“为什么列宽明明设置了却没效果?”这类问题。下面这些是我在自己项目里真踩过的坑,整理成速查表,按图索骥排查效率很高。
4.1 问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 点击导出无反应、浏览器无下载 | 表格 id 不存在,DOM 查找失败 | 检查 id 是否写对、是否大小写敏感 |
| 导出后 Excel 打开提示格式错误 | 使用 HTML 转 .xls 的旁门左道 | 改用 xlsx 库标准写法 |
| 中文文件名下载后乱码 | 某些浏览器对 UTF-8 文件名支持异常 | 文件名穿插英文字母,或使用 download 属性设置 |
| 合计行、多级表头导出后错位 | 二维数组里行列不完整 | 用!merges声明合并区间 |
| 数据显示为 #### 或科学计数法 | 列宽不足 | 配置!cols列宽 |
| 长数字(如身份证号)变成科学计数法 | Excel 默认类型推断 | 数据转字符串,或在数字前加制表符 |
| 大数据量导出卡顿 | 主线程同步计算过载 | 分片处理 + 异步让出主线程 |
| 导出的日期变成一串数字 | 时间戳未格式化 | 导出前调用日期格式化函数 |
加粗当前行首项显示异常,可以排查时逐行列对照。特别提醒“长数字显示成科学计数法”这个坑,身份证号、订单编号这类数字,一旦超过 Excel 默认的 11 位数字上限,就会被转成科学计数法,用户根本看不清原值。
解决办法是在拼二维数组时,把这些字段统一转成字符串:
const formatIdCard = (value) => { return `"${value}"` }注意这里不是真的引出双引号,而是一个取巧的写法:当 xlsx 库检测到某个单元格的值是字符串类型时,Excel 就不会再做数字类型转换。所以只要你把原始值通过String()包一层,问题就解决了。
4.2 导出文件打不开:隐藏的 DOM 表格污染问题
有次我遇到一个诡异的问题:某个表格页导出后的文件,个别电脑能打开,个别电脑直接报文件损坏。一开始怀疑是浏览器版本差异,后来逐行排查数据才发现,问题出在表格里某个单元格内容包含了特殊字符,这些字符在解析时干扰了工作表的 XML 结构。
这类问题在纯 DOM 方案里更容易出现,因为表格里可能会有浮层、按钮组、标签这些非文本元素。table_to_sheet会把它们的嵌套结构也一并解析进去,导出的数据里混进了多余的层级。
我的建议是:
- 表格内部放按钮、链接、标签等组件时,导出逻辑不要依赖
table_to_sheet。 - 改用从数据源直接拼接二维数组的方案,从根源上控制导出内容。
- 如果确实需要导出更多内部 DOM 内容,先手动提取文本,再交给
aoa_to_sheet。
4.3 导出过程中页面状态管理:按钮防重复与 loading 时机
导出的数据量一旦上千,生成文件的过程需要一点时间。用户手一抖连点三次导出按钮,浏览器就会排队下载三个文件,既难看又浪费资源。
更重要的时机是:如果文件生成逻辑是同步的,loading 动画是在导出结束后才关闭的,用户看到的画面是点击后页面卡住、没有反馈、几分钟后突然下载。
更好用的做法是整个过程用异步控制:
- 点击导出,立即显示 loading。
- 用 setTimeout 或 requestAnimationFrame 把生成文件的同步逻辑包一层,让 loading 先渲染到页面。
- 导出完成后关闭 loading。
const handleExport = async () => { if (exporting.value) { ElMessage.warning('正在导出中,请勿重复操作') return } exporting.value = true try { // 先把提取数据的逻辑切成异步,给 loading 渲染时间 await nextTick() exportExcel() } finally { exporting.value = false } }这里的exporting是一个 boolean 类型的响应式变量,用它做互斥锁,比单独禁用按钮更可靠。因为按钮禁用状态在某些极端情况下会被重置,而互斥锁的逻辑在函数入口出口都做校验,双保险。
4.4 关于依赖版本的一个提醒
前面提到过,npm 上 xlsx 的社区版版本停留在某个历史 commit。它工作正常,但有两类情况你需要额外注意:
第一种,如果项目里用了pnpm且开启了严格依赖隔离,某些版本下XLSX.writeFile可能会报无法访问全局对象的错误。解决办法是在引入时显式指定默认导出:
import * as XLSX from 'xlsx'第二种,如果你的项目对包体积极其敏感,可以按需引入 xlsx 的核心函数:
import { utils, writeFile } from 'xlsx'这两种方式都能绕过模块解析的边界问题。但说实话,遇到这类问题的人并不多,如果你的项目没踩过,不必提前做这些优化。”
5. 从导出 Excel 到通用导出模块的封装思路
导出一个表格不难,难的是每个页面都要导出时,如何不写大量重复代码。如果你负责的项目里有十几个列表页,每个页面都抄一份exportExcel函数,后续想改文件名规则、加统一 loading、换导出格式,就得全局搜索替换,维护成本立刻上来了。
5.1 抽一个 useExport 组合式函数
在 Vue 3 里,组合式函数是抽公共逻辑的标准形态。我把导出逻辑封装成useExport,导出函数接收列配置和数据源,返回一个导出方法,页面里只用传参数,其他地方完全不用关心 xlsx 内部发生了什么。
import { utils, writeFile } from 'xlsx' export const useExport = () => { const exporting = ref(false) const exportData = async (columns, dataSource, fileName) => { if (exporting.value) { return } exporting.value = true try { const header = columns.map(col => col.label) const rows = dataSource.map(item => columns.map(col => item[col.prop])) const worksheet = utils.aoa_to_sheet([header, ...rows]) const workbook = utils.book_new() utils.book_append_sheet(workbook, worksheet, 'Sheet1') writeFile(workbook, fileName) } finally { exporting.value = false } } return { exporting, exportData } }页面里的调用方式简洁清晰:
const { exporting, exportData } = useExport() const handleExport = () => { const columns = [ { prop: 'name', label: '姓名' }, { prop: 'age', label: '年龄' } ] exportData(columns, tableData.value, '用户列表.xlsx') }这套封装的收益不只是省代码。列配置在页面里往往和 el-table 的 column 定义高度重合,理想情况下能做到“一套配置两处使用”,页面上展示什么,导出就导什么。这个思路建议尽早落实,别等到导出逻辑散落十几个页面之后再来重构。
写在最后,算是我个人摸索这个功能下来的一点体会:前端导出 Excel 最迷人的地方在于,它把“后端才能干的活”拉到了浏览器里。但正因为入口太简单,很多人忽略了数据格式化、大数据量优化、用户体验这些细节。如果你在落地过程中也遇到奇怪的导出问题,优先去检查数据源而不是导出函数本身——大部分坑,其实都是数据的问题。