☰
前后端导出Excel全方案:SheetJS与ExcelJS实战及乱码样式坑
2026/10/14 16:08:51 网站建设 项目流程

很多后台项目做到一半都会遇到同一个需求:把页面上那张表格导成Excel。我在某个管理系统里也踩过这个坑——第三方表格插件自带的导出功能只能导当前页,用户每次打电话过来说"数据不对",我打开一看,明明就是导出文件里只有30行。后来我干脆自己动手实现,结果发现前端能导、后端也能导,两条路各有各的坑,踩过一遍才有了这篇文章。

这篇把前后端的导出方案都讲透:数据量小怎么在前端直接生成文件,数据量大、有权限控制、需要样式时怎么走后端接口,以及一系列高频问题——中文乱码、下载超时、样式丢失、日期变成一串数字。不管你是刚开始接触导出功能的新手,还是被乱码折磨过的老手,这章内容基本都能直接"抄作业"。

1. 先想清楚:这个导出功能要放在前端还是后端

1.1 导出动作的本质是"数据序列化"

别把导出想得太神秘。所谓导出Excel,本质上就是把内存里的结构化数据(数组、对象、数据库查询结果)转换成一种约定俗成的文件格式,让Excel或WPS能认出来。前端导出,是浏览器本地完成这个转换;后端导出,是服务器完成转换后再把文件字节流传给浏览器。

这两条路的差别,不在于"能不能生成xlsx",而在于数据从哪里来、文件生成后归谁处理。如果数据本来就在前端(比如用户当前页面的表格),前端直接生成文件最省事;如果数据在数据库、需要服务端聚合筛选、需要做权限校验,那就算前端写了生成代码,最终也得靠后端把数据吐出来。

1.2 决定放前端还是后端的四个关键指标

我在实际项目里一般用下面这张表做判断,看完基本不会选错:

判断维度适合前端适合后端
数据量几百到几千行上万行起步,几十万行很正常
数据来源已在前端内存/已加载页面数据库查询、跨服务聚合
权限控制无,页面能看就能导需要接口鉴权、数据脱敏
样式与复杂度纯数据表,样式要求低模板导出、复杂表头、报表头图
浏览器与服务器压力浏览器本地处理服务器分担运算,前端零压力
离线能力断网也能导必须联网

你会发现大多数管理后台的导出需求,其实都落在后端的方格子里:你要导的往往不是当前页那几十行,而是"全部筛选条件下的数据"。这时候别偷懒,直接走后端才是稳的。

1.3 最高频的选型错误:把全量数据拉回前端

有个典型的反面案例:某项目的导出功能,前端工程师嫌写接口麻烦,直接调列表接口,把pageSize改成100000,一次性把全量数据拉到浏览器,再用前端库生成Excel。结果是页面白屏、浏览器弹"无响应",用户等了半天才下载成功。

原因很简单:浏览器处理几万行数据就要占用大量内存,再把数据渲染成表格、换算成Excel的XML结构,主线程基本卡死。前端方案的上限取决于浏览器内存,而不是你的需求和用户耐心。我实测下来,超过一万行就该考虑后端方案,超过十万行则必须用后端的流式写入,这个经验在下面会展开讲。

2. 前端方案:用SheetJS在浏览器里直接生成Excel

2.1 最小可运行版本:三行代码生成一个xlsx

先说依赖。前端导出Excel,最常用的库是SheetJS(npm包名是xlsx),社区版免费,功能覆盖绝大多数场景。安装方式:

npm install xlsx

然后写一个最简单的例子,把二维数组直接变成Excel文件:

import * as XLSX from 'xlsx'; const rows = [ ['姓名', '部门', '薪资'], ['张三', '技术部', 15000], ['李四', '产品部', 12000] ]; // 1. 把二维数组转为工作表(Array of Arrays -> Sheet) const worksheet = XLSX.utils.aoa_to_sheet(rows); // 2. 创建工作簿并把工作表挂进去 const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, '员工信息'); // 3. 触发浏览器下载 XLSX.writeFile(workbook, '员工信息.xlsx');

这段代码的重点在aoa_to_sheet这个方法:它把"数组的数组"转换为Excel的行列结构。外层数组的每一项代表一行,内层数组的每一项代表一个单元格。理解了这一点,你就掌握了前端导出的核心逻辑,后面所有写法都是在这个基础上堆功能。

2.2 带表头的对象数组导出:json_to_sheet 的正确用法

大部分项目里的后端接口返回的是对象数组,比如[{ name: '张三', department: '技术部', salary: 15000 }]。直接用aoa_to_sheet要先手动抽字段拼数组,麻烦且容易漏。用json_to_sheet一步到位:

