前阵子接了个后台管理系统的改造需求,报表页要加一个“导出 Excel”按钮。数据已经在前端拉下来了,后端不提供导出接口,也不可能为了一个导出功能去加一个服务。我当时的思路就一个:纯 JS 把数据生成 Excel 能打开的文件,浏览器直接下载。做完之后我把这套方案沉淀成了一个不依赖后端、不依赖网络请求的纯 JS 导出工具,离线环境也能秒出表格文件。这篇就把它讲透:为什么有现成库还要自己写、几条技术路线怎么选、核心代码怎么组织、以及乱码、数字精度、性能这些坑是怎么一个个踩平的。
如果你是做后台管理、报表、H5 数据页的同学,应该会用到。
1. 为什么非要在浏览器里直接生成 Excel 文件
1.1 没有后端,但业务就是要导出
先说真实场景。普通的前后端分离项目,导出 Excel 一般分两种做法:一种是后端查完数据库直接生成 .xlsx 返回,前端拿个下载地址就完事;另一种是后端返回 JSON,前端本地生成,也就是常说的“纯前端导出”。后者以前被很多人嫌弃,觉得 JS 生成的 Excel 不正规、样式难控制、大文件卡顿。但这两年我得说句公道话:前端导出在某些场景下不是备选,而是最优解。
首先是部署轻。有些内网系统、离线工具、单页应用,压根没有自有后端,数据来自第三方接口或者本地缓存,这时候你不可能为了导出再搭一个服务。其次是响应快。后端导出要等接口返回文件流,如果数据体量不大,前端生成几乎是秒开。还有一个隐藏优势是可控性:后端导出的格式一旦要改,得发版,前端导出想调整列宽、加个筛选,直接改代码立刻生效。
1.2 先别急着写代码,把导出需求拆成三个等级
我见过很多同事一上来就引 SheetJS,也不管项目里到底需不需要那么重的库。其实“导出 Excel”这四个字,背后的需求可能是完全不同的三个等级:
| 需求等级 | 典型场景 | 必须能力 |
|---|---|---|
| L1 数据型 | 导出订单列表、用户表格 | 能打开、编码正确、字段完整 |
| L2 样式型 | 导出对账报表、运营周报 | 表头颜色、合并单元格、列宽 |
| L3 交互型 | 导出带公式、筛选、图表的文件 | 单元格公式、数据验证、图表 |
L1 用 CSV 就能解决,代码二十行;L2 要考虑用 HTML 表格或者 XML Spreadsheet;L3 才真正需要 SheetJS 这种能读写 .xlsx 的库。搞清楚自己处在哪个等级,再决定技术路线,这比盲目造轮子重要得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 几条“纯前端生成 Excel”路线的原理与取舍
2.1 CSV 路线:最轻量,但只适合纯数据
CSV 本质就是一个用逗号分隔的纯文本文件,Excel 能直接打开。它的优势是简单到不行,生成字符串,塞进 Blob,触发下载,完事。但它有两个致命问题:一是默认不支持样式,你想加个表头加粗、背景色,没门;二是中文容易出现编码问题,必须加上 UTF-8 BOM 才能让 Excel 正确识别。
javascript复制function exportCSV(rows, filename = 'export.csv') {
const content = rows
.map(row => row
.map(cell => {
const text = String(cell ?? '');
// 包含逗号、引号或换行时必须用引号包裹,内部引号翻倍转义
if (/[",\n]/.test(text)) {
return '"' + text.replace(/"/g, '""') + '"';
}
return text;
})
.join(','))
.join('\r\n');
const blob = new Blob(['\ufeff' + content], {
type: 'text/csv;charset=utf-8;',
});
downloadBlob(blob, filename);
}
function downloadBlob(blob, filename) {
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);
}
这段代码里有几个细节值得记住:换行符建议用 \r\n,这是 Windows 环境 Excel 最认的换行;单元格内容如果包含逗号、双引号、换行,必须整体用双引号包住,并且把内容里的双引号替换成两个双引号,否则数据会被撕成多列;文件内容前面加 \ufeff,也就是 BOM,目的是告诉 Excel“这是 UTF-8 编码,别用 ANSI 猜”。
CSV 还有一个安全注意点:如果单元格内容以 =、+、-、@ 开头,打开时可能被当成公式执行,也就是常说的 CSV 注入。用户输入不可信时,要么给这类单元格前面加一个单引号或制表符,要么干脆别用 CSV。
2.2 HTML 表格另存为 .xls:要样式的低配解法
很多人不知道,Excel 能直接打开 HTML 文件,也能打开以 .xls 结尾的 HTML 内容。原理是浏览器渲染一个 <table>,把 table 的 HTML 字符串写进 Blob,再用 .xls 后缀下载,Excel 打开时会自动识别表格结构。这个方案最大的好处是:表头颜色、边框、列宽、合并单元格都能用 CSS 和 rowspan/colspan 表达,代码写起来非常符合前端直觉。
javascript复制function exportHTMLExcel(table, filename = 'export.xls') {
const html = `
<html xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:x="urn:schemas-microsoft-com:office:excel">
<head>
<meta charset="UTF-8">
<style>
table { border-collapse: collapse; }
td, th { border: 1px solid #ccc; padding: 4px 8px; }
th { background: #f0f0f0; font-weight: bold; }
</style>
</head>
<body>${table}</body>
</html>`;
const blob = new Blob([html], { type: 'application/vnd.ms-excel' });
downloadBlob(blob, filename);
}
但用的时候要有心理准备:Excel 打开这样的文件会弹一次“文件格式和扩展名不匹配”的警告,这个警告消不掉,只能让用户点“是”。另外,复杂样式在 Excel 里还原度不一定高,WPS 的表现反而经常更好。所以它适合那种“只要有个能看的表头、没有太严格还原要求”的临时导出,不适合正式对账文件。
2.3 XML Spreadsheet 2003:比 CSV 能打,又比真 xlsx 简单
如果你需要明确指定单元格类型,比如身份证号按文本导出、日期按日期导出,又不想引入任何库,XML Spreadsheet 2003 是个非常冷门但好用的格式。它的本质是一段 XML,文件头和 Excel 的 office schema 绑定,Excel 能直接识别,而且每个单元格都有明确的 ss:Type,可以写成 String、Number、DateTime。
xml复制<?xml version="1.0"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:x="urn:schemas-microsoft-com:office:excel"
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
<Worksheet ss:Name="Sheet1">
<Table>
<Row>
<Cell><Data ss:Type="String">姓名</Data></Cell>
<Cell><Data ss:Type="String">手机号</Data></Cell>
</Row>
<Row>
<Cell><Data ss:Type="String">张三</Data></Cell>
<Cell><Data ss:Type="String">13800138000</Data></Cell>
</Row>
</Table>
</Worksheet>
</Workbook>
这段 XML 用 .xls 后缀保存,Excel 打开后基本不会产生乱码,手机号也会因为类型是 String 而保留成文本,不会变科学计数法。和 HTML 方案比,它的格式更“正经”;和真 .xlsx 比,它不需要处理 ZIP 打包,字符串拼出来就能用。算是我个人在“不引库”前提下的首选。
2.4 真正的 .xlsx:本质是一个 ZIP 包
最后说 .xlsx。很多人不知道,xlsx 其实是一个 ZIP 压缩包,里面装着 workbook.xml、sheet1.xml、sharedStrings.xml 等一堆 XML 文件。所以要在纯 JS 环境里手工生成一份合法 xlsx,你得先实现 ZIP 打包,再按照 OOXML 规范拼 XML,门槛一下子高了不少,这也是为什么大多数人直接引入 SheetJS 的原因。
如果项目允许引库,我建议别重复造 ZIP 的轮子。SheetJS 社区版目前能覆盖“前端导出 xlsx”的绝大多数场景:二维数组转工作表、多 Sheet 工作簿、设置列宽、简单样式、单元格公式,它都支持,而且 npm 包和 CDN 都很好用。后面我会专门讲它的使用边界。
3. 从零手写一个轻量导出工具:核心实现
3.1 统一数据模型:二维数组与对象数组
不管是 CSV、HTML 还是 XML,先确定一件事:你的导出数据长什么样。我的做法是把所有数据先统一成“二维数组 + 表头数组”的结构,行数据和表头分开,这样渲染和转格式都简单。比如后端接口返回的是对象数组,你可以这样归一化:
javascript复制function normalizeTable(columns, list) {
return [
columns.map(col => col.title),
...list.map(item => columns.map(col => item[col.key])),
];
}
columns 负责定义顺序和标题,list 是原始数据。这种模型最大的好处是:后续无论换成 CSV 还是 XML,只需要写一个二维数组转对应格式的函数,核心逻辑不用动第二遍。
3.2 工具类骨架:暴露三个方法
我把导出能力封装成一个 ExcelExporter 对象,提供几个方法:csv(table, filename)、xml(table, filename)、xlsx(table, filename)。内部统一用 normalizeTable 处理数据,各方法只负责格式转换和下载。看起来很简单,但用了半年,团队里不同项目接入都很顺,比你写一堆散落的工具函数要强得多。
javascript复制const ExcelExporter = {
csv(columns, list, filename) {
const table = normalizeTable(columns, list);
const content = tableToCSV(table);
downloadBlob(new Blob(['\ufeff' + content], { type: 'text/csv;charset=utf-8;' }), filename);
},
xml(columns, list, filename) {
const table = normalizeTable(columns, list);
const content = tableToSpreadsheetXML(table);
downloadBlob(new Blob([content], { type: 'application/vnd.ms-excel' }), filename);
},
async xlsx(columns, list, filename) {
// 依赖 SheetJS 时使用,见 5.3
},
};
3.3 XML 生成器的防坑细节
XML 方案的 JavaScript 生成器,核心就一件事:把二维数组拼成 Workbook 结构,同时做好 XML 转义。别小看转义,数据里一旦出现 &、<、> 这类字符,不转义生成的 XML 就是非法文件,Excel 直接打不开。
javascript复制function escapeXml(str) {
return String(str).replace(/[<>&'"]/g, ch => ({
'<': '<',
'>': '>',
'&': '&',
"'": ''',
'"': '"',
}[ch]));
}
function tableToSpreadsheetXML(table, sheetName = 'Sheet1') {
const rows = table.map(row => {
const cells = row.map(cell => {
const text = String(cell ?? '');
const isNum = typeof cell === 'number' && isFinite(cell);
const type = isNum ? 'Number' : 'String';
return `<Cell><Data ss:Type="${type}">${escapeXml(text)}</Data></Cell>`;
}).join('');
return `<Row>${cells}</Row>`;
}).join('');
return `<?xml version="1.0"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:x="urn:schemas-microsoft-com:office:excel"
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
<Worksheet ss:Name="${escapeXml(sheetName)}">
<Table>${rows}</Table>
</Worksheet>
</Workbook>`;
}
注意 Data 里的 ss:Type 判断:我故意让数字走 Number、其余全走 String。这样身份证号、手机号、订单号这些“长得像数字的文本”就不会被 Excel 强转成数字。如果你在 CSV 里处理长数字,会发现怎么转都容易变科学计数法,根因就在 CSV 没有“单元格类型”这个概念,Excel 只能靠内容猜。
4. 我踩过的三个坑:乱码、长数字、日期序列
4.1 中文乱码不是编码问题,是 Excel 猜编码的毛病
有段时间我导出的 CSV 在编辑器里看是正常中文,用 Excel 打开却全是乱码,WPS 也乱。排查到最后发现:UTF-8 的 CSV 文件如果没有 BOM,Excel 默认按系统 ANSI 编码去解析,中文环境里就是 GBK,解码 UTF-8 内容自然乱掉。解决方式前面已经给过,文件内容最前面加 \ufeff,一行代码的事。但如果是 HTML 或 XML 方案,要在 <head> 或 XML 声明里显式写 charset=UTF-8,并且文件名别乱带特殊字符。
4.2 15 位以上的数字被转成科学计数法
Excel 对超过 15 位的数字会转成科学计数法,而且第 15 位之后会被吞成 0,这是它的设计限制。最常见的翻车就是订单号、身份证号、银行卡号。CSV 方案里,即使你输出字符串,Excel 打开时仍然可能自动变科学计数法;我试过的有效处理是给单元格内容前面加一个制表符 \t,或者用 "=""138001380001234567""" 这种公式包裹。但说实话,最干净的还是 XML 或 SheetJS 方案,因为可以显式标记单元格为文本类型。
xml复制<Cell ss:StyleID="text"><Data ss:Type="String">138001380001234567</Data></Cell>
4.3 日期为什么导出来是一串四万多的数字
Excel 内部表示日期用的是一串序列号,比如 2024 年 1 月 1 日对应的数字是 45292,它是相对于 1900 年 1 月 1 日的天数。如果你直接往单元格里塞一个 Date 对象,不同格式的转换结果可能完全不同,有时导出来直接是一串纯数字。我的建议是:导出前统一把日期格式化成 YYYY-MM-DD HH:mm:ss 字符串,再按 String 类型写入,这样对用户最直观,也减少跨端差异。
javascript复制function formatDateTime(d) {
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
4.4 时区坑:new Date('2024-01-01') 可能是前一天
补充一个容易被忽略的时区问题。new Date('2024-01-01') 在标准里是按 UTC 解析的,而 getFullYear() 之类的方法是按本地时区取。如果用户在 UTC- 的时区,取出来可能变成 2023-12-31。稳妥做法是手动拆分字符串,或者显式 new Date(2024, 0, 1)。跨时区用户多的话,日期格式化这件事一定要写单元测试,不然数据对不上很头疼。
5. 样式需求上来之后:合并单元格、表头与列宽
5.1 HTML 方案里的样式控制与兼容性
如果只是要个好看的导出文件,HTML 方案改动成本最低,直接在 <style> 里写死表头背景色、字体、边框、列宽,用 rowspan/colspan 合并单元格,Excel 基本能识别。但你要做好“打开后样式和预览不完全一致”的准备,尤其单元格合并,不同 Excel 版本解析 rowspan/colspan 的规则有差异。我踩过最明显的一个:第一行表头合并三列,Excel 打开后偶尔会多出空列。后来我改用 XML 方案的 MergeAcross,问题才稳定下来。
5.2 XML 方案里的 MergeAcross 与列宽
XML Spreadsheet 里合并单元格用 ss:MergeAcross 和 ss:MergeDown,分别表示向右合并 N 格、向下合并 N 格。注意这里的 N 是“额外合并的数量”,不是包含自身后的总数。比如一个表头占 3 列,应该写 ss:MergeAcross="2",不是 3。
xml复制<Row>
<Cell ss:MergeAcross="2"><Data ss:Type="String">本月销售汇总</Data></Cell>
</Row>
<Row>
<Cell><Data ss:Type="String">月份</Data></Cell>
<Cell><Data ss:Type="String">销售额</Data></Cell>
<Cell><Data ss:Type="String">环比</Data></Cell>
</Row>
列宽在 Table 上统一设置:
xml复制<Table>
<Column ss:Width="80"/>
<Column ss:Width="120"/>
<Column ss:Width="100"/>
</Table>
样式定义放在 Workbook 开头的 Styles 节点里,通过 ss:StyleID 引用。这套东西和 HTML/CSS 的思路很像,只是属性名变成了 ss: 前缀。上手成本很低,但网上资料少,遇到问题只能靠翻 schema 和反复试,建议从简单需求开始用。
5.3 复杂需求直接上 SheetJS 社区版
样式和公式需求一旦多起来,自己拼 XML 效率就太低了。比如你要导出多工作表、单元格公式、自动筛选,或者合并一堆动态表头,手写模板很容易出错。这时候我建议直接用 SheetJS 社区版,下面的例子能覆盖 90% 的导出场景:
javascript复制import * as XLSX from 'xlsx';
const ws = XLSX.utils.aoa_to_sheet(data);
ws['!cols'] = [{ wch: 12 }, { wch: 20 }, { wch: 30 }];
ws['!merges'] = [{ s: { r: 0, c: 0 }, e: { r: 0, c: 2 } }];
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Sheet1');
XLSX.writeFile(wb, 'export.xlsx');
aoa_to_sheet 接收二维数组,!cols 控制列宽,!merges 控制合并单元格,公式可以直接写进单元格对象里的 f 字段。社区版对绝大多数业务导出都是够用的,但如果你的需求已经到“导出带图片、数据透视表”这种程度,社区版会吃力,就得评估付费版本或换其他库了。
6. 导出大表格时怎么不把浏览器卡死
6.1 字符串拼接的毒药:+= 与数组 join 的差距
第一次
