做前端开发这些年,“导出Excel”这个需求几乎在每个后台管理系统里都出现过。早几年大家习惯把导出做成后端接口,前端拿个返回值就完事。后来数据中台、在线报表这类项目越做越多,我慢慢发现一个规律:很多场景下,数据早就在浏览器内存里了,为了导出一份表格再走一次后端、写接口、处理临时文件,真的没有必要。
纯JS导出Excel工具,听起来好像是个小功能,但真正落地时涉及的细节能写一篇长文:文件结构是什么、样式怎么控制、大数据量会不会卡死、特殊字符会不会乱码、公式能不能带过去。这篇文章就把我从零实现、踩坑、再到优化的完整过程讲一遍。无论你是刚接触前端的新手,还是已经在业务系统里写过导出的老手,应该都能找到能直接拿去用的东西。
1. 方案选择与技术拆解
1.1 为什么不用后端导出
先说个反直觉的结论:大部分普通导出需求,用后端实现反而更费劲。你要传参数、写临时接口、后端用POI或者EasyExcel拼数据、生成文件、再走文件下载流。如果同时有几十个人点导出,后端要开一堆临时文件,还得定时清理。这套流程在业务简单的时候没什么,但很容易把一次导出变成一次接口联调。
而纯前端导出最大的优势是:数据已经在前端了。查询接口返回的数组、表格组件里的筛选结果、图表平台上的统计明细,这些都是现成的JS对象。借助纯JS工具库,直接把数据转换成Excel文件,在浏览器里触发下载,零后端依赖、零服务器存储、响应速度也快。对于报表平台、数据管理后台、低代码搭建工具这类场景,我基本都会优先考虑前端方案。
当然,后端导出也不是一无是处。如果数据量特别大,比如几百万行,或者需要导出多个关联表、再做复杂的权限校验,那后端仍然是更合适的方案。前端导出适合的是数据量在几万行以内、数据结构相对简单的场景,正好覆盖了大量管理系统的日常需求。
1.2 前端导出Excel的几条技术路线对比
纯前端导出Excel,实际可以选的路线不少,但每条路线都藏着坑。我把几个常见方案整理出来对比一下:
| 方案 | 实现难度 | 功能完整度 | 兼容性 | 坑点 |
|---|---|---|---|---|
| HTML Table转图片导出 | 低 | 差,图片不可编辑 | 一般 | 表格长截图会糊、列多会被截断 |
| 导出CSV文件 | 低 | 差,不支持样式/公式 | 好,Excel可打开 | 中文乱码、长数字变科学计数法 |
| HTML转XLS(伪Excel) | 较低 | 一般,仅文本和简单样式 | 差,易触发格式警告 | 打开时提示文件损坏/格式不符 |
| 纯手写Excel XML | 中 | 中等,支持简单样式 | 一般 | 对Excel文件规范不熟悉容易写错 |
| 成熟JS库(ExcelJS等) | 低 | 高,样式/公式/合并/图表都支持 | 好 | 库体积略大,需按需处理 |
CSV方案看起来最简单,但有个问题就是它本质上是一个文本文件,不是真正的Excel。你导出的身份证号会被Excel改成科学计数法,再加一个回车换行、逗号、引号,字段内容分分钟错乱。HTML转XLS的方式早年间很流行,核心原理是把一个带有table标签的HTML页面加上XML头,伪装成xls文件,但打开时经常提示“文件格式与扩展名不匹配”,体验很差。图片方案就更不用说了,完全没有编辑能力。
真正能在前端做出“和办公软件差不多的Excel文件”的,还是用成熟的JS库。这类库是把开放的Excel文件格式(主要是Open XML格式)打包生成真正的xlsx文件。用库的好处很明显:支持设置列宽、行高、字体、背景色、边框、合并单元格、公式、条件格式、数据透视表,甚至还能往里面插图片和图表。也就是说,用户下载到的不是一张“长得像表格的网页”,而是一份正经的、可以继续编辑的Excel工作簿。
1.3 为什么选ExcelJS
我用的比较多的是ExcelJS库。它同时支持浏览器和Node.js环境,API设计比较直观,几乎可以把Excel里常见的功能都映射到代码里。另一个老牌的SheetJS(也就是xlsx库)也很流行,但它的官方npm版本在2023年之后更新策略改了,某些新版本有授权问题,公司项目里用的时候要谨慎一点。ExcelJS在MIT协议下开源,许可证干净,项目里用着安心。
从依赖体积上看,ExcelJS确实不算小,压缩后大概两百多KB,但对于一个后台管理系统来说,完全可以接受。而且现在大多数前端工程都有构建工具,配合动态import做按需加载,只有用户点到“导出”按钮时才把库加载进来,体验上也感觉不到什么负担。
另一个关键点是ExcelJS能输出ArrayBuffer,这个数据格式可以直接用来生成Blob对象,配合URL.createObjectURL触发浏览器下载。整条链路不需要后端参与,这是它很合适前端场景的核心原因。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从零实现一个可用的导出功能
2.1 安装与引入
在工程化项目里,引入ExcelJS只需要一条命令:
bash复制npm install exceljs --save
如果你的项目是纯浏览器环境,也可以用CDN方式加载,不过我更推荐用npm配合打包工具,方便动态import和按需加载。
引入方式有两种。如果你用的是ES Module:
javascript复制import ExcelJS from 'exceljs';
如果你是普通Node.js或者老项目:
javascript复制const ExcelJS = require('exceljs');
这里有一个小细节:在浏览器里使用时,ExcelJS默认会尝试使用Node.js的Buffer对象。有的打包配置下需要做一点兼容处理,否则会出现Buffer未定义的报错。我用Vite和Webpack都遇到过,解决方法通常是安装一个Buffer的polyfill,或者把ExcelJS的导入配置成按照浏览器模式处理。不过新版本ExcelJS对浏览器支持已经越来越好,大多数情况下不需要额外配置,遇到报错再针对性处理即可。
2.2 核心数据结构:列与行
使用ExcelJS的基本流程可以概括为:创建工作簿、添加工作表、定义列、添加行、设置样式、输出文件。先看一眼最核心的API:
javascript复制// 创建工作簿
const workbook = new ExcelJS.Workbook();
// 添加工作表
const worksheet = workbook.addWorksheet('销售明细');
// 定义列
worksheet.columns = [
{ header: '订单号', key: 'orderId', width: 20 },
{ header: '客户名称', key: 'customerName', width: 15 },
{ header: '商品名称', key: 'productName', width: 25 },
{ header: '数量', key: 'quantity', width: 10 },
{ header: '单价', key: 'price', width: 12 },
{ header: '金额', key: 'amount', width: 14 },
];
// 添加行
worksheet.addRow({
orderId: 'SO20240101001',
customerName: '华东贸易有限公司',
productName: '移动电源10000mAh',
quantity: 20,
price: 89,
amount: 1780,
});
这种用对象添加行的方式很好理解,key会跟columns里的key对应,顺序会自动按照列定义排列。如果列的顺序不重要,也可以用数组一次性添加多行:
javascript复制worksheet.addRows([
['SO20240101002', '华南科技', '蓝牙耳机', 15, 199, 2985],
['SO20240101003', '西部贸易', '快充充电器', 30, 49, 1470],
]);
用数组添加性能更好,因为省去了键值查找的过程。我自己做批量导出时,经常先用map方法把原始数据处理成二维数组,再一次性传给addRows,数据量大的时候效果很明显。
2.3 表头样式与合并单元格
光有数据还不够,一份合格的导出文件至少要有看得过去的表头。ExcelJS里做样式设计非常直接,核心是反复操作单元格对象。
javascript复制// 设置表头行的样式
const headerRow = worksheet.getRow(1);
headerRow.height = 24;
headerRow.eachCell((cell) => {
cell.font = { name: '微软雅黑', size: 11, bold: true, color: { argb: 'FFFFFFFF' } };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4472C4' } };
cell.alignment = { vertical: 'middle', horizontal: 'center' };
cell.border = {
top: { style: 'thin' },
left: { style: 'thin' },
bottom: { style: 'thin' },
right: { style: 'thin' },
};
});
这里需要注意的颜色标注是ARGB格式,必须用8位十六进制,前两位是透明度,后面才是RGB颜色。很多人第一次用的时候只写了4472C4,结果颜色不生效,就是这个原因。
合并单元格是另一个高频操作。典型场景是导出“带大标题的报表”——第一行是整个表格的标题,横跨所有列居中。做法是:
javascript复制// 合并第一行的A1到F1单元格
worksheet.mergeCells('A1:F1');
// 然后给合并后的区域写值
const titleCell = worksheet.getCell('A1');
titleCell.value = '2024年度销售统计表';
titleCell.font = { size: 16, bold: true };
titleCell.alignment = { horizontal: 'center', vertical: 'middle' };
合并单元格后,ExcelJS会把区域内的左上角单元格作为主单元格,写入的时候要往这个主单元格写。这个细节容易踩坑,如果你往合并区域的右上角写值,文件打开后是看不到内容的,因为那个位置已经被合并占了。
还有一个实用的技巧,是给工作表设置自动筛选功能。这样用户打开文件后,表头会自带下拉箭头,可以直接做筛选。
javascript复制worksheet.autoFilter = {
from: 'A1',
to: 'F100',
};
2.4 公式与自动筛选
把公式写进导出的Excel,是很多人没意识到但很实用的能力。比如我要导出一份销售明细,金额那一列如果希望用户在Excel里拖动公式自动计算,可以直接设置公式:
javascript复制// 第2行到第100行的金额列,设置公式 D行*E行
for (let i = 2; i <= 100; i++) {
worksheet.getCell(`F${i}`).value = { formula: `D${i}*E${i}`, result: 0 };
}
公式对象里可以传result作为缓存结果,这样Excel打开后不需要重新计算结果也能显示正确数值。如果某些场景不允许自动计算,或者用户用WPS打开时不想被“是否重新计算”的弹窗打扰,这个缓存结果就显得很重要。
自动筛选和冻结窗格也可以组合起来用:
javascript复制// 冻结第一行,滚动时表头始终可见
worksheet.views = [
{ state: 'frozen', ySplit: 1 }
];
这里要说明一下,ySplit: 1表示前1行固定不动。如果你表头占了2行,就改成2。冻结窗格在表格行数多的时候非常有用,用户拿到文件后不用来回滚动找表头。
2.5 完整示例代码
把前面的细节串起来,写成一个可复用的导出函数:
javascript复制import ExcelJS from 'exceljs';
async function exportExcel({ filename = '导出数据.xlsx', sheetName = 'Sheet1', columns, rows }) {
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet(sheetName);
// 定义列
worksheet.columns = columns.map((col) => ({
header: col.title,
key: col.key,
width: col.width || 15,
}));
// 添加行数据
worksheet.addRows(rows);
// 表头样式
const headerRow = worksheet.getRow(1);
headerRow.height = 24;
headerRow.eachCell((cell) => {
cell.font = { name: '微软雅黑', size: 11, bold: true, color: { argb: 'FFFFFFFF' } };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4472C4' } };
cell.alignment = { vertical: 'middle', horizontal: 'center' };
});
// 冻结首行
worksheet.views = [{ state: 'frozen', ySplit: 1 }];
// 导出
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
使用的时候就很清爽了:
javascript复制exportExcel({
filename: '2024年1月销售明细.xlsx',
sheetName: '销售数据',
columns: [
{ title: '订单号', key: 'orderId', width: 20 },
{ title: '客户名称', key: 'customerName', width: 15 },
{ title: '金额', key: 'amount', width: 14 },
],
rows: [
{ orderId: 'SO001', customerName: '客户A', amount: 100 },
{ orderId: 'SO002', customerName: '客户B', amount: 200 },
],
});
很多业务系统的导出需求,按照这个函数扩展一下就能覆盖。比如想在导出前把数据做一轮加工,可以在调用前用map处理rows;想加合计行,可以在rows末尾push一条带样式的汇总数据;想按不同模块导出多个sheet,再加一层循环就行。
3. 深入一点:原生JS手写Excel XML
3.1 Excel文件本身是什么
用第三方库里封装得好好的,很多人用了很久可能不知道Excel文件内部长什么样。其实xlsx文件本质上是一个zip压缩包,里面装了一大堆XML文件,包括描述工作簿结构的workbook.xml、描述工作表内容的sheet1.xml、描述样式定义的styles.xml等。这些XML文件按照Office Open XML规范组织起来,再被zip工具压缩成一个文件。
既然核心是XML,理论上不需要任何第三方库,只靠纯JS拼字符串也能生成一个Excel能打开的表格文件。当然,要生成完整的xlsx需要自己处理zip压缩,手写比较繁琐。不过有一种老式的XML格式,叫做Excel 2003 XML格式,又叫SpreadsheetML,扩展名用.xls,Excel和WPS都能打开。它不需要压缩,就是一份简单的XML文本,很适合用来理解导出原理,也适合一些轻量场景下的“手撸”导出。
3.2 用XML字符串生成可打开的.xls文件
下面是一段极简的XML模板:
javascript复制function generateExcelXml(headers, data) {
let rowsXml = '';
// 添加表头行
rowsXml += '<Row>';
headers.forEach((header) => {
rowsXml += `<Cell><Data ss:Type="String">${header}</Data></Cell>`;
});
rowsXml += '</Row>';
// 添加数据行
data.forEach((row) => {
rowsXml += '<Row>';
row.forEach((cell) => {
rowsXml += `<Cell><Data ss:Type="String">${cell}</Data></Cell>`;
});
rowsXml += '</Row>';
});
return `<?xml version="1.0"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
<Worksheet ss:Name="Sheet1">
<Table>
${rowsXml}
</Table>
</Worksheet>
</Workbook>`;
}
// 生成Blob并下载
const xml = generateExcelXml(['姓名', '年龄'], [['张三', 25], ['李四', 30]]);
const blob = new Blob(['\ufeff' + xml], {
type: 'application/vnd.ms-excel',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = '手写导出.xls';
a.click();
URL.revokeObjectURL(url);
这种方案的核心就一个模板字符串,把表头和行数据拼进去,加上XML头,导出后Excel能直接打开。优势是零依赖、代码量小、不需要等待库加载。但它的局限也很明显:样式丰富度不够,设置单元格背景色和边框比较麻烦;老式的XML格式在Excel里打开会有“格式与扩展名不符”的警告;公式和合并单元格的语法也偏老旧。
我自己平时做小工具、在线示例或临时脚本时会用这种思路,它最大的价值是帮助理解“Excel导出”的本质——我们不是在造一个Excel,而是在按规范生成一份Office能识别的描述文件。有了这层理解,后面再遇到第三方库解决不了的问题,也知道该从哪里下手排查。
3.3 Export功能的真实落地流程
实际项目中,完整的前端导出功能通常不是只写一个导出函数那么简单。一个比较成熟的落地流程大致是:
第一步,确认导出内容。先搞清楚用户是在哪个页面点的导出,导出的是筛选后的数据还是全部数据,包含哪些列,排序规则是什么。
第二步,数据处理。在调导出函数之前,把后端返回的原始数据做一次清洗转换。比如日期格式从时间戳转成YYYY-MM-DD、金额保留两位小数、部分字段为空时显示成“-”。
第三步,调用导出函数生成文件。这一步根据选型不同,可能只是调一个函数,也可能需要在缓存里把库动态加载进来。
第四步,补充使用说明。虽然导出功能本身不是复杂交互,但要注意给用户设置合理的默认文件名、默认sheet名。我见过很多系统导出的文件名就叫undefined.xlsx或者新建文件.xlsx,这种体验实在算不上好。
第五步,埋点与错误捕获。导出这个过程虽然在前端,但如果有异常(比如数据为空、处理超时),同样需要弹友好的提示,同时在控制台输出错误信息,方便排查问题。
4. 大数据量导出的性能调优
4.1 分批写入与复用样式
几百行数据导出,ExcelJS毫无压力。但一旦到几万行,或者带复杂样式的表格,就能明显感受到卡顿。这时首先要做的优化是调整写入方式。
一次性构造一个大数组,然后通过worksheet.addRows(rows)批量添加,比用for循环逐行addRow快很多。原因是批量添加可以减少内部的数据结构重排次数。具体对比我就不贴代码了,简单说结论:数据量在5000行以上时,批量添加和逐行添加的性能差距能达到几倍。
另一个容易被忽略的优化点是样式对象的复用。比如要给10000行数据的每一行都设置边框,如果每行都重新创建一份border对象,内存消耗会非常大。正确做法是提前定义好样式对象,然后用对象引用去赋值:
javascript复制const commonBorder = {
top: { style: 'thin' },
left: { style: 'thin' },
bottom: { style: 'thin' },
right: { style: 'thin' },
};
worksheet.eachRow((row) => {
row.eachCell((cell) => {
cell.border = commonBorder;
});
});
因为多个单元格引用的是同一个JavaScript对象,底层会复用同一份样式定义,文件体积和内存占用都能降下来。
4.2 用Web Worker减轻主线程压力
当数据量进一步增大,数据处理逻辑又很复杂时(比如要从几十个字段里抽出导出字段、要做格式化),主线程会一直被占用,页面出现白屏、无法点击。这种情况可以把数据处理的步骤放到Web Worker里。
大致思路是:主线程把原始JSON数据发送给Worker,Worker里只做数据清洗和转换,最后把处理好的二维数组postMessage回主线程,主线程再交给ExcelJS完成导出。注意ExcelJS本身主要还是在主线程操作,所以Worker只做数据预处理,不要试图在Worker里创建Workbook,那样反而容易踩Buffer和DOM相关的坑。
代码骨架大概是这样:
javascript复制// worker.js
self.onmessage = (event) => {
const rawData = event.data;
const processed = rawData.map((item) => [
item.orderId,
item.customerName,
formatDate(item.createTime),
item.amount.toFixed(2),
]);
self.postMessage(processed);
};
// 主线程
const worker = new Worker('./worker.js');
worker.postMessage(rawData);
worker.onmessage = (event) => {
// event.data 就是处理好的二维数组
exportExcel({ rows: event.data, columns });
worker.terminate();
};
这样页面不会因为数据处理而卡顿,用户点击导出后还能继续操作界面。实测下来,超过5万行数据的时候,开启Worker体验差异非常明显。
4.3 数据量太大时的兜底策略
纯前端导出不是万能的。当数据量到了几十万行甚至上百万行时,浏览器内存首先扛不住,Blob对象也会超出浏览器的下载限制。这时候就别硬撑了,最优解是转回后端。
我自己一般以一个经验值来划分:数据行数在1万以内,直接用ExcelJS;1万到5万,用批量添加加样式复用,配合Worker处理;5万到10万,先评估列数和单元格样式复杂度,简单表格还能试,复杂表格建议后端;超过10万,前端基本不考虑,直接走后端导出。
还有另一种变通方案是前端导出CSV。CSV文件本质上就是纯文本,生成速度和内存占用都比xlsx低很多,几十万行数据也能轻松生成。缺点是样式和公式全丢。如果你确定用户只需要“拿到数据做二次处理”,CSV完全够用。但要注意加BOM头,避免Excel打开中文乱码:
javascript复制const blob = new Blob(['\ufeff' + csvContent], {
type: 'text/csv;charset=utf-8;',
});
5. 常见问题与避坑速查表
5.1 一个表格解决大部分问题
做纯JS导出Excel这段时间,我把遇到的典型问题整理成了一张表,项目里遇到类似问题可以直接对着排查:
| 问题现象 | 根本原因 | 解决方法 |
|---|---|---|
| 打开文件提示已损坏 | Blob的type类型不对,或writeBuffer不完整 | 使用application/vnd.openxmlformats-officedocument.spreadsheetml.sheet |
| 中文乱码(CSV)/ 中文文件名乱码 | 缺少BOM头 / 文件名编码问题 | CSV内容加\ufeff,文件名避免特殊符号 |
| 身份证号/订单号变成科学计数法 | Excel默认将长数字转为科学计数法 | 把这些字段的单元格格式设为文本:numFmt: '@' |
| 日期显示成一串数字 | 单元格没有设置日期格式 | 设置numFmt: 'yyyy-mm-dd'或yyyy/mm/dd |
| 合并单元格后内容不显示 | 往非主单元格写入了内容 | 只往合并区域左上角单元格写值 |
| 导出几万行时页面卡死 | 数据处理或样式创建耗时 | 用Worker预处理、批量addRows、复用样式对象 |
| Safari下载文件名不正确 | 兼容性原因 | 使用window.open(url)或手动设置下载属性 |
| 颜色设置不生效 | 颜色格式写成了RRGGBB | 使用8位ARGB格式,如FF4472C4 |
| 公式不显示结果 | 没有提供result缓存值 | 公式对象同时传result值 |
| 导出的文件WPS打开样式错乱 | 兼容性差异 | 尽量使用基础样式,表格边框、字体用常见格式 |
5.2 几个容易被忽略的细节
第一点,下载触发后一定要调用URL.revokeObjectURL(url)。这个API是负责释放浏览器内部创建的临时URL的,如果不调用,大型导出频繁操作时内存占用会持续上涨,最终可能导致浏览器崩溃。我见过一个长期运行的后台系统,导出功能越用越卡,排查到最后就是这里漏了。
第二点,文件名不要在用户输入后直接拼接。有些系统支持用户自定义文件名,但用户输入了/、?、*这些Windows文件名非法字符,下载会直接失败。最好在生成文件名时做一个过滤,或者干脆用固定格式加时间戳。
第三点,导出之前务必判断数据是否为空。空数据导出会生成一个只有表头的Excel文件,有些场景下用户会误以为导出出错。更好的做法是,在数据为空时弹提示“暂无数据可导出”,既不浪费时间生成文件,也避免误解。
第四点,业务系统的导出按钮要加Loading状态。ExcelJS生成文件看起来是瞬时的,但数据量大时耗时能达到两三秒甚至更久,期间用户如果狂点按钮,浏览器会反复创建多个下载任务,体验非常差。用防抖或者点击后置灰都能解决。
第五点,我建议在开发环境把导出文件的中间数据打印到控制台看一眼。特别是涉及复杂格式、合并单元格、公式的时候,双击打开Excel文件看效果当然也是必要的,但控制台看一眼结构能更快定位是数据问题还是模板问题。
最后再分享一个真实案例。之前给一个运营后台做过一份包含几万行销售明细的导出功能,前端导出后用户经常反馈文件打开很慢。后来我在导出前把数据里的冗余字段全部去掉,只保留8列,文件名也改成带日期前缀的格式,文件体积直接从十几MB降到三MB左右。优化之后,用户下载和打开的速度都快了很多。纯JS导出Excel这件事,上限不在工具,往往在自己对业务和数据的理解上。把字段精简到用户真正需要的,把格式调整到最适合查看的,有时候比堆更多的高级特性更管用。
