ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

前端导出Excel不卡顿:从SheetJS到Web Worker的进度条实战方案

前端导出Excel不卡顿:从SheetJS到Web Worker的进度条实战方案 做后台系统的前端基本都逃不掉导出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/AsposeSheetJS社区版写样式能力约等于零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/xlsx0.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线程里没有DOMwriteFile在Worker中无法触发浏览器的下载行为。正确做法是在Worker里生成ArrayBuffer回传到主线程再手动转成Blob并创建a标签下载。用importScripts引入CDN的xlsx包虽然方便但生产环境还是建议把xlsx打成本地静态资源避免CDN挂掉导致导出功能雪崩。而且要注意importScripts是Worker全局函数只能在Worker环境里用别在主线程代码里写。6. 实测踩坑xlsx is not defined、科学计数法、文件损坏这些天坑6.1 模块导入方式导致的 xlsx is not definedxlsx is not defined是搜索热度很高的报错。我排查过几个项目发现多数情况是模块引入姿势不对。SheetJS这个包比较老历史包袱重。在ES Module项目里正确写法是import * as XLSX from xlsx;如果写成import XLSX from xlsx在某些构建配置下也能用因为包做了兼容导出但在Vite ESM环境下偶尔会拿到undefined默认导出调用XLSX.utils时直接报错。另一个常见场景是通过CDN的script标签引入script srchttps://cdn.jsdelivr.net/npm/xlsx0.18.5/dist/xlsx.full.min.js/script这时候全局变量叫XLSX但你如果在模块化代码里访问XLSX会得到undefined因为ES模块的作用域隔离。解决办法是显式挂到window上const XLSX window.XLSX;或者索性就别用CDN统一走npm包少踩一个坑。6.2 身份证号、长数字变科学计数法这是导出功能里最经典的需求陷阱。Excel默认对超过11位的数字会显示成科学计数法身份证号、交易流水号这类字段一旦直接用数字类型写进单元格用户打开文件看到的是一串8.2034E17直接把数据搞废了。解决方案是在映射阶段就把这类字段转成字符串const rows data.map((item) ({ 身份证号: String(item.idCard), // 强制转字符串 金额: Number(item.amount).toFixed(2), }));不过这里有个让人血压升高的点item.idCard如果是后端返回的Number类型超过Number.MAX_SAFE_INTEGER9007199254740991的时候精度已经丢失了前端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这件事技术本身不复杂复杂的是怎么让用户在整个过程中保持有掌控感进度条只是实现这个掌控感的一种载体。
返回列表