前端把一份表格导出来,听起来是最没技术含量的功能之一。可真到联调的时候,十有八九会卡在“文件下载下来了,但打不开”这一步。我见过最典型的一版是:点导出按钮,浏览器确实弹出了xxx.xlsx,双击提示“文件已损坏”,看一眼大小,0 字节。开发同学第一反应是后端接口挂了,抓包一看,后端返回的字节流清清楚楚,几百 KB。问题出在前端这一侧——二进制文件流接住之后怎么处理,决定了这份Excel 文件能不能被正常双击打开。这篇东西就是我把自己和身边几个团队在这条链路上踩过的坑完整摊开:请求怎么发、流怎么接、文件名怎么取、链接怎么造、什么时候该放弃 blob 改用流式落盘,以及那些“代码看着没问题但就是不生效”的细节。
适合谁看?一是刚接触文件导出、对Blob、ArrayBuffer、responseType这几个词还停留在“见过但说不清”的前端同学;二是正在排查导出功能异常、需要一份可对照的排查路径的人;三是负责设计导出接口、想搞清楚前端到底需要后端配合什么(响应头、错误码、分片)的技术负责人。代码以 JavaScript 和 TypeScript 为主,Axios 和 fetch 两套方案都会给,思路是通用的,换成任何框架都能搬。
1. 从那个 0KB 的 xlsx 说起:二进制流的完整链路
先把一件事说清楚:浏览器下载文件,从来不是“后端一返回文件,浏览器就自动保存”。这条链路上任何一个环节出错,最终都表现为“文件不对”。把链路拆开,心里才有排查的坐标。
1.1 一条从接口到磁盘的完整路径
一次正常的二进制导出,实际上有五个环节:
- 请求发出:前端发起 HTTP 请求,关键是要在请求配置里声明“我期待的不是 JSON,是二进制”。
- 响应接收:浏览器把响应体读成一段字节,这段字节在 JavaScript 里的载体可能是
Blob,也可能是ArrayBuffer,取决于你用哪个 API。 - 内存持有:字节流被装进一个 JS 对象,此时它还只是一个“数据类型”,跟磁盘毫无关系。
- 生成可访问地址:通过
URL.createObjectURL()给这段内存生成一个临时的伪地址,形如blob:https://your-site.com/1a2b3c...。 - 触发浏览器保存:造一个
<a>元素,href指向伪地址,加上download属性,模拟点击,浏览器接管后续的落盘行为。
五个环节里,第 1、2 步出错,表现为文件损坏或变成一堆乱码;第 3、4 步出错,表现为 0 字节或点不动;第 5 步出错,表现为文件名不对、下载无反应、或者下两次。上面那个 0KB 的案例,最后定位到的是第 1 步——没设responseType。
为什么没设responseType会变成 0 字节?因为 Axios 默认按 JSON 处理响应,它会先拿到一段字符串,然后走JSON.parse,解析失败时被try/catch兜住,返回的还是那段被当成文本解码过的字符串。文本解码这一步,本质上是用 UTF-8 去强行解释一段二进制,非法字节被替换成\uFFFD,原始字节信息已经永久丢失。你再new Blob([这段字符串]),得到的是一份“经过二次编码的错误内容”,大小可能对不上、内容肯定不对。0KB 通常是因为后端返回的字节流里恰好有大量在文本解码中被丢弃的内容,也有可能是响应拦截器把数据“清洗”了一遍。
这里给一条判断法则:只要下载下来的文件大小和
Content-Length对不上,问题几乎一定出在“字节流被当成文本处理过”。
1.2 为什么不能用后端直链解决问题
有人会说,让后端给个 URL,前端window.open不就行了?这种方式确实最简单,但它在几种场景下会失效:
- 导出需要 POST 提交一大坨筛选条件(比如选了几百个 ID),直链只能 GET,参数长度受限,而且把业务参数暴露在地址栏。
- 接口需要携带自定义请求头做鉴权。
- 后端返回的是临时生成、几秒后就清理的文件,直链生命周期不够。
反过来,直链方式也有它的优势:浏览器原生接管下载,有进度、可断点续传、大文件不吃内存,还能走 CDN。所以真实的工程决策往往是分场景:小数据量、筛选条件简单的导出走直链;条件复杂、需要鉴权的走二进制流。这个取舍在后面第 7 节还会展开。
一个容易被忽略的细节:即使走直链,如果后端用了Content-Disposition: attachment,浏览器也不会跳转页面,而是直接下载,前端完全无感知;如果想要“下载完成”的回调,直链方案基本做不到,只能靠轮询或者干脆不提示。这也是不少团队最终愿意花时间去啃二进制流的原因——前端拿得到过程控制权。
2. responseType 不是可选项:Axios 与 fetch 的两种取流姿势
这一节讲请求侧。两种 API 的思路完全不同,混用它们的返回值类型是最常见的错误来源之一。
2.1 Axios:配置项写在哪一层
Axios 里,responseType: 'blob'必须写在当次请求的 config上,写在全局默认值里也不是不行,但全局设置成 blob 会波及所有普通接口,属于给自己挖坑。正确写法:
// api/export.js import axios from 'axios' export function exportExcel(params) { return axios({ url: '/api/report/export', method: 'POST', data: params, responseType: 'blob', // 关键:告诉 axios 不要按 JSON 解析 timeout: 60 * 1000, // 导出通常慢,超时要放宽 headers: { 'Content-Type': 'application/json' } }) }responseType的取值有几个,区别值得记牢:
| 取值 | 拿到的东西 | 适用场景 | 备注 |
|---|---|---|---|
| 不设(默认 json) | 解析后的对象或字符串 | 普通 JSON 接口 | 处理二进制会导致内容损坏 |
blob | Blob对象 | 文件下载 | 最常用,保留 MIME 类型 |
arraybuffer | ArrayBuffer | 需要按字节操作、解析 Excel 内容 | 体积大时内存占用更高 |
text | 字符串 | 文本文件 | 二进制内容会失真 |
这里有第一个大坑:请求拦截器和响应拦截器可能把你的 blob 又改回去。很多项目会写一个统一的响应拦截器,形如:
service.interceptors.response.use(response => { const { code, data, message } = response.data if (code !== 200) { return Promise.reject(new Error(message)) } return data })这段代码在responseType: 'blob'时会直接炸——response.data是个 Blob,解构出来全是undefined,code !== 200恒成立,于是你的导出请求永远走 reject,控制台报一个莫名其妙的错。修法是在拦截器开头做类型判断:
service.interceptors.response.use(response => { // 二进制响应直接放行,交给业务层处理 if (response.config.responseType === 'blob' || response.data instanceof Blob) { return response } // ...原有 JSON 逻辑 })注意这里是返回response而不是response.data,因为业务层需要拿response.headers['content-disposition']去解析文件名。如果拦截器已经统一返回了data,那就得改成返回一个包含data和headers的结构,具体看你项目的约定,关键是别把 headers 丢了。
2.2 fetch:res.ok 必须手动检查
fetch 没有responseType这个概念,它是靠.blob()、.arrayBuffer()这类方法决定以什么形式读流。写法:
async function downloadExcel(payload) { const res = await fetch('/api/report/export', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) // fetch 只有在网络层失败时才 reject,4xx/5xx 也会走到这里 if (!res.ok) { // 关键:错误响应通常还是 JSON,要按 JSON 读 const err = await res.json().catch(() => ({})) throw new Error(err.message || `导出失败(${res.status})`) } const disposition = res.headers.get('content-disposition') const filename = parseFilename(disposition) const blob = await res.blob() saveAs(blob, filename) }对比下来,fetch 的“坑点”更显性:res.ok忘了判断、错误分支用.blob()读导致错误信息读不出来。Axios 的坑更隐蔽:拦截器、默认配置、transformResponse三层都可能悄悄改掉你的数据。
我的经验是,导出这类接口不要复用业务请求的 axios 实例,单独建一个干净的实例,只挂必要的鉴权头,能省掉八成“为什么数据不对”的排查时间。
还有一种极简情况:如果你只需要GET请求且不需要鉴权,<a href="/api/export?id=1" download>一行就够了,不必上二进制流。技术选型上,能简单绝不复杂。
3. blob 与 arraybuffer 之间:内存里的那段字节到底长什么样
这一节讲数据类型。听起来枯燥,但只要理解了这几个对象的本质,很多“转换来转换去还是错”的问题会一次性消失。
3.1 一个比喻:ArrayBuffer 是仓库,TypedArray 是窗户
把ArrayBuffer想象成一块固定大小的仓库,里面存的是一格一格的原始字节,你不能直接伸手进去拿东西。想访问它,得开一扇窗户——也就是Uint8Array、Int16Array这些视图(TypedArray)。窗户决定了你怎么看待同一批字节:用Uint8Array看,每个格子是 0~255;用Int32Array看,每四个格子合成一个数。字节本身没变,只是解读方式变了。
Blob则是更高一层的抽象,它是“不可变的、带 MIME 类型标记的二进制大对象”。它内部可能分段存储,浏览器可以把大 Blob 直接交给磁盘或网络,不必整体读进内存。这也是为什么下载场景推荐用 Blob 而不是 ArrayBuffer——Blob 更适合整块搬运,ArrayBuffer 更适合精确操作。
几个常用转换,值得抄进自己的工具文件:
// Blob -> ArrayBuffer(当需要读取内容时) const buffer = await blob.arrayBuffer() // ArrayBuffer -> Uint8Array(当需要按字节处理时) const bytes = new Uint8Array(buffer) // Uint8Array -> Blob const blob = new Blob([bytes], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) // 字符串 -> Blob(常用于前端自己生成 CSV) const csvBlob = new Blob(['\uFEFF' + csvText], { type: 'text/csv;charset=utf-8' }) // Blob -> 文本(用于读取错误响应) const text = await blob.text()最后那行'\uFEFF'是 BOM,加在 CSV 最前面,Excel 打开时才不会把中文变成乱码。这是个很小的细节,但如果你导出的是 CSV 而不是 xlsx,不加 BOM 的后果就是用户打开看到一屏锟斤拷。
3.2 MIME 类型写错会发生什么
new Blob(parts, { type })里的type不是摆设。它决定了:
URL.createObjectURL()生成的伪地址被访问时返回的Content-Type。- 某些浏览器在
download属性缺失时,会按 MIME 推断文件后缀。 - 部分系统在打开文件时会先读 MIME 做程序关联。
xlsx 和 xls 的 MIME 不一样,别混:
| 文件类型 | 正确 MIME |
|---|---|
| .xlsx | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet |
| .xls | application/vnd.ms-excel |
| .csv | text/csv;charset=utf-8 |
实际项目里,我一般直接从响应头拿:
const mime = res.headers['content-type']?.split(';')[0] || 'application/octet-stream' const blob = new Blob([res.data], { type: mime })但其实 Axios 拿到的res.data本身就是 Blob,已经带了 type,直接用它更省事。只有在需要重新拼装(比如分片下载、或者把多个文件合并)时才需要手动指定类型。这里再强调一次:new Blob([blob], { type })里如果 type 传错,最终文件的后缀可能对,但系统识别出来的类型是错的,Windows 上偶尔会弹出“你希望用什么程序打开”的对话框。
3.3 什么时候必须用 ArrayBuffer
绝大多数下载场景 Blob 就够了。需要 ArrayBuffer 的情况主要有三种:
一是要在前端解析文件内容,比如模板校验、读表头做预览。这时候得用像 SheetJS 这类解析库,它接受的输入可以是 ArrayBuffer 或 Uint8Array。
二是要做加密或压缩,比如导出前对字节做一次 AES 处理,那必须操作到字节级。
三是要计算校验和,比如用crypto.subtle.digest算 SHA-256 校验文件完整性,它只接受 ArrayBuffer。
三种情况的转换成本都很低,await blob.arrayBuffer()一行搞定。但要记住,这一步会把整个文件读进内存,几十 MB 的文件这么干会让页面卡顿一下,所以只在确实需要的时候转。
4. 文件名为什么变成了一串乱码:Content-Disposition 的解析
文件名不对是导出功能里出现频率第二高的问题,仅次于文件打不开。而它的表现很有迷惑性:文件能打开、内容也对,就是名字叫download或者%E9%94%80%E5%94%AE%E6%8A%A5%E8%A1%A8.xlsx。
4.1 两种 filename 语法与它们的优先级
后端告诉浏览器文件名,靠的是响应头Content-Disposition,标准格式长这样:
Content-Disposition: attachment; filename="report.xlsx"; filename*=UTF-8''%E9%94%80%E5%94%AE%E6%8A%A5%E8%A1%A8.xlsx两段filename的区别至关重要:
filename=是老语法,HTTP 头按规范只允许 ASCII,所以中文必须转义,不同服务器用的编码还不一样(有的用 ISO-8859-1,有的用 URL 编码),结果是各家实现五花八门。filename*=是RFC 5987 新增的扩展语法,格式是字符集''编码后的值,明确声明用 UTF-8 加 URL 编码,这是唯一能可靠传中文的方式。
解析顺序必须是先找filename*,找不到再退回filename。反过来就会出现“明明后端正确设置了,前端还是显示乱码”。
4.2 一份能直接用的解析函数
/** * 从 Content-Disposition 中提取文件名 * 兼容 filename*=UTF-8''xxx 与 filename="xxx" */ export function parseContentDisposition(disposition) { if (!disposition) return '' // 优先解析 RFC 5987 的 filename* const utf8Match = /filename\*\s*=\s*([^;]+)/i.exec(disposition) if (utf8Match) { let value = utf8Match[1].trim().replace(/^["']|["']$/g, '') // 形如 UTF-8''%E4%B8%AD const parts = value.split("''") const encoded = parts.length === 2 ? parts[1] : value try { return decodeURIComponent(encoded) } catch (e) { // 编码不合法时降级到普通 filename } } // 退回普通 filename const plainMatch = /filename\s*=\s*"?([^";]+)"?/i.exec(disposition) if (plainMatch) { const raw = plainMatch[1].trim() try { return decodeURIComponent(raw) } catch (e) { return raw } } return '' }decodeURIComponent外面套try/catch是必要的,因为有些后端会把已经是明文的字符串塞进filename,里面可能包含%字符,直接 decode 会抛URIError。
4.3 跨域场景下拿不到响应头
一个高频问题:本地开发用代理一切正常,上了测试环境部署到不同域名,文件名突然没了。原因是浏览器的同源策略会限制 JS 读取跨域响应的非简单响应头,Content-Disposition不在默认白名单内。
解决方式只有一个:后端在响应里加一行
Access-Control-Expose-Headers: Content-Disposition, Content-Length前端这边没有任何绕过的办法,res.headers里就是拿不到。所以排查这类问题时,先看控制台 Network 面板里的原始响应头有没有content-disposition:
- 有,但
res.headers.get()返回 null → 后端没加Expose-Headers。 - 没有 → 后端压根没设。
区分清楚这一点,能省下一轮和后端的扯皮。
前端也要做兜底:文件名解析失败时,用时间戳生成一个,别让文件叫download。
const filename = parseContentDisposition(disposition) || `导出数据_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`5. a 标签点下去没反应:URL 生命周期与浏览器差异
字节有了、名字有了,最后一步是把文件送到用户手里。这一步代码只有几行,但每一步都有讲究。
5.1 一套稳的封装
export function saveBlob(blob, filename) { // 1. 生成伪地址 const url = URL.createObjectURL(blob) // 2. 造 a 标签并挂到 DOM 上 const link = document.createElement('a') link.href = url link.download = filename || 'download' link.style.display = 'none' document.body.appendChild(link) // 3. 模拟点击 link.click() // 4. 清理 document.body.removeChild(link) // 延迟释放,立即释放可能导致部分浏览器下载中断 setTimeout(() => URL.revokeObjectURL(url), 4000) }四步里被省略最多的是第 2 步的“挂到 DOM”。现代 Chrome 里不挂 DOM 直接click()也行,但在一些老版本内核和部分移动端浏览器里,游离节点上的点击不会触发下载。成本几乎为零,加上更安心。
5.2 revokeObjectURL 的时机
URL.createObjectURL()产生的伪地址会一直持有对应 Blob 的引用,不释放就是内存泄漏。导出功能如果支持批量操作,用户一口气点五次导出,可能就有一两百 MB 的内存被钉住不还。
但立刻 revoke 会出问题:link.click()只是把下载任务提交给浏览器,实际的读取和写入是异步的,有些浏览器在这个任务完成前就把 URL 释放掉,结果就是下载被中断或者得到空文件。所以要用延时释放,4 秒是个比较安全的经验值。更严谨的做法是监听window上的focus事件(下载开始后页面会失焦再回来),但延时足够简单可靠。
提醒一句:不要用
URL.revokeObjectURL去释放别人创建的 URL。曾经见过一个全局的“清理函数”,把所有blob:开头的链接都 revoke 了一遍,把另一个还在下载中的任务干掉了。
5.3 移动端与内置浏览器的坑
移动端是另一个世界。
| 环境 | 表现 | 应对 |
|---|---|---|
| iOS Safari | 较新版本支持download,但更愿意新开标签 | 检测到 iOS 可用新窗口打开 blob URL |
| 微信内置浏览器 | download常被忽略,直接预览 | 引导“用浏览器打开”或提供分享卡片 |
| 安卓部分 WebView | 点击无响应 | 优先用window.open(url) |
| 桌面 Chrome/Edge | 正常 | 标准流程即可 |
在 iOS 上的一个实用写法:
function isIOS() { return /iPad|iPhone|iPod/.test(navigator.userAgent) } if (isIOS()) { // 新窗口打开,用户手动点“存储到文件” window.open(url, '_blank') } else { link.click() }这类兼容处理没什么技术含量,但如果你不做,用户反馈就是“苹果手机导不出来”,然后你得花半天时间找一台 iPhone 复现。
6. 文件打不开的三种真相:错误流、伪 Excel 与编码陷阱
回到最开始的场景。排除了responseType之后,文件还是打不开,那就得看文件内容到底是什么了。
6.1 200 状态码里的错误 JSON
这是最坑的一种。后端发生异常,但没有返回 4xx,而是老老实实返回了 200,body 是{"code":500,"message":"数据量过大,请缩小范围"}。前端因为设了responseType: 'blob',这段 JSON 被原样包成 Blob,保存成了报表.xlsx。用户双击,Excel 说文件格式不对。
Axios 这种情况不会进catch,因为状态码是 200。所以必须在拿到响应后主动判断:
async function handleResponse(res) { const blob = res.data // 大小异常,或者类型是 JSON,都说明后端返回了错误信息 const isJson = blob.type.includes('application/json') if (isJson || blob.size < 100) { const text = await blob.text() let msg = '导出失败' try { msg = JSON.parse(text).message || msg } catch (e) { // 不是 JSON 就原样带出去 msg = text.slice(0, 200) || msg } throw new Error(msg) } const disposition = res.headers['content-disposition'] const filename = parseContentDisposition(disposition) || '导出数据.xlsx' saveBlob(blob, filename) }blob.size < 100这个阈值是个经验判断,正常 Excel 哪怕只有一行也有几 KB(xlsx 是 zip 包,有固定的元数据开销)。不过更可靠的是判断类型,所以要和后端约定:错误响应必须带Content-Type: application/json。有了这个约定,判断就变得确定。
6.2 内容其实是 HTML 或 XML
另一种情况:网关或鉴权中间件拦截了请求,返回了一个登录页 HTML,状态码可能是 200。保存下来的“Excel”其实是一个网页。
判断方法:
const head = await blob.slice(0, 4).arrayBuffer() const bytes = new Uint8Array(head) // xlsx 本质是 zip,前两字节是 PK (0x50 0x4B) const isZip = bytes[0] === 0x50 && bytes[1] === 0x4Bxlsx 文件头一定是50 4B(PK),因为它就是个 zip 包。老式 xls 是D0 CF 11 E0(OLE 复合文档)。如果你的文件头是3C(<),那说明拿到的是 HTML/XML,百分百是请求被拦截了。这个校验很轻量,加在开发环境里能救很多次调试。
6.3 CSV 的编码与 BOM
如果导出的是 CSV,问题又不一样。CSV 是纯文本,但文本有编码。后端用 GBK 编码返回,前端按 UTF-8 存成 Blob,Excel 打开就是乱码。
两种情况分开处理:
- 后端返回的是 UTF-8:前端拼 Blob 时补 BOM,
new Blob(['\uFEFF' + text])。 - 后端返回的是 GBK:必须在前端转码,可以用
TextDecoder('gbk')(注意这个编码标签在部分环境需要额外支持),或者让后端统一改成 UTF-8。工程上更推荐后者——编码问题在后端解决一次,比在前端到处打补丁强。
顺便说个判断技巧:用文本编辑器打开 CSV,如果中文正常但 Excel 打开乱码,就是 BOM 缺失;如果文本编辑器打开都乱码,那是编码本身就不对。
7. 几百 MB 的导出:流式落盘与分片拼接
前面讲的方案有个隐含前提:文件不大。当导出量级上到几十万行、几百 MB 时,blob方案会遇到硬墙——整个文件必须先进内存。Chrome 里 Blob 超过一定大小会触发额外的内存拷贝,页面直接卡死或者标签页崩溃。
7.1 先算一笔账
假设导出 50 万行、20 列的明细表,xlsx 压缩后大概 20~40 MB。看起来不大,但注意中间过程的放大:
| 阶段 | 内存占用 |
|---|---|
| 网络接收的字节 | 约 30 MB |
| Axios 转成 Blob | 约 30 MB(可能翻倍) |
new Blob([data], {type})重新包装 | 再复制一份,约 30 MB |
createObjectURL持有引用 | 直到 revoke 才释放 |
也就是说,一个 30 MB 的文件,峰值可能占到 100 MB 以上。如果是 200 MB 的报表,页面崩溃是必然的。
7.2 大文件的两条路
第一条:Range 分片请求,逐片拼接。前提是后端支持Range头。
const CHUNK_SIZE = 5 * 1024 * 1024 // 5MB 一片 async function downloadByChunks(url, totalSize, onProgress) { const parts = [] let start = 0 while (start < totalSize) { const end = Math.min(start + CHUNK_SIZE - 1, totalSize - 1) const res = await fetch(url, { headers: { Range: `bytes=${start}-${end}` } }) if (!res.ok && res.status !== 206) { throw new Error(`分片下载失败:${res.status}`) } // 每片都存成 Blob,不要转成字符串 parts.push(await res.blob()) start = end + 1 onProgress?.(Math.min(start / totalSize, 1)) } return new Blob(parts, { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) }new Blob(parts)的好处是浏览器可以对分段做引用拼接,不需要先把所有分片合成一个大 ArrayBuffer,内存压力小一些。但最终仍然要在内存里有一份完整文件,本质上是“延迟放大”,不是根治。
第二种:流式写入磁盘。思路是在页面里注册一个 Service Worker,拦截一个特定的虚拟路径,把fetch的响应流式写回,配合ReadableStream逐块读取上游数据并往前推。这样文件从不完整进入内存,进度和断点都能控。
实现复杂度不低,要处理 Service Worker 注册、跨域、HTTPS 环境限制、首次加载的激活时机等问题。我的建议是:只有在确实存在几百 MB 级导出需求时才上。绝大多数业务场景(几万行以内),用分片 + Blob 拼接配合进度条已经足够。而且对大报表来说,更合理的方案往往是后端异步生成、前端轮询拿结果、最后给个短时效的下载地址——把内存问题转成任务调度问题。
7.3 进度条怎么做得准
如果走 Blob 方案,axios的onDownloadProgress能拿到已接收字节数,但总长度依赖Content-Length。如果后端用了 gzip 压缩传输,Content-Length是压缩后的长度,而loaded是解压后的,比例会超过 100%,进度条会卡在 99% 或者直接爆表。
处理办法是给进度做上限截断,同时用“已接收 / 预估总大小”来估算:
onDownloadProgress: (e) => { const percent = e.total ? Math.min(Math.round((e.loaded / e.total) * 100), 99) : null // 没有 total 时用不确定态进度条,别硬算 setProgress(percent) }到最后保存完成、revokeObjectURL执行之后,再把进度置为 100%。这个顺序很重要,用户看到 100% 却发现文件还没出现,体验会很差。
8. 前端生成还是后端生成:ExcelJS 那条岔路
讲到这里,其实还有一个更前置的选择没讨论:文件到底该谁生成。
8.1 三种方案的适用边界
| 方案 | 谁生成 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| 后端生成 xlsx | 服务端 | 样式复杂、支持公式/图表、数据源在库里 | 服务端压力大、高并发要排队 | 正式报表、大数据量 |
| 后端给 JSON,前端用 ExcelJS 生成 | 浏览器 | 服务端只查数据,样式前端控 | 数据量受浏览器内存限制 | 中小数据量、样式高度定制 |
| 前端自己拼 CSV | 浏览器 | 零依赖、极快 | 无样式、无多 sheet、编码坑 | 快速导数据、内部工具 |
前端生成用 ExcelJS 的典型写法:
import ExcelJS from 'exceljs' async function buildAndDownload(rows) { const wb = new ExcelJS.Workbook() const ws = wb.addWorksheet('明细') ws.columns = [ { header: '订单号', key: 'orderNo', width: 24 }, { header: '金额', key: 'amount', width: 12 }, { header: '下单时间', key: 'createdAt', width: 22 } ] ws.getRow(1).font = { bold: true } rows.forEach(r => ws.addRow(r)) // 关键:拿 buffer 而不是 blob const buffer = await wb.xlsx.writeBuffer() const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) saveBlob(blob, `明细_${Date.now()}.xlsx`) }这里有个容易搞混的点:wb.xlsx.writeBuffer()返回的是ArrayBuffer(或 Buffer),wb.xlsx.write()才是往流里写。别拿错了。另外writeBuffer生成大表时会明显阻塞主线程,几万行的话建议放到 Web Worker 里跑,生成完把 ArrayBuffer 用postMessage转移回主线程(记得用 Transferable,避免结构化克隆再复制一份)。
8.2 我为什么更倾向于后端生成
不是说前端生成不好,而是它的适用窗口比想象中窄。原因有三个:
第一,业务方迟早要样式。合并单元格、条件着色、冻结首行、加个公司 logo——ExcelJS 都能做,但每加一个需求就是一轮前端发版,而后端改个模板文件就能上线。
第二,数据源在服务端。前端生成意味着要先拿到全量 JSON,这一步本身就受接口分页、字段裁剪、权限过滤的限制,很多时候反而更麻烦。
第三,内存和卡顿。几万行数据的 JSON 序列化 + 工作簿构建,在主线程上足够让页面白屏两秒。用户会以为页面挂了。
所以我的分工建议是:后端负责“数据量大、样式固定”的导出,前端负责“数据量小、需要即时预览或强交互”的生成。两者不冲突,同一个系统里并存很正常。
8.3 一个被低估的细节:下载完成后做什么
导出功能的产品体验往往止步于“文件下来了”。但真实场景里,用户下一句话通常是“我刚才导的是哪天的数据来着”。
两个便宜又有效的处理:
一是文件名带维度信息。别叫导出.xlsx,叫销售报表_华东区_20250301-20250331.xlsx。后端拼或者前端拼都行,用户下载下来一眼能认。
二是下载成功给个明确反馈。因为link.click()是同步的,你不知道浏览器实际什么时候写完磁盘,所以合适的反馈是“文件已开始下载,请查看浏览器下载栏”,而不是“导出成功”。前者是诚实的,后者如果文件其实失败了(比如磁盘满、被拦截),用户会以为拿到了数据,然后在别处折腾半天。
还有个小坑:如果用户连续点导出,浏览器可能会把重复文件自动改名成(1)、(2),也可能直接覆盖。这在前端无法控制,所以按钮上加个 loading 锁,让一次只发一个请求,是成本最低的做法。
最后分享一个小技巧。调试导出功能时,别急着双击打开 Excel,先用文本编辑器或者file命令看一眼文件头。我习惯在开发环境写个临时函数,把拿到的 Blob 前 16 个字节打成十六进制:
const hex = Array.from(new Uint8Array(await blob.slice(0, 16).arrayBuffer())) .map(b => b.toString(16).padStart(2, '0')) .join(' ') console.log('文件头:', hex, '大小:', blob.size, '类型:', blob.type)正常的 xlsx 会打印出50 4b 03 04 ...,看到这个就知道字节流通了,剩下的都是文件名和保存环节的问题;如果打印出7b 22 63 6f 64 65(也就是{"code),那说明根本就是一段错误 JSON。这一行日志定位问题的速度,比在 Excel 的“文件已损坏”弹窗面前反复怀疑人生要快得多。