import * as XLSX from 'xlsx'; const employees = [ { name: '张三', department: '技术部', salary: 15000 }, { name: '李四', department: '产品部', salary: 12000 }, { name: '王五', department: '设计部', salary: 13000 } ]; // 第二个参数控制表头顺序和显示名 const worksheet = XLSX.utils.json_to_sheet(employees, { header: ['name', 'department', 'salary'], skipHeader: false }); // 如果你不想让英文键直接作为表头,可以手动重命名表头行 XLSX.utils.sheet_add_aoa(worksheet, [['姓名', '部门', '薪资']], { origin: 'A1' }); const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, '员工信息'); XLSX.writeFile(workbook, '员工信息.xlsx');

值得留意的是origin: 'A1'这个参数,它的意思是从A1单元格开始覆盖第一行。这样做的目的是:先用json_to_sheet生成数据行,再把第一行的英文表头替换成中文表头。如果你后端接口返回的字段本身就是中文,那这一步可以省掉。

2.3 文件名、兼容性和Blob下载的细节

XLSX.writeFile在多数现代浏览器上可以直接触发下载,但我在实际项目里遇到过Safari和部分国产浏览器兼容问题,表现为"下载没反应"或者弹出一个空白标签页。更稳的做法是先用XLSX.write生成Blob,再交给file-saver库去保存:

npm install file-saver
import * as XLSX from 'xlsx'; import { saveAs } from 'file-saver'; function exportExcel(rows, fileName) { const worksheet = XLSX.utils.aoa_to_sheet(rows); const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1'); // type: 'array' 返回ArrayBuffer,适合生成xlsx const excelBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' }); const blob = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); saveAs(blob, fileName); }

这里有一个容易忽略的点:XLSX.write的type参数决定返回什么格式,'array'生成ArrayBuffer,'buffer'生成Node.js Buffer,在浏览器里用'array'之后包Blob最稳。MIME类型也要写对,否则个别浏览器会当成未知文件下载,后缀名丢失。

2.4 前端方案的硬边界:样式、性能和内存

前端方案看着简单,但是有两条硬边界你得心里有数。

第一条边界:SheetJS社区版不支持样式。我试过给单元格加背景色、加边框,发现写进去之后Excel里完全没有。SheetJS社区版只能写数据、列宽、单元格合并这类基础结构,要样式必须购买Pro版。想在浏览器里做带样式的Excel,更实际的选择是改用exceljs浏览器版本,它支持样式但文档复杂一些,五颜六色的报表我建议直接看后面的后端方案。

第二条边界:大数据量会把浏览器拖垮。我做过一个实测:3万行、8列的数据,用SheetJS在前端生成xlsx,整个过程浏览器内存上涨了大概150MB,耗时接近8秒,期间页面可能掉帧。如果是10万行,页面基本就要白屏了。所以遇到"全量导出"这种需求,别跟浏览器较劲,把话术换成"导出任务已提交,请稍后在下载中心查看",由后端异步生成文件,这个体验反而更好。

3. 后端方案:用ExcelJS在服务端流式生成

3.1 为什么服务端要选流式写入而不是一次性拼接

后端生成Excel,最常被忽略的问题是内存。很多初学者会用前端那套思路:把几万行数据全部塞进一个数组,然后一次性写入Excel。这在数据量大时是灾难——Node.js默认堆内存也就1GB左右,50万行数据加上中间转换产生的临时对象,框架直接OOM。

正确的思路是流式写入:数据来一行,写一行;Excel文件内部是一个一个独立的XML部件,每一行写完就落到磁盘或响应流里,内存里只保留极小的缓冲区。我用exceljs做流式导出时,导出一份15万行数据的文件,内存占用始终稳定在40MB以内,这个对比是很有说服力的。

3.2 Express接口:创建WorkbookWriter并把文件流返回给浏览器

以Express为例,后端导出的标准骨架是这样的:

npm install exceljs
const ExcelJS = require('exceljs'); // GET /api/export/employees async function exportEmployees(req, res) { // 1. 设置响应头,告诉浏览器这是附件下载 res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent('员工信息.xlsx')}`); // 2. 创建流式WorkbookWriter,直接把数据写到res响应流里 const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({ stream: res, useStyles: true }); // 3. 创建一个工作表 const worksheet = workbook.addWorksheet('员工信息', { views: [{ state: 'frozen', ySplit: 1 }] // 冻结第一行 }); // 4. 定义列结构 worksheet.columns = [ { header: '姓名', key: 'name', width: 20 }, { header: '部门', key: 'department', width: 20 }, { header: '薪资', key: 'salary', width: 15 } ]; // 5. 从数据库分批取数并逐行写入 // 假设fetchPage(offset, limit)从数据库取一页数据 const pageSize = 2000; let offset = 0; while (true) { const pageData = await fetchPage(offset, pageSize); if (!pageData || pageData.length === 0) break; // 每行数据写入工作表 for (const row of pageData) { worksheet.addRow({ name: row.name, department: row.department, salary: row.salary }).commit(); // commit这一行数据到流 } offset += pageSize; } // 6. 完成写入,结束响应 await workbook.commit(); }

