做后台系统的前端,基本都逃不掉"导出Excel"这个需求。一开始大家都觉得轻松,丢个接口,拿个blob,下载完事。直到真实业务里遇到5万条、甚至20万条数据的导出,你会发现事情没那么简单:后端提前下班了让你自己拼数据,或者数据量一大浏览器直接卡死弹"无响应",又或者用户盯着屏幕等了几十秒,完全不知道到底在不在跑。这篇就聊聊我在实际项目里攒下来的方案,核心解决两件事:一是xlsx文件怎么在浏览器里正确生成和下载,二是导出过程中的进度条怎么做得既真实、又不伤害页面性能。适合后台管理系统、数据中台、报表平台这类场景的前端同学参考,也欢迎被"导出卡死"气到头秃的朋友来抄作业。
1. 用户感知的"卡死"和服务端的"不配合":这个需求真实长什么样
1.1 从一条真实需求说起
我接到过一个很典型的工单,业务方原话是:"导出报表,点完按钮没有任何反应,点了好几下也没反应,过了几分钟突然蹦出来下载。"
这个工单描述其实很准确,因为当时的实现确实什么都没做——前端调一个接口,等后端把文件流吐回来。问题是这张报表关联了十几张表,后端要按照筛选条件现算,跑一次要好几十秒。用户点完按钮之后,页面没有任何反馈,看起来就是"死掉了"。
后来我把这个工单拆开来看,里面其实藏着两个完全不同的需求点:
- 导出这个动作本身要有"确定性反馈"——用户点了之后立刻知道"在跑",而且能看出跑动的进展。
- 导出过程不能阻塞页面操作——用户等待的时候还能去干别的,至少页面不能卡成白屏。
这两个点,纯后端生成加一个loading转圈,解决不了;前端直接把几万条数据一次性塞进Excel,也解决不了。必须把导出流程拆成若干阶段,每个阶段有可观测的进度,再把计算压力从主线程挪走。
1.2 为什么进度条会成为导出功能的硬指标
很多产品经理提需求时只会说"加个进度条",但如果直接把一个<div>加上宽度动画糊弄上去,用户很快会发现:进度条卡在90%半天不动,或者瞬间从0跳到100%。这种"假进度"反而更让人焦虑。
真实工程里,进度条的本质是把导出流程中每一个耗时的环节显性化。一个完整的导出链路通常长这样:
- 收集筛选条件和表头配置。
- 向服务端请求数据(如果是分页接口,则需要循环拉取多页)。
- 对拿到的原始数据做清洗、映射、字段格式化。
- 组装成二维数组或JSON数组,写入xlsx。
- 把生成的二进制流触发浏览器下载。
任何一个环节超过1秒,都要有进度反馈。所以我的结论是:进度条不是UI问题,而是流程拆解和异步调度问题。先想清楚流程分几段,再谈进度条长什么样。
2. 技术方案取舍:前端生成、后端生成还是前后端配合
2.1 SheetJS、ExcelJS 与纯后端方案的适用边界
先说结论:没有银弹,不同数据量级走不同的路。
| 场景 | 数据量 | 推荐方案 | 原因 |
|---|---|---|---|
| 前端已有完整数据,几千到几万行 | 1万行以内 | 前端SheetJS直接生成 | 省一次接口往返,体验最快 |
| 后端分页接口,数据量中等 | 1万到10万行 | 前端循环拉取+分片生成 | 后端改动少,进度可控 |
| 超大报表,几十万行以上 | 10万行以上 | 后端异步生成,前端轮询进度 | 浏览器内存根本扛不住 |
| 需要复杂样式、合并单元格、多级表头 | 任何量级 | ExcelJS或后端POI/Aspose | SheetJS社区版写样式能力约等于零 |
SheetJS社区版(npm包名xlsx)是很多前端项目里最常见的选择,它的API极度简单,json_to_sheet一把梭,适合快速交付。但它有两个明显的短板:
- 社区版不能写样式,字体、背景色、列宽这种东西统统别想。想加样式得买专业版,或者换ExcelJS。
- 大数据量写入时会一次性占大量内存。10万行可能在你的本地开发机勉强能跑,在用户的老机器上直接就崩了。
ExcelJS的优势是样式能力和单元格颗粒度控制,缺点也很直接:包体积大、API繁琐、写入性能比SheetJS更慢。我之前试过用ExcelJS写8万行,内存峰值直接逼近1GB,只能在场景确实需要"花里胡哨"的Excel时才用它。
至于纯后端方案,最大的优势是利用服务器资源和Content-Disposition: attachment直接输出文件,干净利落。但问题在于:你没有中间态可观测。要么让后端在做接口时加一个任务队列,前端轮询任务状态;要么就得忍受几十秒的无反馈。所以现在很多中后台项目的导出架构,其实是"后端任务化+前端轮询进度条"的组合拳。
2.2 我是怎么快速判断用哪条路的
我在接需求时,第一句话不是问"你们要什么格式",而是先问"最大数据量多少条"。
- 如果对方说"也就几千条",那前端SheetJS直接干,半天能交付。
- 如果对方支支吾吾说"可能几万吧",我会去翻接口定义和数据表行数,估算极端情况。
- 如果对方说"全量数据都要导",那劝你别在前端死磕,趁早拉后端一起改造,做成任务式导出。
另外还有一个非常容易被忽略的判断维度:导出数据是用户当前页面上已经加载的,还是需要重新从服务端查一遍。
如果数据已经在前端内存里(比如当前表格绑定了全量数据),前端生成是最快的路,能省一个接口。如果数据不在前端,老老实实考虑拉接口,这时候进度条要和"请求进度"挂钩,而不是和"文件生成"挂钩。这两者搞混了,进度条就会显得很失真。
3. 前端生成本体:SheetJS 的完整用法与可选优化
3.1 最朴素的导出代码,先跑通
不管多复杂的方案,第一步永远是先把最简单的导出跑通。先装依赖:
npm install xlsx然后在业务组件里引入:
import * as XLSX from 'xlsx'; function simpleExport(data, fileName = '导出数据.xlsx') { // 将 JSON 数组转换成工作表对象 const worksheet = XLSX.utils.json_to_sheet(data); // 创建 workbook 并追加 sheet const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, '数据'); // 写入文件并触发下载 XLSX.writeFile(workbook, fileName); }这段代码看起来是不是太简单了?确实,导出xlsx本身在API层面就是三行的事。但工程上真正的难点从来不在API,而在它周围的数据处理量和渲染帧调度。
用json_to_sheet时,每个对象的key会自动变成表头,value直接进单元格。如果字段顺序有要求,更好的做法是先映射成数组:
const rows = data.map((item) => ({ '姓名': item.name, '手机号': item.mobile, '创建时间': formatDate(item.createdAt), }));因为json_to_sheet是按对象key的顺序来生成列的,如果你希望表头顺序稳定,又不信任后端字段顺序,最好在map阶段就手动构建好目标形态。这一步还可以顺手做字段格式化、状态码映射、过滤空值,比导出后再处理干净得多。
3.2 常见"卡界面"的根因
很多同学写完上面的代码,在数据量上来之后发现页面卡死,然后开始怀疑SheetJS的写入性能。但实际上,卡顿往往不发生在XLSX.writeFile这一步,而在前面的数据映射和JSON序列化。
举个例子,你有5万行原始数据,每行有30个字段。map里做一个日期格式化、一个状态码映射。这一趟下来,JavaScript要创建5万个新对象,还要跑一堆字符串函数。这个过程的耗时可能比json_to_sheet本身还高,而且它发生在主线程,用户能感知到的就是"点击导出后页面冻住了"。
所以在写导出功能时,我把处理管线拆成了两段:
- 第一段:清洗和映射,可能非常耗时。
- 第二段:生成sheet并写文件,性能相对可控。
如果第一段耗时超过几百毫秒,就必须考虑分片。分片的核心思想是:把5万行数据切成50片,每片1000行,处理完一片之后把控制权还给浏览器,让它可以刷新进度条、响应点击事件,然后继续处理下一片。
4. 真实进度条的设计思路:分片、权重与UI刷新
4.1 进度条到底该"算"进度,而不是"感觉"进度
很多前端看到"进度条"第一反应是写一个setInterval,每100毫秒把宽度加一点,到99%停住,等导出完成再跳到100%。这在文件下载完成后确实够用,但如果导出过程可能失败、可能超时,一个假的百分比反而会把用户误导到"还差1%就成功了"的期望里,然后眼睁睁看着它卡死在99%。
真实的进度条,一定要绑定具体的可计算节点。我的习惯是把导出过程拆成这样:
- 拉取数据阶段:权重60%。因为这一阶段通常是网络IO,最慢。
- 数据清洗阶段:权重20%。纯计算,快慢取决于数据量。
- 生成文件阶段:权重15%。底层写入。
- 触发下载:权重5%。瞬时完成。
这样进度条就可以按阶段推进,每个阶段内部再做粒度更细的进度计算。比如拉取数据阶段如果后端给了分页接口,接口一共20页,每成功返回1页就加3%(60%除以20);如果后端是一个一次性接口,那这个阶段就只能有"请求中"和"请求完成"两种状态,无法细分——这种情况下我会把权重压缩,告诉用户"正在读取数据",而不是硬给一个数字。
4.2 分片处理让出主线程
解决了"进度怎么算"之后,下一个问题就是"怎么让进度条真正动起来"。答案是:不能一口气处理完所有数据再更新UI,必须分片处理,每片处理完停一下,让浏览器有机会渲染进度条。
来看一个我实际封装过的分片处理示例:
async function processInChunks(rawList, chunkSize = 1000, onProgress) { const result = []; const total = rawList.length; for (let start = 0; start < total; start += chunkSize) { const end = Math.min(start + chunkSize, total); const chunk = rawList.slice(start, end); // 实际业务处理:字段映射、格式化、过滤 const mapped = chunk.map(normalizeRow); result.push(...mapped); const percent = Math.round((end / total) * 100); onProgress(percent); // 如果总数据量较大,主动让出主线程 if (total > 20000) { await new Promise((resolve) => setTimeout(resolve, 0)); } } return result; }await new Promise(resolve => setTimeout(resolve, 0))这一行看起来像玄学,实际意义是:把当前任务排在浏览器渲染任务的后面。这样每次处理完一批数据,浏览器就能插空绘制一次,用户看到的进度条才会连续地动起来,而不是一卡一卡地跳。
这里有个性能细节需要提醒:setTimeout的执行间隔并不是0毫秒,浏览器通常会把嵌套的定时器最小间隔限制在4毫秒左右。如果分片数特别多(比如10万行分成了100片),光让出线程的时间就有400毫秒。所以分片大小要权衡,我一般取1000到2000行一片,既能保证UI有一定刷新率,又不至于因为频繁让出主线程拖慢整体速度。
4.3 进度UI更新的节流与避坑
进度条动起来之后,又会出现新问题:状态更新太频繁,React或Vue的setState往组件里塞了太多更新任务。
我这个踩过坑。一开始我在onProgress回调里直接setProgress(percent),分片是1000行一片,进度数字一秒跳几十次,页面反而因为频繁渲染而卡顿。后来改成用requestAnimationFrame做节流:进度值先存到一个ref里,然后等浏览器下一帧再统一刷到界面上。
function createProgressUpdater(onUpdate) { let latestProgress = 0; let rafId = null; return function (percent) { latestProgress = percent; if (rafId !== null) return; rafId = requestAnimationFrame(() => { onUpdate(latestProgress); rafId = null; }); }; }这样无论进度回调被调用多频繁,真正渲染到界面的频率始终和浏览器帧率对齐。这个方案在React 18和Vue 3里都适用,核心是"渲染交给浏览器调度,业务只负责上报最新值"。
另外一个容易踩的坑是:导出完成后进度条闪一下100%然后立刻消失。用户还没看清"导出成功"就没了,体验很差。我的处理是:到100%之后不马上隐藏,至少停留300到500毫秒,再用"下载已开始"的toast提示,给用户一个明确的收尾反馈。
5. 数据量再大一点:Web Worker 后台生成与进度回传
5.1 为什么数据量上来后主线程方案会失效
分片+让出主线程的方案,在5万行以内体验不错,但到了10万行以上就有些捉襟见肘了。
原因有两个:
- 主线程内存压力。10万行数据经过映射之后,会产生几十万个JS对象,再加上SheetJS内部拷贝一次,内存峰值可能到几百MB。老一点电脑的浏览器进程会直接崩溃或弹"Aw, Snap!"。
- 主线程任务再碎也还是主线程。用户如果去滚动页面、输入筛选条件,依然有明显的卡顿感,因为大部分时间还是被数据处理占着。
这个阶段,正确的姿势是把生成xlsx的整套逻辑塞进Web Worker。
Worker里的逻辑帮我做完了这些事:
- 在独立的线程里做数据清洗、分片、调用SheetJS生成文件。
- 每处理完一片,通过
postMessage向主线程回传进度。 - 主线程只做两件事:更新进度条、接收最终的ArrayBuffer并触发下载。
这样一来,不管数据量多大,主线程都轻得像在度假——进度条丝滑,页面随便点,用户完全不会被导出任务绑架。
5.2 Worker 方案的完整骨架代码
先写worker文件,export-worker.js:
// 在 Worker 里引入 SheetJS full 版本 importScripts('https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js'); self.onmessage = function (e) { const { rawList, chunkSize = 2000, fileName } = e.data; const total = rawList.length; const allRows = []; for (let start = 0; start < total; start += chunkSize) { const end = Math.min(start + chunkSize, total); const chunk = rawList.slice(start, end); const mapped = chunk.map(normalizeRow); allRows.push(...mapped); self.postMessage({ type: 'progress', percent: Math.round((end / total) * 100), }); } // 生成 sheet 和 workbook const worksheet = XLSX.utils.json_to_sheet(allRows); const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, '数据'); // 生成二进制Buffer const buffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' }); self.postMessage({ type: 'done', buffer, fileName, }); }; function normalizeRow(item) { // 这里的实际业务映射逻辑 return { '姓名': item.name, '手机号': item.mobile, '状态': item.status === 1 ? '启用' : '停用', }; }主线程这边创建Worker并监听消息:
function exportWithWorker(rawList, fileName = '报表.xlsx') { return new Promise((resolve, reject) => { const worker = new Worker('/export-worker.js'); worker.onmessage = (e) => { const { type, percent, buffer, fileName: name } = e.data; if (type === 'progress') { updateProgress(percent); } else if (type === 'done') { // 接收 ArrayBuffer 并触发下载 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 = name; a.click(); URL.revokeObjectURL(url); worker.terminate(); resolve(); } }; worker.onerror = (err) => { worker.terminate(); reject(err); }; worker.postMessage({ rawList, fileName }); }); }注意一个细节:我用XLSX.write而不是XLSX.writeFile,因为Worker线程里没有DOM,writeFile在Worker中无法触发浏览器的下载行为。正确做法是在Worker里生成ArrayBuffer,回传到主线程,再手动转成Blob并创建<a>标签下载。
用importScripts引入CDN的xlsx包虽然方便,但生产环境还是建议把xlsx打成本地静态资源,避免CDN挂掉导致导出功能雪崩。而且要注意:importScripts是Worker全局函数,只能在Worker环境里用,别在主线程代码里写。
6. 实测踩坑:xlsx is not defined、科学计数法、文件损坏这些天坑
6.1 模块导入方式导致的 xlsx is not defined
"xlsx is not defined"是搜索热度很高的报错。我排查过几个项目,发现多数情况是模块引入姿势不对。
SheetJS这个包比较老,历史包袱重。在ES Module项目里正确写法是:
import * as XLSX from 'xlsx';如果写成import XLSX from 'xlsx',在某些构建配置下也能用(因为包做了兼容导出),但在Vite + ESM环境下偶尔会拿到undefined默认导出,调用XLSX.utils时直接报错。
另一个常见场景是通过CDN的<script>标签引入:
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>这时候全局变量叫XLSX,但你如果在模块化代码里访问XLSX会得到undefined,因为ES模块的作用域隔离。解决办法是显式挂到window上:
const XLSX = window.XLSX;或者索性就别用CDN,统一走npm包,少踩一个坑。
6.2 身份证号、长数字变科学计数法
这是导出功能里最经典的需求陷阱。Excel默认对超过11位的数字会显示成科学计数法,身份证号、交易流水号这类字段一旦直接用数字类型写进单元格,用户打开文件看到的是一串"8.2034E+17",直接把数据搞废了。
解决方案是在映射阶段就把这类字段转成字符串:
const rows = data.map((item) => ({ '身份证号': String(item.idCard), // 强制转字符串 '金额': Number(item.amount).toFixed(2), }));不过这里有个让人血压升高的点:item.idCard如果是后端返回的Number类型,超过Number.MAX_SAFE_INTEGER(9007199254740991)的时候精度已经丢失了,前端String()救不回来。这种字段必须要求后端在接口返回时就用字符串类型。我在实际项目中遇到过数据库存的是int类型、接口返回数字、前端转字符串后发现末几位变成0的惨案,最终只能让后端改接口,前端再做一层兜底校验。
6.3 生成文件损坏或打不开
文件损坏的常见原因之一,是用老版本xlsx库生成文件时,单元格内容里包含非法字符。比如数据中带上了不可见的控制字符、特殊Unicode字符,或者某个字段的值是以=开头的字符串,Excel的CSV注入防护会让人误以为文件有问题。
我处理这类问题有两个习惯:
- 对单元格文本做一次清洗,过滤掉
\x00这类控制字符。 - 对于以
= + - @开头的文本字段,统一加一个前导空格或单引号,防止Excel执行公式注入。
function safeText(value) { if (typeof value !== 'string') return value; // 去掉控制字符,并防止公式注入 return value .replace(/[\u0000-\u001F]/g, '') .replace(/^([=+\-@])/, "'$1"); }另一个文件打不开的原因,是XLSX.write时type参数设置错误。在浏览器环境用'array'或'binary'都行,但如果你在Node环境服务端生成,要用'buffer'。这个参数选错,生成的Blob可能字节不对,下载下来就是损坏文件。
6.4 大文件导出时的内存与下载姿势
最后一个坑是关于下载方式的。几万行的xlsx文件有十几MB很正常,用URL.createObjectURL生成下载链接没问题,但创建完<a>标签并click()之后,记得调用URL.revokeObjectURL释放对象URL。不释放的话,连续导出几次,浏览器的内存占用会肉眼可见地飙升。
另外提醒一下:有的浏览器对自动下载是有限制的。如果用户没有手动交互,多步操作后触发的a.click()可能被拦截。我遇到过在异步回调里创建下载链接被Chrome静默拦截,用户点了没反应。解决办法是在用户点击导出时先弹一个"正在生成"的模态框(这个模态框的关闭可以再触发一次下载),或者用window.open(url)让浏览器更"信任"这次下载。
还有一个我自己常用的兜底:导出前检查文件大小。如果Blob大于50MB,就弹个提示告诉用户改用分sheet或过滤条件导出。这不是偷懒,是因为超过这个量级的xlsx,用户打开Excel也会卡,体验并不好。
7. 一个更省心的变体:后端任务化 + 前端轮询进度
前面讲的主要是前端自己生成。但如果你的项目后端资源充足,我更推荐一种省心的架构:后端把导出变成异步任务,生成完文件后把地址存起来,前端轮询任务状态来更新进度条。
这个方案的好处是:无论数据量多大、报表多复杂,前端都只做一件事——轮询接口,然后更新进度条。你甚至不需要知道后端用什么生成的,文件可能已经在服务器的临时目录或对象存储里躺好了。
实现上,前端只需要一个简单的轮询函数:
async function pollTask(taskId, interval = 2000) { while (true) { const res = await fetch(`/api/export/task/${taskId}`); const data = await res.json(); if (data.status === 'success') { return data.fileUrl; } if (data.status === 'failed') { throw new Error(data.message || '导出失败'); } updateProgress(data.progress); await new Promise((resolve) => setTimeout(resolve, interval)); } }当然这个方案依赖后端愿意配合,加任务表、任务状态接口、文件清理都是工作量。我在实际项目中遇到的情况是:后端觉得"导出而已,你前端同步刷一下就行",直到我被卡死页面的工单淹没了,后端看了用户投诉才愿意改造。所以如果你有这个话语权,走任务化方案最稳。
如果后端实在改不动,那还是用前面Worker那套方案,至少能保住前端体验的底线。我个人现在的选择是:5万行以下前端Worker方案,5万行以上尽量推动后端任务化。这样既能在小项目里快速交付,又能在大数据量场景下不翻车。
最后再分享一个小技巧。无论用哪种方案,都应该在导出按钮旁边标注数据量级,或者干脆做一次前置校验:如果前端一次性要处理的条数超过某个阈值,提前弹确认框"当前导出约12万条数据,预计需要1-3分钟"。这个小小的交互改动,可以挡掉一大半"导出没反应"的投诉——因为用户被提前告知了,就不会以为是自己点错了。前端导出xlsx这件事,技术本身不复杂,复杂的是怎么让用户在整个过程中保持"有掌控感",进度条只是实现这个掌控感的一种载体。