拓冰建站拓冰建站
首页 / 资讯中心 / 正文

前端导出CSV/Excel全攻略:从手写Blob到ExcelJS选型与性能优化

1. 需求梳理前端导出到底在导什么1.1 三个高频场景报表下载、数据备份、表格协作我做了十来年前端接到导出需求的次数多得数不清。很多刚入行的同事觉得导出功能简单不就是把数组拼成字符串、扔给浏览器下载吗。真到自己上手踩过的坑比想象中多得多。先理清需求。日常项目里导出 csv/excel 文件基本逃不出三类场景第一类是管理后台的报表下载。运营、财务、业务人员要把系统里的统计数据导出来拿去做月度汇报、数据透视、二次加工。这类需求的特点是数据量可能很大字段多对格式有一定要求比如日期要2025-06-01而不是45278这样的 Excel 序列值偶尔还要加个表头、合计行。第二类是数据备份和迁移。用户在页面上维护了一套数据比如导入的客户名单、配置的规则列表想把数据带走或备份到本地。这类需求更看重数据的完整性和可读性CSV 格式通常就够了不需要花哨的样式。第三类是表格协作场景。类似在线表格工具里的导出 xlsx需要尽可能保留格式、公式、批注甚至多个工作表。这种就不能再用 CSV 糊弄了得老老实实生成真正的 Excel 文件。这三类场景对应不同的技术选型。很多人上来就问用哪个库我一般会反问一句你先搞清楚要导出的是 CSV 还是真正的 xlsx数据量大概多大对格式的要求到哪一档。这三个问题问完方案基本就定了一半。1.2 先分清 CSV 和 Excel 的区别CSV 全称是逗号分隔值本质上就是一个纯文本文件。它的数据用逗号分隔用换行符分行可以用任何文本编辑器打开。而 .xlsx 是一个 zip 压缩包里面装着一堆 XML 文件描述着单元格、样式、公式、图表等结构化信息。这意味着什么生成 CSV 只需要拼字符串生成 xlsx 需要构造一个完整的文件包。前者是百行代码以内就能搞定的事后者要么引入库要么自己按 OOXML 规范去组装 XML 文件——后者的工程量大得多几乎没人会手写。还有一个实际层面的区别Excel 打开 CSV 时的编码识别问题。CSV 没有统一编码标识Excel 通常按系统区域设置来猜编码。中文环境下生成的 UTF-8 编码 CSV直接用 Excel 双击打开时经常乱码。这也是我在文章后面会用一整节来重点聊的问题。另外CSV 无法保存格式、公式、合并单元格、多个工作表。如果你的需求里出现了这些词就别考虑 CSV 了直接用 Excel 方案。1.3 什么时候必须上库什么时候手写就行我的判断标准很简单导出 CSV 且数据量不大万行以内、不需要复杂转义处理手写就行导出 CSV 但字段内容里包含了逗号、引号、换行等特殊字符手写时要格外小心最好先封装一个csvEscape函数需要导出 xlsx哪怕需求很简单也建议直接上库需要导出带样式、公式、多 sheet 的 xlsx选 ExcelJS 这类功能强大的库数据量到了几十万行这个级别不管是 CSV 还是 xlsx都要考虑性能优化后面专门讲。有个常见的错误观点是导 Excel 一定要引入 xlsx 库。实际上很多报表需求用一个生成 CSV 的简单函数再在 Excel 里打开完全够用。少引入一个依赖项目就少一分体积和风险。工具是为需求服务的不是越重越好。2. 零依赖方案用 Blob 手写导出 CSV2.1 从数组到 CSV 字符串的最小实现先看一个最简单的入门版本。假设后端返回了一个 JSON 数组每项对应一行数据我们要把它导成 CSVconst data [ { name: 张三, age: 28, city: 北京 }, { name: 李四, age: 32, city: 上海 }, ]; function exportToCsv(data) { if (!data.length) return; const headers Object.keys(data[0]); const rows [headers.join(,)]; data.forEach(item { rows.push(headers.map(key item[key]).join(,)); }); const blob new Blob([rows.join(\n)], { type: text/csv;charsetutf-8; }); downloadBlob(blob, export.csv); } function downloadBlob(blob, filename) { const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download filename; a.click(); URL.revokeObjectURL(url); }这段代码的逻辑是把对象数组转成表头行 数据行的二维结构每行用逗号拼接行与行之间用换行符连接最后包成一个 Blob 对象通过临时创建的a标签触发下载。URL.createObjectURL会生成一个临时 URL指向浏览器内存中的这个 Bloba.click()触发下载后要记得URL.revokeObjectURL释放内存这个小细节很多人会漏掉。漏掉的后果是下载多了之后页面会变卡因为内存里的临时 URL 没有回收。2.2 解决 Excel 打开 CSV 中文乱码问题前面提到的乱码问题在这里正式面对。直接导出的 UTF-8 编码 CSV用记事本打开没问题但双击用 Excel 打开十有八九是乱码。原因是 Excel 默认按 ANSI即 GBK/GB2312来解析 CSV而浏览器生成的是 UTF-8。解决方案是在 CSV 内容前面加上 UTF-8 BOMByte Order Mark字节序标记。BOM 是一组特殊的字节\uFEFFExcel 看到它就能正确识别编码。const blob new Blob([\uFEFF rows.join(\n)], { type: text/csv;charsetutf-8;, });就这么一个字符Excel 乱码问题基本就解决了。我在团队内部培训时反复强调凡是导出 CSV 给国内用户用 Excel 打开的场景都必须加 BOM。这不是可选项是必选项。2.3 老项目里的下载兼容处理IE 下的 msSaveBlob现在基本没人关心 IE 了但如果你维护的是五六年前的老后台系统还是会碰到。IE 10/11 不支持a download这种 H5 下载方式得用navigator.msSaveBlob。function downloadBlob(blob, filename) { if (window.navigator window.navigator.msSaveOrOpenBlob) { window.navigator.msSaveOrOpenBlob(blob, filename); return; } const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }注意msSaveOrOpenBlob和msSaveBlob的区别前者除了保存还会弹出一个打开/保存的选择框后者只保存不弹出。具体用哪个看你想要什么交互。IE 已死这段代码的意义更多是让你理解下载功能的兼容层本质上是判断浏览器能力然后走不同的下载通道这个思路放到今天依然成立。导出 CSV 最容易被忽略的还有字段值本身包含逗号、双引号、换行符的情况。比如地址是北京市,朝阳区如果不对它做处理直接拼进 CSVExcel 打开后这个字段会被切成两列。标准做法是用双引号包裹这个字段并把字段内的双引号替换成两个双引号function csvEscape(value) { if (value null || value undefined) return ; const str String(value); if (/[,\n]/.test(str)) { return str.replace(//g, ) ; } return str; }这个转义函数我建议封装到公共工具里所有导出 CSV 的地方统一走它避免每个业务自己写一遍写法还各不一样。3. SheetJS 实战一分钟生成一个 xlsx3.1 引入方式和基础 API 流程当需求从能打开看升级到要 xlsx 格式、要多个工作表、要固定列宽时手拼 CSV 就不够用了。这是正式引入 SheetJS习惯叫 xlsx 库的场景。SheetJS 的社区版是 npm 包xlsx引入方式npm install xlsx核心使用流程是三步拿数据构建工作表worksheet把工作表塞进工作簿workbook把工作簿写入文件并触发下载。import * as XLSX from xlsx; function exportExcel(data) { // 将对象数组转为工作表 const worksheet XLSX.utils.json_to_sheet(data); // 新建工作簿 const workbook XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, Sheet1); // 生成二进制并触发下载 XLSX.writeFile(workbook, export.xlsx); }这段代码能跑通而且json_to_sheet会自动根据对象的 key 生成表头非常方便。但我实测下来的感受是它是一个很薄很薄的封装适合快速导出几乎不提供样式能力。所以它最适合的场景是数据结构简单、不需要美化、只想拿文件走人的内部工具。3.2 列宽、合并单元格和样式的取舍SheetJS 社区版对样式的支持非常有限。如果你想设置单元格背景色、字体加粗、边框线需要购买专业版或者引入xlsx-js-style这类衍生库。这是很多项目埋下的一个隐性坑前期用 SheetJS 搭好了架子后期产品突然说表头要加个底色列宽调一下合并一下标题行开发瞬间陷入被动。列宽倒是可以在不引入额外依赖的情况下设置。做法是给工作表对象加一个!cols属性worksheet[!cols] [ { width: 10 }, { width: 20 }, { width: 30 }, ];合并单元格也可以通过!merges属性worksheet[!merges] [ { s: { r: 0, c: 0 }, e: { r: 0, c: 2 } }, // 第一行前三列合并 ];这里的r是行索引、c是列索引都是 0 开始的。可问题是一旦需求扩展到表头加粗、单元格背景色、冻结首行SheetJS 社区版就力不从心了。所以我对团队的建议是看好需求再选库别拿 SheetJS 硬撑复杂报表。3.3 多 sheet 工作簿的构造思路多 sheet 是数据导出里一个很常见的需求一个 Excel 文件里放汇总表和明细表或者按月份拆成多个工作表。用 SheetJS 实现起来不复杂const wb XLSX.utils.book_new(); const summarySheet XLSX.utils.json_to_sheet(summaryData); const detailSheet XLSX.utils.json_to_sheet(detailData); XLSX.utils.book_append_sheet(wb, summarySheet, 汇总); XLSX.utils.book_append_sheet(wb, detailSheet, 明细); // 设置第一个工作表为激活状态这样用户打开默认看到汇总页 wb.SheetNames.forEach((name, index) { wb.Workbook wb.Workbook || {}; wb.Workbook.Sheets wb.Workbook.Sheets || {}; wb.Workbook.Sheets[name] wb.Workbook.Sheets[name] || {}; wb.Workbook.Sheets[name].Views [{ RTL: false, ActiveTab: index 0 ? 1 : 0 }]; }); XLSX.writeFile(wb, report.xlsx);多 sheet 还好说真正的难点是当各个 sheet 的数据结构差异很大时json_to_sheet的自动表头并不总是符合预期得手动用aoa_to_sheetarray of arrays来构造明确控制每个单元格的内容。4. ExcelJS当你要的是精致的 Excel4.1 为什么 ExcelJS 更适合复杂报表如果你的需求像我最常遇到的财务月度报表那样——表头要合并居中字体要加粗数字要保留两位小数列宽要按内容调整甚至还要带几个公式——那 ExcelJS 是更合适的选择。ExcelJS 是一个功能完备的 Excel 操作库支持 Node 端和浏览器端双环境。它的核心特点是把单元格、行、列、样式都建模成了对象你操作起来像在操作一个 Excel 程序本身。import ExcelJS from exceljs; async function exportWithExcelJS(data) { const workbook new ExcelJS.Workbook(); workbook.creator My System; workbook.created new Date(); const sheet workbook.addWorksheet(月度报表, { views: [{ state: frozen, ySplit: 1 }], // 冻结首行 }); // 设置列 sheet.columns [ { header: 月份, key: month, width: 12 }, { header: 收入, key: income, width: 18 }, { header: 支出, key: expense, width: 18 }, { header: 结余, key: balance, width: 18 }, ]; // 表头样式 const headerRow sheet.getRow(1); headerRow.font { bold: true, size: 12 }; headerRow.alignment { vertical: middle, horizontal: center }; headerRow.height 22; // 添加数据行 data.forEach(item { sheet.addRow({ month: item.month, income: item.income, expense: item.expense, balance: item.income - item.expense, }); }); // 设置数字格式 sheet.eachRow((row, rowNumber) { if (rowNumber 1) { row.getCell(2).numFmt #,##0.00; row.getCell(3).numFmt #,##0.00; row.getCell(4).numFmt #,##0.00; } }); const buffer await workbook.xlsx.writeBuffer(); const blob new Blob([buffer], { type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, }); downloadBlob(blob, monthly-report.xlsx); }看到区别了吗ExcelJS 的处理方式是显式声明列宽、字体、对齐、数字格式全部一条条配置非常直观。代价是代码量上去了换来的是对最终文件形态的完全掌控。4.2 单元格样式、公式与数据校验的配置ExcelJS 支持的样式维度包括字体font、对齐alignment、边框border、填充fill、数字格式numFmt。这些配置组合起来能做出接近手工制作效果的报表。公式的写法也直接。如果你想让结余这一列不是由前端计算好而是 Excel 打开后自动计算可以在写入数据后设置sheet.getCell(D${rowNumber}).value { formula: B${rowNumber}-C${rowNumber} };相当于在单元格里写了一个 Excel 公式用户打开文件后如果改了收入和支出结余会自动更新。这在财务场景里是非常实际的需求。数据校验也是 ExcelJS 的亮点。比如想让状态这一列只能是通过或拒绝可以这样配置const statusCol sheet.getColumn(4); statusCol.eachCell(cell { cell.dataValidation { type: list, allowBlank: true, formulae: [通过,拒绝], }; });这样用户在 Excel 里点击单元格时会出现一个下拉列表只能从给定的选项里选。这比导出后用户乱填数据导致后续处理报错要友好得多。4.3 浏览器端和 Node 端共用的代码组织ExcelJS 一个比较优秀的地方在于逻辑可以在浏览器和 Node 端共享。理由是它操作的是内存中的工作簿对象最后通过workbook.xlsx.writeBuffer()把内容写成 buffer这个 buffer 在浏览器端可以被包成 Blob 触发下载在 Node 端可以直接写进文件系统。一个常见架构是服务端先从数据库查出数据用 ExcelJS 生成 xlsx 并直接返回文件流前端只要拿 URL 打开或下载即可。这样做的好处很明显——大数据量导出不消耗用户浏览器的内存也不会因为用户关了页面导致导出中断。我在中大型项目里的默认方案就是后端生成前端只负责发起请求和展示正在导出状态。如果坚持前端导出代码组织上建议把数据准备和文件生成拆成两个模块。数据准备负责从接口拉取、清洗、格式化文件生成负责接收标准化的二维数组或对象数组生成文件。这样后端要从 Node 端直接复用生成逻辑时只要数据格式对齐就能无缝迁移。5. 大文件导出的性能陷阱与流式处理思路5.1 几十万行数据时内存和卡顿从哪里来前端能不能一次导出 50 万行数据这个问题我每次都要先反问一句你确定用户真的需要一次看到 50 万行吗很多情况下这是需求方没有想过数据体量就随口提的真做出来用户也不会全看。但确实存在必须导大批量数据的场景比如导出全量用户列表、导出某个时间段的所有操作日志。这时候前端导出会遇到两个问题第一个是内存问题。json_to_sheet会把整个二维数组放在内存里构建工作表50 万行数据光字符串拼接就可能占用上百 MB 内存加上浏览器本身的占用用户的电脑很容易卡死。第二个是下载体验问题。数据量一大生成文件的时间可能长达几十秒用户看着页面没有反馈要么反复点击要么直接刷新关页面。这个问题比内存更常见也更影响用户体验。我见过各种硬扛的方案用requestAnimationFrame分批渲染提示正在导出 XX%用 Web Worker 在后台线程计算避免主线程阻塞用分页接口配合后端流式生成。这些方案各有各的适用场景但我要先泼一盆冷水一旦数据量到了十万行以上前端导出就是在一个不合适的层面做不合适的对抗不如直接考虑后端导出。5.2 分批写入与 Worker 线程的实际取舍如果因为种种原因必须在前端导那有两个可以落地的优化路径。路径一分批写入 每批之间让出主线程。核心思路是不一次性把整个大数组喂给库而是分批把数据写入 worksheetconst sheet workbook.addWorksheet(数据); // 每 5000 行写一批然后 await 一个宏任务让出主线程 const batchSize 5000; for (let i 0; i data.length; i batchSize) { const batch data.slice(i, i batchSize); batch.forEach(item sheet.addRow(item)); await new Promise(resolve setTimeout(resolve, 0)); }await new Promise(resolve setTimeout(resolve, 0))的实质是让出事件循环让浏览器有机会处理渲染、处理用户的点击事件。这样 UI 不会僵尸化页面上还可以放一个进度条。路径二用 Web Worker 做文件生成。把数据传给 WorkerWorker 里完成json_to_sheet和writeBuffer再把最终的 ArrayBuffer 传回主线程。主线程的 UI 全程不卡。Worker 方案最大的痛点是 ExcelJS 社区版打包成 Worker 时的体积问题以及跨域场景下 Worker 脚本的加载问题需要额外处理。而且要传数据给 Worker 本身也有结构化克隆的开销数据量大时这部分的耗时不可忽略。实测下来的感受是如果你的瓶颈主要是生成文件时 UI 卡死Worker 有效如果你的瓶颈是数据本身太大导致内存爆炸Worker 帮不了太多因为数据在 Worker 内存里也是一份拷贝。5.3 导出进度反馈的实现思路不管用哪种方案进度反馈都是值得做的。实现方式很简单拉取数据阶段按接口分批拉取用已拉取条数 / 总条数计算进度生成文件阶段如果发生在 Worker 里Worker 每处理一批就postMessage一次进度主线程更新进度条。基本代码结构是这样// Worker 内 for (let i 0; i data.length; i batchSize) { // 处理这一批 self.postMessage({ type: progress, percent: Math.min(100, Math.round((i / data.length) * 100)), }); } // 主线程 worker.onmessage (e) { if (e.data.type progress) { updateProgress(e.data.percent); } };这里有个容易犯的错误进度条只反映了文件生成的进度没包含数据拉取的耗时。如果数据拉取要 10 秒、文件生成只要 2 秒进度条会在 10 秒内一直是 0%然后突然跳到 100%体验并没有质变。正确的做法是把两个阶段的耗时都考虑进去或者分阶段显示正在获取数据和正在生成文件。6. 常见坑位排雷编码、日期、数字精度6.1 Excel CSV 乱码的完整解决方案前面说了加 BOM这里再补充几个实际场景里遇到过的乱码变体。变体一加了 BOM 依然乱码。检查你的文本是不是被转成了 UTF-16 或者 BASE64 字符串再塞进 Blob。Blob 的构造类型和数据的编码要一致如果数据本身是 UTF-16 的字符串光加 BOM 没用。变体二Excel 打开 CSV 后中文正常但列错位。这个通常是字段本身含逗号。我遇到过从数据库导出的备注字段里包含了英文逗号和换行符没有转义导致错列。解决方案就是前面提到的csvEscape。变体三用 Mac 的 Numbers 打开乱码。Mac 版的 Numbers 对 CSV 的解析规则和 Windows 版 Excel 不尽相同。群体是 Mac 用户时更稳妥的方案是直接导 xlsx绕开 CSV 编码的兼容性问题。6.2 长数字变科学计数法的处理这是导出场景里的经典问题身份证号、手机号、订单号这类长数字在 Excel 里打开后会变成科学计数法比如1.23457E17。原因不是前端生成了错误的数据而是 Excel 对超过一定长度的数字自动套用了科学计数格式。处理办法有两类办法一在数字后面加一个看不见的字符强制 Excel 把它当文本。常见做法是在数字前面加单引号就像手动在 Excel 里输入文本数字一样。但在 CSV 里前缀单引号并不总是被识别为文本标记更多时候它会原样出现在单元格里。办法二导出 xlsx 时把单元格的类型明确设为字符串。用 SheetJS 时可以这样处理const rows data.map(item ({ id: { t: s, v: String(item.id) }, // 强制作为字符串 name: item.name, })); const worksheet XLSX.utils.json_to_sheet(rows);用 ExcelJS 时更简单直接把值赋成字符串即可ExcelJS 会写成内联字符串row.getCell(id).value String(item.id); row.getCell(id).numFmt ; // 明确设定为文本格式关键是在源头就把长数字转成字符串不要依赖 Excel 打开后的自动格式。经验教训是不要在前端把item.id转成数字再传给导出函数很多人的做法已经很小心了结果把数据交给库的时候又被库推断成数字类型功亏一篑。6.3 日期格式在不同 Excel 版本下的表现日期问题是大坑中的大坑。从后端接口拿到的日期通常是 ISO 字符串如2025-06-01T00:00:00Z或2025/06/01。直接把这个字符串塞进单元格Excel 能不能正确识别为日期取决于它的解析策略不同语言环境的 Excel 表现还不一样。我个人的经验是明确配置日期格式不要依赖 Excel 的自动识别。用 ExcelJS 时row.getCell(date).value new Date(item.date); row.getCell(date).numFmt yyyy-mm-dd;这里有个细节new Date(item.date)如果是 ISO 字符串且带时区比如2025-06-01T00:00:00Z在 UTC8 的环境中浏览器会把它转成本地时间2025-06-01 08:00:00。如果你只需要显示日期日期本身不会变问题不大但如果数据里的时间字段是通过12:00:00Z这样存的中午时间转换后日期可能变成第二天。这就是为什么导出的日期比数据库里多一天的常见根源。稳妥做法是在数据准备阶段就把日期字符串按yyyy-mm-dd格式化好再作为字符串写入单元格并设置numFmt yyyy-mm-dd。7. 方案选型建议与我的个人经验7.1 不同业务规模下的选型对照表我把这么多年前端导出 CSV/Excel 的经验整理成一张选型对照表方便大家直接对照自己的场景做决定需求特征推荐方案理由简单数据列表导出业务方用 Excel 打开即可手写 CSV BOM零依赖代码量小维护成本低CSV 字段含逗号、换行、引号手写 CSV 转义函数必须处理特殊字符否则列错位导出 xlsx数据结构简单无样式要求SheetJSxlsxAPI 简洁json_to_sheet一行搞定需要表头样式、列宽、冻结、公式、多 sheetExcelJS样式和结构控制能力强数据量 10 万行以上优先考虑后端导出前端内存和体验都不适合硬扛跨团队协作后端也想复用生成逻辑ExcelJS 服务端导出Node 端支持良好可复用统一模块老项目需要兼容 IE手写 CSV msSaveBlob分支避免引入方案复杂度只为处理过时浏览器这个表格不代表 SheetJS 和 ExcelJS 只能二选一实际项目里完全可以共存导出简单快照用 SheetJS导正式报表用 ExcelJS。前提是定义好各自的边界别让一个模块承担过多的职责。7.2 我在实际项目中踩过的坑和坚持的做法最后聊几个我个人实操时比较坚持的做法。第一个坚持统一封装导出工具模块。不管用哪种库我都会把构造数据 - 生成文件 - 触发下载 - 异常处理封装成一个统一的导出服务。团队其他人要做新导出需求只需要传数据和配置项不接触底层库。这样做最大的好处是底层库升级或替换时改动收敛在一个人手里不会波及几十个页面。我见过太多项目xlsx的 import 散落在各个页面里某天 SheetJS 出了安全公告要升级那是灾难级的排查过程。有了统一封装升级库通常只改一个文件。第二个坚持导出前一定要确认数据格式。有一回财务系统的导出需求后端返回的金额字段是元为单位的字符串我直接塞给 ExcelJS 导出结果数字格式变成了一大串小数。后来定了一条规矩写导出代码前先写清楚每个字段的预期类型和格式这个文档然后由数据准备模块做强制转换。比如金额统一在准备阶段转成 number 并保留两位小数日期统一转成yyyy-mm-dd字符串长数字统一转字符串。这样能从源头拦截绝大多数导出格式问题。第三个坚持导出要带空状态和失败反馈。实际业务中用户点导出按钮时可能遇到接口超时、数据为空、浏览器弹出下载拦截等情况。我会在导出工具的 try/catch 里统一做错误提示并在开始下载前检测浏览器是否禁用了自动下载有时候a download会被某些浏览器设置拦截。这个细节做不做直接决定了用户对导出功能是不是好用的感知。第四个总结性的建议遇到导出需求先别动手写代码先问三个问题——要 CSV 还是 xlsx最大数据量是多少对格式样式的要求到哪一档。这三个问题问完你自己都会觉得这个需求突然变简单了。前端导出 CSV/Excel 看起来是个小功能但涉及编码、类型、样式、性能、兼容性多个维度。把每个维度的坑都摸一遍你在这个方向的积累就足以应对绝大多数业务场景了。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门