相信不少前端同行都遇到过这类需求:页面上有个表格,领导说“给我导出一份 Excel”,然后你第一反应是找后端同事出接口。但很多时候,后端要么排期紧张,要么只是临时要个数据,为这点事走一轮前后端联调,实在太重了。
在这类场景下,“纯JS导出Excel”就成了一个特别实际的选择——不需要后端参与,不依赖复杂服务,甚至不用装任何桌面软件,浏览器里拿到数据直接生成 xlsx 文件。这就是这篇文章要聊的核心:一个纯粹跑在前端的 Excel 导出工具,能帮你解决什么问题,底层逻辑是什么,以及在实际项目中怎么落地。
这篇文章适合三类人看:一是在做后台管理系统、经常要导报表的前端工程师;二是被产品经理临时加需求、又不想给后端添麻烦的同学;三是刚接触前端数据导出、想搞明白原理的新手。看完你不仅能照抄代码,还能在遇到问题时自己排查。
1. 为什么选择纯前端导出:方案对比与适用边界
1.1 后端导出的痛点与纯JS方案的优势
先说一个很常见的场景。你拿着需求去找后端,后端通常会问:“导出多少条数据?要不要校验权限?报表模板长什么样?”等你回答完“大概几千条、就按页面上的数据导就行”,对方很可能回你一句:“那你前端直接生成不就行了?”
这句话其实点出了纯前端导出的最大优势:数据原本就已经在页面上了,前端直接操作这些数据,不需要再走一遍“浏览器 → 服务器 → 文件流 → 浏览器下载”的完整链路。少了网络请求,少了服务器内存消耗,也不需要后端额外写一个接口、处理导出逻辑、考虑文件名编码、再配一个下载接口。整个导出过程从前端发起、在前端完成,服务端负载为零,这在中小型项目中非常划算。
那什么时候不建议用纯JS?如果你要导出的数据量极大,比如几十万行甚至上百万行,浏览器内存扛不住,或者数据本身需要在服务端做聚合、脱敏、权限控制,这时候老老实实走后端更稳。纯JS方案擅长的是“中等数据量、数据已在前端、中等样式要求”的场景,这个边界要心里有数。
1.2 常见导出方案的横向对比
下面聊聊市面上的几种主流方案,我按实用度排个序,顺便给个参数对比,方便你做选择。
| 方案 | 实现方式 | 样式支持 | 文件大小 | 性能表现 | 适用场景 |
|---|---|---|---|---|---|
| 后端生成(POI/Aspose等) | 服务端返回文件流 | 强,支持复杂模板 | 服务端生成,客户端无感 | 适合大数据量 | 需要权限控制、数据聚合、复杂报表 |
| SheetJS(xlsx社区版) | 纯前端解析/生成 | 弱,不支持单元格样式 | 约200KB(gzip后更小) | 中等数据量表现不错 | 快速导出、批量数据下载 |
| ExcelJS | 纯前端生成 | 较强,支持颜色边框字体 | 体积稍大 | 大数据量需要优化 | 需要表头样式、合并、冻结等进阶需求 |
| 表格转图片 | html2canvas 截图 | 与网页渲染完全一致 | 图片文件 | 受截图尺寸限制 | 需要“所见即所得”的颜值导出 |
| CSV | 纯文本导出 | 无样式 | 极小 | 极快 | 数据量超大时的降级方案 |
从表里能看出来,纯前端方案里,SheetJS 和 ExcelJS 是主力,前者轻量快速、后者样式能力强。实际项目里,我经常是 SheetJS 打底,样式要求一上来就换 ExcelJS,两者不是替代关系,是互补关系。
另外多说一句 CSV。很多人忽略了一个事实:Excel 本身可以直接打开 CSV 文件。如果你的需求只是“把表格数据导出给用户存档”,并且用户也不在乎样式,那么 CSV 才是最优解——文件体积小、生成极快、内存占用低。等需求真的升级到“表头要红色、列宽要固定、要有筛选按钮”,再上 ExcelJS 也不迟。
1.3 xlsx 文件到底是什么:理解底层能帮你避开很多坑
要真正会用好导出工具,建议先花两分钟理解 xlsx 的文件结构。很多人觉得“导出Excel”很神秘,其实 xlsx 本质就是一个 zip 压缩包,里面装着多个 XML 文件,其中核心的有这几个:
xl/workbook.xml:描述工作簿信息,比如包含几个工作表、工作表顺序xl/worksheets/sheet1.xml:真正的表格数据,行列内容都在这里xl/styles.xml:样式定义,单元格颜色、字体、边框等xl/sharedStrings.xml:共享字符串表,所有文本内容都在这里登记
前端导出工具做的事情,本质上就是“帮你把这些 XML 文件拼好、压缩成 zip、再给浏览器触发下载”。理解这个原理之后,很多奇怪问题就说得通了,比如“为什么导出文件打不开?”——多半是 XML 结构坏了;再比如“为什么纯前端生成的没有样式?”——因为 styles.xml 要么没生成,要么生成的版本不完整。后面排查问题的时候,这个底层认知会非常有用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 用 SheetJS 快速实现导出:从零到能跑
2.1 引入方式与项目准备
SheetJS 社区版在 npm 上叫 xlsx,版本停在 0.18.5 左右,功能虽然不再更新,但对导出场景来说完全够用。引入方式有两种,看你的项目环境。
如果你的项目已经用了 npm,直接在项目里安装:
bash复制npm install xlsx@0.18.5
然后在代码里引入:
javascript复制import * as XLSX from 'xlsx';
如果你只是写个单页测试,或者项目里没有构建工具,也可以直接通过 CDN 引入:
html复制<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
引入完之后,全局会挂一个 XLSX 对象,之后所有 API 都通过它来调用。这里提一句,社区版和付费的企业版功能差异很大,企业版支持样式写入和更多高级功能,但普通项目用社区版导出数据完全够,没必要为这事儿付费。
2.2 最基础的导出:数组数据一步搞定
先来一个最经典的写法。假设后端返回了一个二维数组 [[姓名, 年龄, 城市], ...],你想把它导出成 xlsx,核心代码只有四行:
javascript复制// 1. 把二维数组转成工作表
const ws = XLSX.utils.aoa_to_sheet([
['姓名', '年龄', '城市'],
['张三', 25, '上海'],
['李四', 30, '北京']
]);
// 2. 创建空的工作簿
const wb = XLSX.utils.book_new();
// 3. 把工作表挂到工作簿上,并给工作表起个名字
XLSX.utils.book_append_sheet(wb, ws, '用户列表');
// 4. 生成文件并触发下载
XLSX.writeFile(wb, '用户列表.xlsx');
这段代码跑完之后,浏览器会自动下载一个叫”用户列表.xlsx“的文件,用 Excel 打开就能看到数据。整个过程完全没有服务器参与,前端自己就把文件造出来了。
这里重点说一下 aoa_to_sheet 里的 aoa 是什么意思——它是 Array of Arrays 的缩写,也就是“数组的数组”。第一行作为表头,后面的行作为数据行。这种写法适合你手里刚好是二维数组的情况,比如从表格插件里拿到的数据。
但如果你的数据源是后端返回的对象数组,比如 [{ name: '张三', age: 25 }, ...],用 XLSX.utils.json_to_sheet 更方便,它会自动把对象的键名作为表头:
javascript复制const data = [
{ name: '张三', age: 25, city: '上海' },
{ name: '李四', age: 30, city: '北京' }
];
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '用户列表');
XLSX.writeFile(wb, '用户列表.xlsx');
两种方法的使用场景差异就一条:数据源是二维数组就用 aoa_to_sheet,是对象数组就用 json_to_sheet。前者灵活,可以直接控制表头文字;后者省事,手写代码少。实际项目里两者出现频率几乎五五开。
2.3 下载触发机制与兼容性处理
XLSX.writeFile 这行代码看起来简单,但它背后做了一件很重要的事:生成 Blob → 创建 object URL → 创建一个隐藏的 <a> 标签 → 模拟点击 → 触发浏览器下载。整个过程属于浏览器端的标准下载机制,体验依赖浏览器环境。
有个兼容性细节值得注意:老版本浏览器对 a.download 属性的支持不完整,可能会直接在当前窗口打开文件而不是触发下载。不过现在主流浏览器(Chrome、Firefox、Edge、Safari 的新版本)都已经支持得很好,除非你的用户还在用很老的浏览器,否则不需要额外做兼容。
如果你不希望直接用 writeFile,也可以手动控制下载过程,这在后面“常见问题”章节排查问题时会用到:
javascript复制const wbout = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
const blob = new Blob([wbout], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = '用户列表.xlsx';
a.click();
URL.revokeObjectURL(url);
手动控制的好处是可以在下载前做更多处理,比如设置文件名编码、加 BOM 头等。这些坑后面会细说,这里先留个印象:writeFile 能解决 80% 场景,剩下 20% 需要手动下载逻辑来实现。
3. 让导出的文件更像“正经报表”:样式与布局的进阶玩法
3.1 先说清楚:SheetJS 社区版到底能不能做样式
这是很多新人最容易踩坑的地方。我在无数技术群里看到有人问:“我用 xlsx 库导出了 Excel,为什么表头没有背景色、字体也不加粗?网上教程里明明能设置样式啊?”
答案是:SheetJS 社区版不支持写入单元格样式。styles.xml 的读取和写入是企业版的功能,社区版的 write 选项里虽然有 cellStyles,但实测只能读取部分样式信息,不能写入。所以如果你用的是社区版,放弃通过它设置字体颜色、背景色、边框这类需求,别浪费时间。
那问题就来了:如果需求就是要带颜色、带边框的“漂亮 Excel”,怎么办?下面几种是我实测过、比较靠谱的路子。
3.2 用 ExcelJS 实现带样式导出
ExcelJS 是另一个纯前端的 Excel 生成库,它最大的优势就是支持样式,而且 API 设计得非常直观。我贴一个最常用的用法,再解释关键点:
javascript复制import ExcelJS from 'exceljs';
async function exportWithStyle() {
// 1. 创建工作簿
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('用户报表');
// 2. 定义列与表头
sheet.columns = [
{ header: '姓名', key: 'name', width: 15 },
{ header: '年龄', key: 'age', width: 10 },
{ header: '城市', key: 'city', width: 20 }
];
// 3. 给表头设置样式:加粗、背景色、居中
const headerRow = sheet.getRow(1);
headerRow.font = { bold: true, color: { argb: 'FFFFFFFF' } };
headerRow.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FF4472C4' }
};
headerRow.alignment = { horizontal: 'center', vertical: 'middle' };
headerRow.height = 25;
// 4. 写入数据
sheet.addRow({ name: '张三', age: 25, city: '上海' });
sheet.addRow({ name: '李四', age: 30, city: '北京' });
// 5. 冻结首行,方便翻阅大数据
sheet.views = [{ state: 'frozen', ySplit: 1 }];
// 6. 生成文件
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 = '用户报表.xlsx';
a.click();
URL.revokeObjectURL(url);
}
这个方案里,sheet.columns 定义列结构,headerRow.font 和 headerRow.fill 分别是字体和背景色,alignment 控制居中,views 设置冻结窗格。这样生成的文件,表头是白字蓝底居中的,还带冻结首行,已经非常接近“正经报表”的样子了。
ExcelJS 还支持单元格合并(sheet.mergeCells('A1:C1'))、边框线(cell.border)、数据验证(sheet.dataValidations.add(...))等高级能力,这些都不是 SheetJS 社区版能实现的。代价是库的体积更大,性能也比 SheetJS 差一些,所以大数据量场景下要谨慎,后面章节会专门讲优化。
3.3 不需要样式的替代方案:CSV 导出与图片导出
如果你的需求只是“把数据交给用户”,对样式零要求,那我强烈建议你考虑直接导出 CSV。CSV 本质是一个纯文本文件,用逗号分隔字段,Excel 打开没有任何问题,而且文件小、生成快,代码也比 xlsx 简单得多:
javascript复制function exportCSV(data, filename) {
// data 是二维数组,第一行是表头
const csvRows = data.map(row =>
row.map(cell => {
// 如果内容里有逗号、引号、换行,需要用双引号包裹
if (typeof cell === 'string' && /[",\n]/.test(cell)) {
return `"${cell.replace(/"/g, '""')}"`;
}
return cell;
}).join(',')
);
const csvString = '\ufeff' + csvRows.join('\n'); // 加 BOM 防中文乱码
const blob = new Blob([csvString], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
注意代码里的两个细节:一是当单元格内容里包含逗号、双引号、换行时,必须用双引号包裹,并且把内部的双引号替换成两个双引号,这是 CSV 的标准转义规则,不处理的话文件会错乱;二是字符串前面加 \ufeff(BOM 头),否则用 Excel 打开 CSV 时中文会乱码。这两个坑我当年都踩过,写出来给各位提个醒。
如果需求是“导出出来的东西要跟页面上长得一模一样”,那就别折腾 Excel 了,直接截图导出成图片更省事。用 html2canvas 把表格 DOM 转成 canvas,再输出为 png 就好。代价是图片不可编辑、数据不可检索,本质上适合“展示型需求”,比如导出数据看板、图表截图这种。真要编辑数据,还是得回去用 ExcelJS 或 SheetJS。
3.4 隐藏技巧:SheetJS 也能做的基础布局
虽然 SheetJS 社区版不能调单元格的字体颜色,但它其实支持一部分“表格级布局”能力,只是很多人不知道。
列宽可以通过 ws['!cols'] 设置:
javascript复制ws['!cols'] = [{ wch: 15 }, { wch: 10 }, { wch: 20 }];
行高可以通过 ws['!rows'] 设置:
javascript复制ws['!rows'] = [{ hpt: 25 }];
合并单元格可以通过 ws['!merges'] 设置:
javascript复制ws['!merges'] = [
{ s: { r: 0, c: 0 }, e: { r: 0, c: 2 } } // 第一行前三列合并
];
这些能力适合用来做标题行横跨多列、统一列宽行高这类基础排版。如果你的需求只是“标题居中、列宽合理”,那用 SheetJS 就够了,既能保证性能,代码量也更少。我一般会先把需求拆开看:只要布局不要颜色 → 用 SheetJS;要颜色要边框 → 直接上 ExcelJS。
4. 常见问题与排查技巧实录
4.1 中文乱码问题
用 CSV 导出的时候中文乱码,十有八九是没加 BOM 头。解决办法很简单,生成 CSV 字符串时在最前面加 \ufeff。但如果你在手动用 Blob 下载 xlsx 文件时遇到乱码,情况就不一样了——那不是编码问题,而是 type 字段设置不对。正确做法是:
javascript复制const blob = new Blob([wbout], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
别用 text/plain 或者其他流式 MIME 类型。MIME 类型不对会导致浏览器把文件当文本解析,打开就乱码。另外,xlsx 是 zip 压缩包,它内部对文本的编码已经处理好了,基本不需要你再做字符串编码转换,除非你导出的是 CSV。
4.2 长数字变科学计数法或精度丢失
这个坑出现过太多次了。银行卡号、身份证号、订单号这类超过 15 位的数字,直接写入 Excel,打开后要么显示科学计数法,要么末尾几位变成 0。原因很简单:Excel 内部对数字的精度有限制,超过 15 位就会丢精度。
解决思路是:导出前把这类“长数字”统一转成字符串。用 json_to_sheet 时,提前把数据的相应字段转成字符串;用 aoa_to_sheet 时也一样,在数组里就转好。如果使用的是 ExcelJS,还可以通过列的 style.numFmt 指定文本格式:
javascript复制sheet.columns = [
{ header: '订单号', key: 'orderNo', width: 20, style: { numFmt: '@' } }
];
numFmt: '@' 就是强制单元格使用文本格式,这样长数字就不会被 Excel 自动转成数值,精度也就保住了。这个细节在导出订单数据、财务数据时非常关键。
4.3 日期显示成文本而不是日期格式
如果你在数据里直接写 '2024-03-15' 这种字符串,SheetJS 会把它当成文本处理,Excel 打开后单元格类型是文本,无法按日期筛选或排序。想让它成为真正的日期单元格,需要在写入前把字符串转成 JavaScript Date 对象,或者手动设置单元格类型:
javascript复制// 方案一:转成 Date 对象
const dateStr = '2024-03-15';
const jsDate = new Date(dateStr + 'T00:00:00'); // 避免时区偏移
// 写入时 SheetJS 会自动识别为日期类型
// 方案二:用 ExcelJS 并设置 numFmt
sheet.getCell('A2').value = new Date('2024-03-15T00:00:00');
sheet.getCell('A2').numFmt = 'yyyy-mm-dd';
这里有个小细节:new Date('2024-03-15') 在浏览器里可能被解析成 UTC 时间,在非中国时区环境下日期会偏移一天。保险做法是在字符串后面补 T00:00:00,强制按本地时间解析,然后再交给导出库处理。
4.4 大数据量导出导致页面卡死
当导出的数据条数上万甚至几十万时,纯前端生成会很消耗 CPU 和内存,页面卡顿、浏览器崩溃都有可能出现。这个问题没有银弹,但有几个实操手段可以缓解:
第一,拆分数据源。不要一次性把整个二维数组传给 aoa_to_sheet,而是分批构造。比如用一个循环逐行往数组里 push,或者用 ExcelJS 的 addRow 一行行加。这样做虽然总耗时没减少,但内存峰值会降低很多。
第二,把生成过程放到 Web Worker 里执行。这样 Excel 文件生成不占用主线程,页面不会卡死,只是写起来稍微麻烦一点,需要在 Worker 内部引入文件并处理消息通信。手上有复杂报表需求的朋友可以考虑这个方案。
第三,提前跟产品对齐数据量级。如果单次导出超过 5 万行,至少要考虑后台分页导出或者文件流式处理。纯前端方案适合中小型数据,硬撑大数据量对用户体验伤害很大。
4.5 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| CSV 中文乱码 | 缺少 BOM 头 | 在字符串前加 \ufeff |
| xlsx 下载后被破坏 | Blob MIME 类型不对 | 用 application/vnd.openxmlformats-officedocument.spreadsheetml.sheet |
| 长数字精度丢失 | Excel 数字精度限制 | 导出前转字符串,或 ExcelJS 用 numFmt: '@' |
| 日期显示为文本 | 字符串未转 Date | new Date('2024-03-15T00:00:00') + 设置 numFmt |
| 导出文件没样式 | SheetJS 社区版不支持样式 | 换 ExcelJS 或调整需求 |
| 浏览器完全没反应 | 数据量过大导致内存爆掉 | 分批处理、Web Worker、或走 CSV |
这张表是我平时遇到问题时的排查起点,新手可以直接对照着查。实际开发里,导出功能的报错大多是数据格式问题而不是库本身的问题,先把数据类型理清楚,很多坑都能避开。
5. 让导出更专业:多工作表、过滤、超链接等进阶能力
5.1 用 SheetJS 生成多个工作表
在很多实际需求里,用户不只导一张表出来,比如“导出周报”可能是“销售数据 + 客户清单 + 本月汇总”三个 Sheet 一起打包。用 SheetJS 实现起来非常简单,就是多次调用 book_append_sheet:
javascript复制const ws1 = XLSX.utils.json_to_sheet(salesData);
const ws2 = XLSX.utils.json_to_sheet(customersData);
const ws3 = XLSX.utils.json_to_sheet(summaryData);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws1, '销售数据');
XLSX.utils.book_append_sheet(wb, ws2, '客户清单');
XLSX.utils.book_append_sheet(wb, ws3, '本月汇总');
XLSX.writeFile(wb, '周报.xlsx');
同一个工作簿里,每个工作表还能单独设置列宽和合并单元格,互不影响。多工作表导出的代码量几乎没有增加,但用户体验会好很多,特别是在数据种类多、需要分类展示的场景下。
5.2 用 ExcelJS 做下拉列表、超链接与自动筛选
ExcelJS 不只支持基本的样式,还支持很多提升专业度的功能。比如给单元格添加下拉列表:
javascript复制sheet.dataValidations.add('B2:B100', {
type: 'list',
allowBlank: true,
formulae: ['"男,女"']
});
上面这段代码的意思是在 B2 到 B100 这个范围内设置一个下拉选择,可选值是“男”和“女”,用户填表的时候可以直接下拉选,减少手输错误。这在做数据收集模板时非常实用。
再比如给单元格添加超链接:
javascript复制sheet.getCell('A1').value = {
text: '点击访问',
hyperlink: 'https://example.com'
};
自动筛选也是高频需求,Excel 里的“筛选”按钮,在 ExcelJS 里一行代码搞定:
javascript复制sheet.autoFilter = { from: 'A1', to: 'C100' };
这些功能叠加起来,导出的文件已经接近一个“可用的数据管理工具”,不只是简单的一张数据表。对于需要把数据分发出去给其他同事填写的场景,这种进阶能力非常加分。
5.3 文件名的坑:特殊字符与非法字符处理
导出文件名的处理看起来简单,实际坑很多。Windows 系统不允许文件名包含 \ / : * ? " < > | 这些字符,如果不做处理,浏览器虽然会正常下载,但用户保存时可能遇到问题。
我习惯在生成文件名前做一次清洗:
javascript复制function safeFileName(name) {
return name.replace(/[\\/:*?"<>|]/g, '_');
}
const fileName = safeFileName('销售报表 / 3月') + '.xlsx';
注意,上面例子里的文件名因为带了斜杠 /,清洗后会变成“销售报表 _ 3月.xlsx”。这个细节看起来小,但在用户把文件保存到服务器或共享盘时能少一堆麻烦。下载的文件名如果包含非法字符,在某些企业网盘里还会上传失败,所以最好统一做一次清洗。
6. 大数据量导出优化:内存、性能与用户体验的综合策略
6.1 性能瓶颈在哪里:先定位再优化
导出功能性能差的表象是“页面卡死”、“浏览器转圈”、“等了半天没反应”。但你得先搞清楚瓶颈在哪一段,才能对症下药。性能消耗主要发生在三个环节:
第一是数据量本身。如果你的数据源是一口气从后端拉回来的几万条 JSON,那么数据在内存里已经占了不少空间,再转成工作表的时候需要复制一份,峰值内存直接翻倍。
第二是转换为工作表的过程。aoa_to_sheet 或 json_to_sheet 会遍历整个数据源,为每个单元格创建一个对象,这些对象包含值、类型、坐标等信息。数据量越大,对象越多,内存占用越高,GC 压力也越大。
第三是压缩成 xlsx 的过程。把 XML 文件压缩成 zip,CPU 开销主要集中在压缩算法上,数据量越大越明显。
定位瓶颈的方式也很简单:在几个关键步骤前后打 console.time 和 console.timeEnd,先看哪一步耗时最长。实测下来,大部分场景都是第二步“转换工作表”占大头,因为它是纯粹的 JS 对象操作,需要分配大量内存。
6.2 流式写入与分批处理的实战技巧
如果用的是 ExcelJS,addRow 本身有流式写入的潜力。你可以构造一个数据源,一行一行往里加:
javascript复制const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('大数据');
// 模拟分批获取数据
for (let i = 0; i < TOTAL_ROWS; i += BATCH_SIZE) {
const batch = fetchData(i, BATCH_SIZE); // 每次只取一批数据
batch.forEach(row => sheet.addRow(row));
// 给浏览器喘息的机会,避免长时间占用主线程
await new Promise(resolve => setTimeout(resolve, 0));
}
这个写法有两个要点:一是不要一次性把所有行都构造好再写,而是来一批写一批,降低内存峰值;二是每处理完一批数据就主动让出主线程(setTimeout 或 requestIdleCallback),避免页面长时间无响应。用户在下载期间还能正常滚动页面、点击按钮,体验会好很多。
如果数据量真的超大,比如超过 10 万行,我建议退一步考虑 CSV 导出或者服务端导出。CSV 是文本拼接,内存占用比 xlsx 小一个量级,10 万行 CSV 也就几 MB,浏览器处理毫不费力。很多时候“导不出来”不是技术做不到,而是方案选错了,CSV 就是那个更合适的方案。
6.3 Web Worker 的正确用法:把计算扔到后台
把导出计算放到 Web Worker 里,主线程完全不阻塞,这是大数据量导出最理想的体验。大致逻辑是这样的:
主线程创建一个 Worker,把数据源通过 postMessage 传给 Worker,Worker 内部引入 SheetJS 或其他导出库,生成 ArrayBuffer 后再 postMessage 传回主线程,主线程拿到结果后触发下载。
这里有一个关键细节:从 Worker 传回数据时,默认是结构化克隆,会复制一份数据,内存仍然有可能翻倍。优化做法是使用 Transferable Objects,把 ArrayBuffer 的所有权转移给主线程,而不是复制:
javascript复制// Worker 内部
self.postMessage({ buffer: wbout }, [wbout]);
第二个参数指定需要转移的对象,主线程拿到 buffer 后,就可以直接拿它创建 Blob 然后下载。这样做最大的好处是,生成 Excel 的过程完全不占用主线程,用户不会看到页面卡死,体验非常流畅。
代价是代码复杂度会上升,需要处理 Worker 脚本的加载路径、错误回调、进度反馈等。如果只是简单场景,我不建议一上来就上 Worker;但如果导出行为经常发生、数据量也大,这笔投资很值得。
6.4 进度提示:别让用户傻等
不管技术上怎么优化,文件生成总是需要那么几百毫秒到几秒的时间。这段时间里,用户最需要的是“有反馈”。我一般会在导出开始时弹一个 loading 遮罩,文案写清楚“正在生成文件,请稍候”,文件生成完成后再关闭。如果是异步流程(比如 Web Worker),还可以做成进度条,实时反馈生成进度。
体验的细节在于:loading 遮罩要放在导出流程的最前面,不要让用户点了按钮之后没有任何反应,然后就以为没点中又点了一次。这个朴素的反馈设计,能解决一大半的“用户觉得功能有问题”的投诉。
7. 从工具到方案:我的一点实战体会
写了这么多,最后说点掏心窝的话。我在项目里遇到的最常见情况是:需求方一句“导个 Excel 呗”,但背后的期望各不相同。有人只是想存个数据发出去,有人想要一张能直接打印的漂亮表,还有人要的是能二次编辑的数据模板。你接需求的时候,第一件事不是问用什么库,而是问清楚“这个文件给谁用、怎么用”。想清楚这个问题,选型往往就自动明确了。
纯 JS 导出 Excel 最舒服的地方在于:它把“导数据”这件事完全留在了前端,后端不用加接口、不用改配置、不用处理文件流。你在浏览器里直接就能完成从数据到文件的全部操作,这种“自己动手、丰衣足食”的痛快感,确实让人上瘾。
我的个人经验是,项目里长期维持一个小工具集,包含 exportXlsxByAoa、exportXlsxByJson、exportCsv 这几个底层函数,再根据需求组合。遇到要样式的用 ExcelJS 封装一层,遇到大数据量的走 CSV 或 Worker。日常开发 90% 的导出需求,这几行代码就能覆盖,不需要每次都从头查文档。
希望这篇文章能帮你少踩几个我当年踩过的坑。如果你在实际项目里遇到过更抓狂的导出问题,也欢迎在评论区聊聊——毕竟,技术这东西,永远是在互相踩坑和补坑中进步的。
