资讯动态

前端导出Excel实战:ExcelJS+file-saver完整方案与踩坑记录

发布时间:2026/10/21 13:39:12 来源:尧图企业网站定制
后台管理系统做到一定阶段总有一个看起来不起眼、但躲不掉的功能把表格数据导成 Excel。我最近就接了这么个活需求描述大概只有一句话——“把筛选出来的数据下载成 Excel 表格”听起来很简单真做起来却不是调一个接口就能交差的。尤其是当数据来自后端接口、需要在前端动态拼装、还要求表头好看、文件名正常、几万行数据不能卡死页面的时候方案选型和处理细节就变得很重要。这篇文章完整复盘一下我用 exceljs file-saver 实现后端数据在前端转成 Excel 并下载的整套方案。内容包括技术选型对比、ExcelJS 核心概念、字段映射与清洗、完整通用导出函数、以及我在真实项目里踩过的各种坑。适合正在做中后台项目、被“导出 Excel”功能折腾过、又不想事事麻烦后端的前端同学也适合刚接触电子表格前端生成的新手。1. 需求诞生与技术选型1.1 为什么这个需求更适合放在前端做很多团队遇到导出功能的第一反应是让后端写一个导出接口返回一个文件流前端用 a 标签或者 window.location 直接下载。这个思路不能说错但实际维护起来很痛苦。后端生成 Excel 的方案通常是处理类库直接生成 xlsx再把文件流通过接口返回。这样做有几个绕不开的麻烦前端改动列名、样式、字段顺序都要去求后端改接口一来一回半天就过去了。接口直接把文件流吐出来在大数据量场景下很容易超时用户只看到几个菊花转圈体验很差。接口暴露了文件生成逻辑权限控制、下载日志、内容安全这些事都得后端单独处理链路被拉得很长。如果产品说“导出时要有合计行标题要加粗列宽要自适应还要冻结首行”后端每次都要重新发布。前端做导出数据还是走原来的列表查询接口只是在拿到 JSON 数据之后用 exceljs 在浏览器内存里生成 xlsx 二进制再用 file-saver 触发浏览器下载。列名、样式、排序、合并单元格全由前端控制后端完全不用感知一个通用导出函数能覆盖很多页面。从团队协作效率角度看这个方案天然更有优势。1.2 exceljs file-saver 为什么是首选组合社区里能生成 Excel 的前端库不少我做了一次快速对比核心结论如下方案优点缺点适合场景ExcelJS样式能力最全支持颜色、边框、合并、公式、超链接浏览器和 Node 都能跑学习成本略高大数据量时需要做优化中后台数据导出、带复杂表头的报表SheetJS 社区版解析能力强老牌项目社区版不支持样式专业版收费只需简单读取或导出无样式 CSVCSV 文本导出实现最简单体积小中文编码易乱码不支持单元格样式日期和数字格式难控制临时工具、日志导出浏览器原生 Blob a 标签手写下载不依赖第三方下载库各浏览器命名行为不统一兼容性坑多对兼容性要求不高的内部系统所以我的选型是exceljs 负责“生成”file-saver 负责“下载”。exceljs 生成的 xlsx 本质是一个二进制 Buffer在浏览器里要转成 Blob再用 saveAs 触发下载。file-saver 的优势在于帮我们处理了不同浏览器的下载差异比如 Safari 下的命名问题、部分 Webkit 内核的路径问题省掉了自己手写那十几行兼容代码的麻烦。提示如果项目极简确实可以用URL.createObjectURL(blob)配合a download实现下载。但一旦遇到中文文件名、iframe 场景或者旧版浏览器file-saver 更稳。与其在别人踩过的坑里再踩一遍不如直接依赖一个成熟小库。2. ExcelJS 核心概念拆解2.1 Workbook、Worksheet、Cell 三层结构ExcelJS 的对象模型和 Excel 文件结构完全对应理解三层结构后面写代码基本不会迷路。Workbook就是整个 xlsx 文件相当于日常说的“工作簿”。Worksheet是工作簿里的一个 Sheet 表格页一个 Workbook 里可以创建很多个 Worksheet。Cell是表格里的单元格通过行、列定位。我用一个生活化类比来理解Workbook 像一本多页记事本每一页是一个 Worksheet每页上的格子是 Cell。操作 Excel 文件的第一步永远是new ExcelJS.Workbook()然后 addWorksheet再往 worksheet 里填充内容。实际编码中最常用的几句是const workbook new ExcelJS.Workbook(); const worksheet workbook.addWorksheet(数据报表); // 通过字母定位单元格 worksheet.getCell(A1).value 标题; // 通过行列号定位单元格行列号从 1 开始 worksheet.getCell(2, 1).value 100; // 获取整行或整列再操作 const row2 worksheet.getRow(2); const colA worksheet.getColumn(A);这里我多说一句用行列号比用字母定位更省心。写循环遍历的时候字母坐标还要转数字行列号直接就是数字配合 for 循环非常顺手。2.2 columns 列定义表头和字段的映射入口这是 exceljs 里最容易忽略却最核心的概念。columns 是一个数组每个元素定义一列worksheet.columns [ { header: 姓名, key: name, width: 18 }, { header: 部门, key: dept, width: 24 }, { header: 月薪, key: salary, width: 14 }, ];header 是表头要显示的文字key 是后续 addRow 时对象属性名width 是列宽。设置好 columns 之后第一行会自动变成表头行紧接着 addRow数据会从第二行开始排列。这个机制的好处是你只需要在 columns 里声明一次字段与表头的映射关系之后加数据时不用关心列顺序exceljs 会按 key 自动把数据放到对应的列里。如果后端返回的是一个按 key 排好的数组对象直接worksheet.addRows(data)就能一次性写入大量数据。有个细节很容易踩到columns 必须在 addRow 之前定义。如果你先 addRow 再设置 columns表头虽然会出现但原有的数据行可能不会自动和 columns 对齐甚至某些环境下表头会覆盖掉第一行真实数据。这个顺序问题后面排查章节会再展开。2.3 单元格样式模型与常用属性ExcelJS 的样式体系很完整主要分为字体、填充、边框、对齐、数字格式五大类。每一类都是一个配置对象直接赋值给 cell 或者行对象。字体用在表头加粗、标题变色cell.font { name: 微软雅黑, size: 11, bold: true, color: { argb: FFFFFFFF } };填充是背景色注意一定要写完整结构cell.fill { type: pattern, pattern: solid, fgColor: { argb: FF4472C4 } };边框需要四边分别声明cell.border { top: { style: thin, color: { argb: FFD9D9D9 } }, left: { style: thin, color: { argb: FFD9D9D9 } }, bottom: { style: thin, color: { argb: FFD9D9D9 } }, right: { style: thin, color: { argb: FFD9D9D9 } } };对齐方式支持水平、垂直和自动换行cell.alignment { horizontal: center, vertical: middle, wrapText: true };数字格式也值得一提比如保留两位小数、千分位cell.numFmt #,##0.00;样式不光可以单独设置一个单元格也可以遍历一行、一列甚至一个矩形区域批量设置。实际项目里最典型的做法是先写完所有数据再对表头行做一次统一美化最后对正文区域设置边框。3. 数据获取与字段映射3.1 后端数据形态与前端处理逻辑前端导出 Excel 的第一步是拿到后端返回的“干净”数据。真实项目里后端接口返回的结构五花八门最常见的是下面两种// 形式一直接返回数组 { code: 0, data: [ { id: 1, name: 张三 }, { id: 2, name: 李四 } ] } // 形式二分页对象 { code: 0, data: { list: [ ... ], total: 100, page: 1, pageSize: 20 } }不管哪种我们最终要做的是把 data 里的数组抽出来转成导出函数需要的格式。这个步骤看起来简单但我建议一定不要跳过直接在 addRow 时现想办法处理字段很容易写出又丑又难维护的代码。我习惯的做法是先请求接口然后在调用导出函数之前把数据做一次标准化const res await fetch(/api/user/list?page1pageSize9999); const json await res.json(); const rawData json.data.list || json.data || []; const exportData rawData.map(item mapExportRow(item));这样导出函数只接收一个标准数组内部不用关心字段到底是从哪里来的后续复用起来特别舒服。3.2 字段映射、类型清洗与文本型数字保留后端字段大多是 snake_case比如 hire_date、department_name而前端表格显示时要变成“入职日期”“部门名称”。字段映射本质是给导出函数传入一个 columns 配置同时把数据对象做一次换名。我比较推荐这种显式映射function mapExportRow(row) { return { name: row.user_name || --, dept: row.department_name || --, hireDate: formatDate(row.hire_date), salary: Number(row.salary || 0), }; }这里有几个经验想分享。日期字段千万不要拿到什么就写什么。后端可能是 2024-01-01 10:00:00 字符串也可能是一个时间戳数字。写进 Excel 之前统一格式化比如转成 YYYY-MM-DD 或者直接转成 Date 对象Excel 才能正确识别为日期。数字字段尽量保持 Number 类型。有些人图省事把所有值都转成字符串结果导出后数字列无法求和也无法排序。更麻烦的是金额列会有尾差显示出来的东西别人不敢信。文本型数字要特别小心比如身份证号、手机号、订单编号。如果你在代码里写cell.value 12345678901234567890Excel 打开后大概率变成科学计数法看着很吓人。稳妥做法是强制把单元格类型设置为文本或者用 exceljs 的对象语法cell.value { text: 8232922222222222 };这样既保留了原始字符串内容又不会触发 Excel 的科学计数法自动转换。这个细节我推荐在写通用导出函数时直接对 String 类型的字段做统一处理。另外空值也要处理。后端返回 null、undefined、空字符串的情况很常见直接在单元格里写 null 可能变成空白部分旧版本 Excel 打开时会提示文件异常。我习惯统一转成 -- 或者空字符串保证单元格内容始终是有效值。4. 完整实操从零构建导出功能4.1 安装依赖与引入方式先安两个依赖npm install exceljs file-saver引入的姿势按项目环境来。现代前端工程用 ESM 或 CommonJSimport ExcelJS from exceljs; import { saveAs } from file-saver;如果项目没有模块打包工具比如纯 HTML 页面也可以使用 script 标签引入script srchttps://unpkg.com/exceljs/dist/exceljs.min.js/script script srchttps://unpkg.com/file-saver/dist/FileSaver.min.js/script这时全局会挂载 ExcelJS 和 saveAs 两个变量后面代码基本一样。唯一要注意的是 CDN 引入时ExcelJS 的默认导出方式有时不太一致建议用window.ExcelJS显式访问。4.2 构建 Workbook 与写入数据下面这段代码是最小可用版本先跑通再谈美化async function exportSimple(list) { const workbook new ExcelJS.Workbook(); const worksheet workbook.addWorksheet(数据报表, { views: [{ state: frozen, ySplit: 1 }] }); worksheet.columns [ { header: 姓名, key: name, width: 18 }, { header: 部门, key: dept, width: 24 }, { header: 入职日期, key: hireDate, width: 16 }, { header: 月薪, key: salary, width: 14 }, ]; list.forEach(item { worksheet.addRow({ name: item.name, dept: item.dept, hireDate: item.hireDate, salary: item.salary, }); }); const buffer await workbook.xlsx.writeBuffer(); const blob new Blob([buffer], { type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, }); saveAs(blob, 数据报表.xlsx); }这里有两个容易错的地方。worksheet 创建时传入的 views 数组是冻结首行用的。ySplit: 1表示冻结前 1 行之后用户拖动滚动条时表头永远固定在顶部。导出报表特别需要这个设置。writeBuffer()在浏览器里返回的是一个 ArrayBuffer在 Node 环境返回 Buffer两者都可以直接作为 Blob 的输入。Blob 的 type 必须是 xlsx 的官方 MIME 类型少了这个类型部分浏览器会把文件当作文本下载双击打不开。4.3 表头样式与整体美化数据写入之后接下来是给表头做统一美化。我最常做的是四方格加粗、白字、深蓝底、居中对齐、加边框。const headerRow worksheet.getRow(1); headerRow.height 24; headerRow.eachCell(cell { cell.font { name: 微软雅黑, size: 11, bold: true, color: { argb: FFFFFFFF } }; cell.fill { type: pattern, pattern: solid, fgColor: { argb: FF4472C4 } }; cell.alignment { horizontal: center, vertical: middle }; cell.border { top: { style: thin, color: { argb: FFD9D9D9 } }, left: { style: thin, color: { argb: FFD9D9D9 } }, bottom: { style: thin, color: { argb: FFD9D9D9 } }, right: { style: thin, color: { argb: FFD9D9D9 } }, }; });正文区域还可以加一个自动过滤按钮方便用户拿到文件后直接排序筛选worksheet.autoFilter { from: A1, to: D${list.length 1}, };注意 autoFilter 的下标要用${list.length 1}因为第一行是表头。这个功能在数据量多的报表里非常实用用户不用自己手动加筛选。正文数据如果也想加边框可以遍历数据区域const endRow list.length 1; for (let rowIndex 2; rowIndex endRow; rowIndex) { const row worksheet.getRow(rowIndex); row.eachCell(cell { cell.border { top: { style: thin, color: { argb: FFD9D9D9 } }, left: { style: thin, color: { argb: FFD9D9D9 } }, bottom: { style: thin, color: { argb: FFD9D9D9 } }, right: { style: thin, color: { argb: FFD9D9D9 } }, }; cell.alignment { horizontal: left, vertical: middle }; }); }这里每行都遍历一次 eachCell如果数据量很大可能有点慢后面我会讲优化策略。4.4 导出 Blob 并触发下载生成二进制、转 Blob、saveAs 下载这三步是核心闭环。写成一个函数方便复用async function writeAndDownload(workbook, fileName) { const buffer await workbook.xlsx.writeBuffer(); const blob new Blob([buffer], { type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, }); saveAs(blob, fileName); }saveAs 与 Blob 之间其实也可以简化。file-saver 内部自己会按 needsFallback 逻辑做兼容处理但在现代 Chrome、Edge、Firefox 上它都是优先创建临时 a 标签并触发 click体验是一致的。下载后的文件名后缀我建议固定.xlsx。如果你自己拼文件名注意用文件名的核心部分不要带上接口里传回来那种带奇怪前缀的字符串否则用户导出的文件叫“xxx_1_2345678.xlsx”体验真不太行。4.5 封装成通用导出函数把前面所有代码收拢成一个通用函数业务侧调用就非常清爽了。我平时封装的长这样import ExcelJS from exceljs; import { saveAs } from file-saver; export async function exportExcel({ columns, data, sheetName Sheet1, fileName 导出数据 }) { const workbook new ExcelJS.Workbook(); const worksheet workbook.addWorksheet(sheetName, { views: [{ state: frozen, ySplit: 1 }], }); worksheet.columns columns.map(col ({ header: col.header, key: col.key, width: col.width || 16, })); worksheet.addRows(data); const headerRow worksheet.getRow(1); headerRow.height 24; headerRow.eachCell(cell { cell.font { name: 微软雅黑, size: 11, bold: true, color: { argb: FFFFFFFF } }; cell.fill { type: pattern, pattern: solid, fgColor: { argb: FF4472C4 } }; cell.alignment { horizontal: center, vertical: middle }; cell.border { top: { style: thin, color: { argb: FFDCE6F1 } }, left: { style: thin, color: { argb: FFDCE6F1 } }, bottom: { style: thin, color: { argb: FFDCE6F1 } }, right: { style: thin, color: { argb: FFDCE6F1 } }, }; }); const buffer await workbook.xlsx.writeBuffer(); const blob new Blob([buffer], { type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, }); saveAs(blob, ${fileName}.xlsx); }调用侧只要维护一份 columns 配置再把数据塞进来即可const columns [ { header: 姓名, key: name, width: 18 }, { header: 部门, key: dept, width: 24 }, { header: 入职日期, key: hireDate, width: 16 }, { header: 月薪, key: salary, width: 14 }, ]; await exportExcel({ columns, data: exportData, sheetName: 人员情况, fileName: 人员花名册, });这个函数我一般会放在 utils 目录下所有页面共用一份后续要改全局样式只改一个地方。第一次写的时候可以简单点后续加需求在函数内部扩展就行。4.6 进阶公式、超链接与合并单元格导出功能不总是那么朴素。我接过一个需求要在导出表里放“部门汇总”数据里没有现成结果这时候直接写公式最合适。exceljs 支持给单元格赋公式const sheet workbook.addWorksheet(统计); // 在 C10 单元格写入 SUM 公式 sheet.getCell(C10).value { formula: SUM(C2:C9) };当用户打开 Excel 时公式会自动计算结果不需要我们提前算好。这在报表汇总场景里特别好用但要小心公式引用范围别写错。超链接也是表格里常见的。如果单元格内容是一个官网地址我们希望用户点击能跳转同时单元格里显示一个友好的文字cell.value { text: 点击访问, hyperlink: https://example.com, }; cell.style { font: { color: { argb: FF0563C1 }, underline: true }, };合并单元格更好理解worksheet.mergeCells(A1:C1); worksheet.getCell(A1).value 这是一个合并标题;不要以为合并了就完事合并区域里的边框和样式经常要单独补后面排错章节我会给一个完整范本。5. 常见问题与排查技巧实录5.1 中文文件名与内容乱码我遇到最多的一个情况是Excel 导出后文件名在浏览器里看是正常的下载到本地变成一串乱码。这主要出现在部分 Webkit 内核浏览器中file-saver 在某些版本里对中文文件名支持得不是特别好。面对这个问题我的经验是优先确保调用saveAs(blob, 中文名.xlsx)时文件名是纯字符串不要在文件名里拼接时间戳或接口返回的乱码字段。如果还是乱码可以试试对文件名做 encodeURIComponent 转换。不过这个属于 workaround不同浏览器表现不一样。内容乱码的情况反而很少因为 xlsx 内部本质是 UTF-8 编码的 XML 打包文件。如果单元格里中文乱码根源基本是后端接口返回的数据本身已经乱码先检查请求返回的 JSON 是否正确。简单说文件名乱码优先确认 file-saver 版本和浏览器内容乱码优先排查接口数据。5.2 日期数据少 8 小时的时区坑日期是最容易翻车的地方之一。exceljs 在把 Date 对象写入单元格时会按 UTC 时间序列化。直接用new Date(2024-01-01)生成日期在部分时区环境下写入 Excel打开后可能会显示成 2023-12-31 16:00看起来像是少了一天。我常用的解决办法是手动构造一个不含时区偏移的 UTC 日期function toExcelDate(date) { return new Date( Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) ); }然后在写入数据时统一走这个函数。更简单粗暴的做法是直接把日期格式化成 YYYY-MM-DD 字符串写入单元格同时给单元格设一个文本格式。这样 Excel 打开后不会随意做日期转换只是单元格类型变成文本了。如果你希望单元格保留日期类型推荐用第一种方式。5.3 样式“失灵”的三种真相我在周期性的疑难问题里总结过所谓“样式不生效”绝大多数是以下几个原因之一。第一columns 设置顺序问题。前面提过如果你先 addRow 再设置 columns表头可能覆盖到原有数据或者已经写入的数据样式全部丢失。正确的顺序永远是定义 columns → 写入数据 → 设置样式。第二fill 结构不完整。给单元格设置背景色时漏掉了type: pattern或者pattern: solidExcel 拿到一个不完整的填充对象可能直接忽略。最简单的方式是永远复制一份完整的 fill 对象。第三边框设置不完整。Excel 的每个单元格有四条边你只设置 top 和 bottom视觉上左右两边的线就是断开的。特别是合并单元格场景合并后只能看到左上角单元格的样式其他区域全是白的必须对合并区域内的每个单元格单独设置边框。排查顺序建议是先确认单元格能不能取到其次确认设置对象结构完整最后看浏览器控制台有没有对象属性拼写错误。这三点查完九成样式问题都能定位。5.4 大数据量导出卡顿的优化手段几万行数据在前端生成 xlsx内存和 CPU 压力都不小。我的经验是以下几招。尽量用addRows批量写入而不是循环里逐个addRow。exceljs 内部批量处理比单条写入节省大量开销。样式设置不要逐格套用。如果几百行数据每行都做 eachCell遍历次数会非常恐怖。改成分区域统一处理或者只给表头加完整样式正文行只加文字对齐效率会高很多。冻结首行和 autoFilter 会额外消耗一些计算资源大表导出时可以只保留冻结首行关掉自动筛选。真的遇到十万行以上前端方案就比较吃力了。这时候我的建议是分多个 Sheet每 3 万行放一个 Sheet并且明确告诉产品“大表建议定时由后端批量生成”。前端不该硬扛的场景没必要为了技术实现自我感动。5.5 合并单元格边框补全与自动换行合并单元格的样式补全是经典坑。worksheet.mergeCells(A1:C1)只是把区域合并成一个逻辑单元格但 A2、B2、C2 这些原有单元格的边框并不会自动统一。我一般这样处理ws.mergeCells(A1:C1); ws.getCell(A1).value 合并标题; for (let col 1; col 3; col) { const cell ws.getCell(1, col); cell.border { top: { style: thin, color: { argb: FF000000 } }, left: { style: thin, color: { argb: FF000000 } }, bottom: { style: thin, color: { argb: FF000000 } }, right: { style: thin, color: { argb: FF000000 } }, }; cell.alignment { horizontal: center, vertical: middle }; }这个循环把合并区域里每个单元格的边框都补上视觉上才能看到完整表格框线。自动换行是另一个容易忽略的点。当单元格内容太长如果只设置wrapText: true行高不会自动按内容高度增加展示出来还是截断的。要么手动给这一行设置一个足够大的 height要么在写完数据后统一按内容估算行高。日常场景我倾向于先估算最大文本长度手动给行高一个合适值省得用户打开文件后还得自己拖行高。6. 导出体验优化与整体回顾6.1 全量数据获取与分页接口的循环拉取列表页接口通常是分页的而导出需要全量数据。最稳妥的做法是先请求一次不带条件的统计接口拿 total然后按 pageSize 循环拉取拼出全量数组。我这里给一个简化示例async function fetchAllData(url) { const all []; const pageSize 500; const firstRes await fetch(${url}page1pageSize${pageSize}); const firstJson await firstRes.json(); const total firstJson.data.total || firstJson.data.list.length; all.push(...(firstJson.data.list || [])); const totalPages Math.ceil(total / pageSize); for (let page 2; page totalPages; page) { const res await fetch(${url}page${page}pageSize${pageSize}); const json await res.json(); all.push(...(json.data.list || [])); } return all; }注意循环里每一页都要做一下错误判断避免某一页失败导致整体中断给出“第几页数据拉取失败”的提示比静默吞掉要好得多。6.2 按钮状态、错误兜底与进度提示导出按钮点击后用户看到的第一反馈很重要。我在开发时专门加了一个状态管理点击按钮先 disable按钮文字变成“正在导出...”防止用户连续点击。数据拉取和 writeBuffer 都是异步操作期间页面不能卡死。如果接口报错showError 提示“数据拉取失败请稍后重试”。如果导出成功再恢复按钮。代码片段看起来是这样btn.disabled true; btn.textContent 正在导出...; try { await handleExport(); } catch (err) { console.error(err); showToast(导出失败请稍后重试); } finally { btn.disabled false; btn.textContent 导出 Excel; }这个小改动对用户体感提升非常明显很多人会连续点导出按钮导致同一时间发起多个相同请求按钮状态能有效避免这种浪费。6.3 我对这套方案的整体感受exceljs file-saver 这套组合在中后台导出场景里已经是我心里的第一顺位方案。它核心胜在样式可控、前后端解耦、代码复用度高。真要说缺点可能就是大表格性能需要额外优化以及 API 文档有些细节比较绕得靠实际踩坑积累经验。我个人现在做每个新项目都会在最开始就把通用导出函数、字段映射工具、日期格式化函数三个工具准备好。所有列表页导出都走同一套流程产品提出新列、新样式我只需要改 columns 数组不用每个页面各写一套专用代码。这种“一次封装、到处复用”的做事方式确实能让以后的维护轻松不少。最后再分享一个小技巧如果后端数据里嵌套了对象比如 user: { name, avatar }不要直接在 addRow 时传 user 对象exceljs 只能读取一层属性。提前拍平成一维结构导出函数才不容易在某个字段上悄悄丢掉内容。希望这篇踩坑记录能帮你的导出功能少掉几次发际线。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