理解这段代码的核心在于WorkbookWriter的构造参数stream。传了res之后,Excel文件的内容会边生成边写入HTTP响应,浏览器端的表现就是"稍等一下,然后直接弹出下载"。数据的写入通过worksheet.addRow()添加,每行补充一个commit(),是为了立即把这一行推到流里,避免在内存里积压。

3.3 列宽、表头样式和数字格式怎么加

很多后端导出的文件"能用但难看",表头灰底、没有边框、列宽全默认。用ExcelJS加样式并不复杂,关键是要知道几个属性的位置:

// 在设置列结构时,把表头样式一起做了 worksheet.columns = [ { header: '姓名', key: 'name', width: 20, style: { font: { name: '微软雅黑', size: 12 }, alignment: { vertical: 'middle', horizontal: 'center' } } }, { header: '部门', key: 'department', width: 20, style: { font: { name: '微软雅黑', size: 12 }, alignment: { vertical: 'middle', horizontal: 'center' } } } ]; // 表头单独加背景色和边框 const headerRow = worksheet.getRow(1); headerRow.height = 28; headerRow.eachCell(cell => { cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4472C4' } }; cell.font = { name: '微软雅黑', size: 12, bold: true, color: { argb: 'FFFFFFFF' } }; cell.alignment = { vertical: 'middle', horizontal: 'center' }; cell.border = { top: { style: 'thin' }, left: { style: 'thin' }, bottom: { style: 'thin' }, right: { style: 'thin' } }; }); // 提交表头的样式变更 headerRow.commit();

这里提醒一句:fill的类型是pattern,fgColor是填充色,写错类型会直接静默失败,样式悄悄没掉。border必须写全四个方向,少写一个方向那个边框就不会出现。还有getRow(1)在流式模式下要记得commit(),否则表头样式可能丢失。

3.4 大批量数据时数据库侧的分页取数写法

服务端流式写入解决了"生成文件"的内存问题,但别忘了"取数据"也可能撑爆内存。一次SELECT * FROM employees WHERE ...拉几十万行到Node进程里,同样危险。正确做法是分批查、批批写,上面示例里的fetchPage可以理解为游标分页:

async function fetchPage(offset, limit) { // 使用数据库驱动内置的分页查询,例如MySQL的 LIMIT offset, limit return await db.query( 'SELECT name, department, salary FROM employees WHERE status = 1 LIMIT ? OFFSET ?', [limit, offset] ); }

分页大小我建议控制在2000~5000行一页,太少会增加查询次数,太多又会让单次查询和大对象转换的时间变长。实际经验是2000行一页对MySQL和Node内存都很友好,查询耗时稳定在100ms以内,整体导出10万行大约耗时10~20秒,用户体验是可以接受的。

4. 实战高频坑:乱码、超时、样式对不上

4.1 中文乱码十有八九是BOM问题

乱码最常出现在CSV导出里,但偶尔在Excel里也会碰到。原因是Excel打开文件时,默认按系统本地编码解析文件内容,UTF-8编码的文件如果没有BOM头(字节顺序标记),中文就会被解读成乱码。

如果项目里既要导CSV又要导Excel,CSV那段务必加一个BOM。前端写法是:

const csv = '\ufeff' + rows.map(r => r.join(',')).join('\n'); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); saveAs(blob, '数据.csv');

\ufeff这个不可见字符会写进文件头部,Excel识别到之后就知道用UTF-8解码,中文就不会乱了。这是我踩过最莫名其妙的一次坑,排查半天才发现是少了这三个字节。

4.2 日期变成一串数字:Excel序列号的秘密

Excel存储日期的底层机制是"序列号":1900年1月1日对应数字1,之后每一天加1。如果你直接把JavaScript的Date对象或者数据库返回的日期字符串丢给工作簿,Excel可能显示成45292这样的怪数字,也可能显示成#,反正不是你能读懂的日期。

正确做法是写数据之前把日期格式化成年月日字符串:

