前端导出 Excel 与大数据量
前端导出小表格很轻松,十万行才是分水岭:内存、主线程阻塞、浏览器下载限制都会出现。
一、手写表格与库导出
三条路,第一条分界线是「要不要样式」:
- CSV 直接拼字符串:最轻,浏览器原生就能触发下载,但没有样式、没有多 sheet、没有列宽。适合「用户只要数据、自己进表格软件再处理」的场景。
- 表格库生成 xlsx:SheetJS(社区版)轻快、读写都行;ExcelJS 样式能力强(单元格颜色、边框、合并、列宽),代价是体积与内存更高。截至 2026 年 10 月,SheetJS 仍是前端导出的主流选择,要复杂样式才优先 ExcelJS(其自 2023 年起基本停止发版,重样式需求要一并评估维护风险)。需注意:SheetJS Community Edition 是 Apache-2.0(OSI 批准)许可,但官方已停止在 npm 发布新版本(npm 上的
xlsx停在 0.18.5),推荐从 SheetJS 官方 CDN 安装并保留署名,而不是直接npm install xlsx。 - 服务端生成 + 下载链接:大数据量的正解。前端只发起任务、轮询进度、拿到链接后下载,生成过程完全不占用浏览器。
能不能要样式是第一条分界线:无样式就 CSV,要样式就上库,数据量再大就交给服务端。
二、量级的分水岭
技术上能跑,但代价要讲清楚:生成过程中主线程被长时间占用(界面卡住甚至被浏览器判定无响应)、内存峰值会涨到几百 MB、低端设备可能直接崩。判断标准不是「能不能生成」,而是用户能不能接受这段时间界面不可用。
超过一定量级就应该改成服务端异步任务——前端发起任务、轮询进度、完成后下载,顺带也解决了刷新即中断的问题。前端这边按设备与交互代价判断,而不是拍一个「N 行以内用前端」的绝对数字:同一份数据,在高配桌面只是「转两秒」,在低端手机可能「白屏然后崩」。判断的不是能不能,是代价能不能接受。
格式本身也有硬边界:.xlsx 单张表最多 1,048,576 行 × 16,384 列,超了就得拆表;CSV 没有这个限制,但也不带类型,类型全靠下游自己解析。
安全上还有一条常被漏掉:CSV 公式注入。单元格内容以 =、+、-、@ 开头时,Excel 与部分表格软件会把它当公式执行。用户可控的字段(昵称、备注、地址)导出前要转义——前置一个单引号,或统一用引号包裹。
三、Blob 与下载时机
无论 CSV 还是 xlsx,最终都是把内容包成 Blob,用 URL.createObjectURL 生成临时地址,挂到 <a download> 上触发下载,用完 URL.revokeObjectURL 释放。不释放会泄漏内存。
// 主线程:把数据切片交给 Worker,避免一次性拼字符串吃光内存
const worker = new Worker('./exporter.worker.js')
worker.postMessage({ rows, chunkSize: 5000 })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'orders.xlsx'
a.click()
URL.revokeObjectURL(url) // 用完即释放
大文件的关键点是「不要一次性把整份字符串拼出来」。一次性拼接一个十万行的字符串,会在内存里同时存下源数组、中间字符串和最终 Blob,峰值明显升高。正确做法是分片生成:一边算一边往流式写入器里喂,或者交给 Web Worker 算、主线程只负责收最终结果,这样主线程不被卡死、内存峰值也可控。拼接字符串会吃掉全部内存,这是大数据量导出最常爆的地方。
四、内存与主线程阻塞
几条每一条都能在用户侧变成事故:
- 科学计数法与精度:长数字(订单号、身份证
202601010012345678)被 Excel 用科学计数法显示,超过 15 位有效数字后直接变 0。解法是按文本格式写——给单元格加'前缀,或在 ExcelJS 里把类型设为string。 - 时区:日期序列化时若用
toISOString(),会按 UTC 转,比本地时间差 8 小时。两端要约定好是按字符串原样存,还是按指定时区。 - CSV 的 BOM:不加 BOM,部分环境(尤其是 Windows 上双击打开)中文会乱码。加
\uFEFF前缀即可。 - 列宽与合并:合并单元格、列宽、自动换行这些只有完整 xlsx 才支持,CSV 做不到。
const ws = XLSX.utils.json_to_sheet(rows)
ws['!cols'] = [{ wch: 20 }, { wch: 12 }] // 列宽
XLSX.writeFile(wb, 'orders.xlsx')
// CSV 中文乱码:加 BOM
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' })
// 大数据量:前端发起任务,轮询进度,完成后下载
const { taskId } = await fetch('/export', { method: 'POST', body }).then((r) => r.json())
async function poll() {
const { status, progress, url } = await fetch(`/export/${taskId}`).then((r) => r.json())
if (status === 'done') return download(url)
updateProgress(progress)
await new Promise((r) => setTimeout(r, 1000))
return poll()
}
每条坑背后都是一次用户投诉:导出来打开是乱码、订单号末尾变 0、日期差一天。把它们当成导出功能的验收项,而不是写完就结束。
生成完还要走一遍下载,这一步漏掉 revoke 会慢慢吃内存:
const blob = new Blob([buf], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})
const url = URL.createObjectURL(blob)
const a = Object.assign(document.createElement('a'), { href: url, download: 'orders.xlsx' })
document.body.append(a)
a.click()
a.remove()
URL.revokeObjectURL(url) // 不释放, Blob 会一直占着内存
五、导出与打印的区别
打印和导出是两回事:导出是生成一份文件,打印是让浏览器把页面送进打印机,两者走的渲染路径完全不同。打印要单独的打印样式表——隐藏导航、侧栏、按钮这类无关 UI,用 @media print 控制;长表格要重复表头、用 page-break 控制分页,否则跨页就断在奇怪的地方。别指望一份产物兼顾两者,导出负责「拿数据走」,打印负责「留在纸上」。
长订单号必须显式写成文本,否则 Excel 按数值处理,末尾直接变 0:
const rows = data.map((r) => ({
...r,
// t: 's' 强制按字符串写,避免 19 位订单号被当成数字丢精度
orderNo: { v: r.orderNo, t: 's' },
amount: { v: r.amount, t: 'n', z: '#,##0.00' },
}))
const ws = XLSX.utils.json_to_sheet(rows)
六、前端导出的真正分界线
导出不只是把数据写成文件——格式、时区、精度、编码每一条都能在用户侧变成事故。
前端导出也不一定比服务端快,数据量上去后,前端要承担主线程阻塞与内存峰值。
导出完成更不等于结束:大数据量必须给进度、支持取消、允许失败重试。
导出功能真正的分界线不是数据量,而是「生成期间界面能不能卡」——不能卡,就交给服务端。
纯前端读写表格基本绕不开 SheetJS,它对大数据量有专门的性能说明,选型前值得看。