function formatDate(value) { if (!value) return ''; const d = new Date(value); const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${day}`; } // 写入时 worksheet.addRow({ name: row.name, joinDate: formatDate(row.join_date) });

想要真正在Excel里呈现为"日期类型"而不是文本,需要给单元格设置格式:

cell.numFmt = 'yyyy-mm-dd';

但优先方案还是先转成字符串,简单可靠,继续格式化的需求再说。

4.3 Content-Disposition里的中文文件名

后端设置下载响应头时,我见过很多人这么写:

res.setHeader('Content-Disposition', `attachment; filename=员工信息.xlsx`);

浏览器下载时文件名要么乱码,要么直接被截断。这是因为HTTP头默认只支持ASCII字符,中文必须做百分号编码。标准的写法是用RFC 5987格式:

res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent('员工信息.xlsx')}`);

注意是filename*=加在文件名字符串里,而且一定要带UTF-8''前缀。这样浏览器解析时才能还原出中文文件名。如果你还要兼容非常老的浏览器,可以在filename*前面再加一个filename=export.xlsx作为降级方案,但现代浏览器已经足够支持filename*了。

4.4 样式丢失、列宽失控:属性名和单位问题

用ExcelJS加半天样式,下载一看啥都没有,这种挫败感我懂。排查顺序一般是:

现象原因处理
背景色不生效fill.type没写pattern补上type: 'pattern'
字体不生效字体名写成英文别名用'微软雅黑'而非'Microsoft YaHei'
列宽很窄或宽到离谱单位理解错误ExcelJS的width按字符数算,不是像素
表头样式没渲染流式模式下未调用commit()表头行和每一行都要commit()
列宽和内容完全不匹配中文宽度突然变大中文一字算2个字符宽,估算时按2倍算

加一个实操技巧:列宽设置的中文列,先按"期望显示字数×2"估算,比如期望显示10个中文字,width就设20;数字列按最小值12,保证8到10位数字不挤压。

5. 选型落地:不同体量下的方案建议

5.1 小报表(千行以内):前端最快

先导视图、临时统计、当日订单列表这类数据量在一千行以内的导出,用前端方案效率最高。无需后端配合改接口,点击导出按钮的瞬间文件就下来了,体验最顺畅。SheetJS社区版足够,样式上如果老板没有特殊要求,白底黑字也能接受。有配色要求时,前端改用exceljs或直接用后端方案都行。

5.2 中大型导出(万级以上):后端流式+异步任务

超过一万行的导出,我建议直接走后端流式写入。如果是超大数据量、可能超过一分钟的任务,别让HTTP请求干等。更稳的玩法是拆成异步任务:

  1. 接口收到导出请求后,先在数据库任务表里插入一条记录;
  2. 后台任务定时扫描待导出的任务,流式生成Excel并上传到临时存储;
  3. 前端轮询任务状态,任务完成后显示下载按钮。

这种设计把"导出"变成"异步任务",用户不会因为长时间等待而关掉页面。短时间内并发多个30万行导出时,服务端也不容易被拖垮。

5.3 复杂模板导出:从空白生成改为模板填充

如果你的需求是"下载一个带品牌Logo、封面标题、合并单元格、固定格式的报价单",我强烈建议不要用代码一点点堆样式。你完全可以在开发阶段生成一个空白xlsx作为模板,后端读取模板并填入数据,复杂样式完全由模板文件本身承载。

const templateWorkbook = new ExcelJS.Workbook(); await templateWorkbook.xlsx.loadFile('./templates/invoice.xlsx'); const worksheet = templateWorkbook.getWorksheet('报价单'); worksheet.getCell('B2').value = order.no; worksheet.getCell('B4').value = customer.name; await templateWorkbook.xlsx.write(res); res.end();

这比在代码里定义字体、边框、合并单元格高效得多,而且后续业务要改模板,发给运营改Excel就行,开发几乎不参与。

5.4 我最后特别想强调的两个细节

第一个是测试用例必须包含空数据。我遇到过接口查询结果为空时,worksheet.addRow({})写入的Excel打开以后列结构全是乱的。正确做法是查询为空时仍然写入一个表头行,并加一条"无数据"的提示行,避免用户拿到一个看似正常的空表格。

第二个是导出完一定要自己下载检查一次。我每次写完导出功能,都会用文件名带中文、表头带特殊字符、内容带长数字(比如身份证号)这三类数据做回归验证。身份证号这个坑很经典——如果不把单元格格式改成文本,Excel会自动把超过15位的数字转成科学计数法并截断后几位,数据就毁了。处理办法是写入时给单元格设置numFmt: '@',强制按文本处理。

个人经验是,导出功能看似不起眼,实际上离"用户体验"非常近。用户不会看你的代码多优雅,但一定会因为下载下来的文件打不开、乱码、样式乱而来问一遍。把上面这些高频坑在写代码时就预防掉,导出功能就能成为项目里最让别人放心的模块。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询