
做前端这几年凡是和“导出”沾边的需求我基本都碰过一遍订单报表、数据统计、表格导入模板、大屏看板导出……只要产品经理轻飘飘一句“这个列表加个导出按钮吧”你就得在 csv 和 excelxlsx两条技术路线里立刻做个选择。选对了风平浪静选错了就是一堆破事中文乱码、Excel 打不开、大文件卡死页面、Mac 上复制粘贴报错。这篇文章我就把前端导出 csv/excel 的几种主流方式完整梳理一遍。除了贴代码我重点讲每个方案背后的原理、选型理由和实际操作中踩过的坑顺便把几个高频前端面试题也一起说清楚。1. 先把需求看清楚csv 和 excel到底该导哪个1.1 两者本质区别别等写完了才后悔很多项目提需求时说的是“导出 Excel”但实际上你要导的是一份 csv。这不是抠字眼而是两者在本质上差了很多。CSVComma-Separated Values是纯文本格式本质就是一个用逗号分隔字段、用换行分隔记录的字符串。它的优点是实现成本极低任何文本编辑器都能打开数据量再大也就是字符串拼接的问题。缺点是它不保存格式、公式、合并单元格、多 Sheet 这些 Excel 特性而且编码处理不好就乱码。xlsxExcel 文件本质是一个 zip 压缩包内部包含一系列 XML 文件用来描述单元格、样式、公式、图表等内容。优点是功能完整用户拿到的就是一个真正的“表格文件”该对齐的对齐、该加粗的加粗。缺点是你不能在浏览器里像拼字符串一样生成它得引入第三方库来“组装”这个 zip 包。一句话总结只导纯数据、表格结构简单、数据量大优先选 csv需要格式、多 Sheet、列宽行高、用户要拿去做二次编辑选 xlsx。1.2 四种典型业务场景对应四种方案我自己的经验是拿到需求先别急着写代码先对号入座场景 A列表页导出当前筛选结果数据量在几千到几万行不需要格式。最适合纯前端生成 csv零依赖、速度快。场景 B需要导出多 Sheet、指定列宽、合并单元格或者导出模板要带下拉选项。直接用 SheetJSxlsx 库前端生成 xlsx 文件。场景 C数据量几十万行甚至上百万行或者导出过程需要查多张表、做聚合计算。前端硬扛会直接把用户的电脑整死机必须走后端生成前端拉取 Blob 触发下载。场景 D公司的安全要求高不允许把数据全部下到浏览器内存里再生成文件。这也是后端导出前端只负责把文件流下载下来。后面几章我会重点展开场景 A 和 B 的完整实现场景 C 和 D 会单独聊一下落地思路。1.3 浏览器端文件下载的底层原理不管用哪种库、哪种格式浏览器端导出的最后一步基本都是同一套操作把文件内容包装成 Blob通过URL.createObjectURL生成一个临时链接挂到一个临时a标签上触发 click 下载。核心代码长这样const blob new Blob([content], { type: text/csv;charsetutf-8; }); const url URL.createObjectURL(blob); const link document.createElement(a); link.href url; link.download 文件名.csv; link.click(); URL.revokeObjectURL(url);理解这 5 行你基本就理解所有前端导出工具库的底层逻辑了。URL.revokeObjectURL必须调用否则页面会积累垃圾 URL 对象长时间操作内存会涨。2. 纯前端导出 CSV一套代码打天下2.1 最朴素的写法拼字符串 Blob 下载先看一个能跑的极简版本。假设后端接口返回了一个数组每个元素是一个对象我们要把对象数组导出成 csvfunction exportCSV(data, filename export.csv) { if (!data || data.length 0) return; const headers Object.keys(data[0]); const headerRow headers.join(,); const rows data.map(item headers.map(key item[key] ?? ).join(,) ); const csvString \ufeff [headerRow, ...rows].join(\r\n); const blob new Blob([csvString], { type: text/csv;charsetutf-8; }); const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download filename; a.click(); URL.revokeObjectURL(url); }这段代码能跑但只适用于纯数字、纯英文且没有特殊符号的场景。\ufeff是 BOM 头加它是为了防 Excel 打开乱码下面细讲。换行用\r\n而不是\n是因为 Windows 上的 Excel 对换行识别比较挑\r\n最稳。2.2 中文乱码问题BOM 头为什么必须加这是 csv 导出最经典的坑也是“csv 乱码”“豆包乱码”这一类问题的高频来源。Excel 打开 UTF-8 编码且没有 BOM 的 csv 文件时会默认按系统本地编码比如 Windows 上的 GBK去解析中文字符就全变成乱码。解决办法很简单往文件内容最前面加一个\ufeff字符也就是 UTF-8 BOM。Excel 看到 BOM 会正确识别为 UTF-8 编码。具体写法就是上面的\ufeff csvString。这个细节面试经常问回答的时候一定要把原因说清楚而不是只说“加个 BOM 就行”。面试官追问“为什么加”你就把编码识别机制讲明白这题就稳了。2.3 字段里有逗号、引号、换行怎么办真实业务数据不可能永远干净。用户填的备注字段可能带逗号、带英文双引号、带换行如果直接用逗号拼接导出的 csv 字段就会错位一行变成多行。CSV 格式本身有转义规则字段内如果包含逗号、双引号或换行符就把整个字段用英文双引号包起来字段内部的英文双引号转义为两个双引号。function escapeCSVCell(value) { const str value null ? : String(value); if (/[,\r\n]/.test(str)) { return str.replace(//g, ) ; } return str; }这段函数应该是你导出 csv 的标配。我记得有一次生产环境用户反馈“导出订单后 Excel 打开错行了”排查半天就是备注里带了个换行符而旧代码没有做转义。这种问题用肉眼很难从数据里发现但导出规则一补上就彻底解决了。2.4 给 CSV 工具函数做一次完整封装把前面几节的东西合并起来一个能应对 80% 业务场景的 csv 导出函数长这样/** * 导出 CSV 文件 * param {ArrayObject} data 数据数组 * param {Array{ key: string, title: string }} columns 列配置 * param {string} filename 文件名 */ export function exportCSV(data, columns, filename export.csv) { if (!Array.isArray(data) || data.length 0) { console.warn(没有可导出的数据); return; } const escapeCell (value) { const str value null ? : String(value); if (/[,\r\n]/.test(str)) { return str.replace(//g, ) ; } return str; }; const headerRow columns.map(col escapeCell(col.title)).join(,); const bodyRows data.map(row columns.map(col escapeCell(row[col.key])).join(,) ); const csvString \ufeff [headerRow, ...bodyRows].join(\r\n); const blob new Blob([csvString], { type: text/csv;charsetutf-8; }); 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); }注意几点列配置必须显式传不要用Object.keys(data[0])动态取否则列顺序不受控document.body挂载临时 a 标签再移除是老牌兼容性写法某些浏览器不挂到 DOM 上点击无效。2.5 大文件导出卡死页面怎么破csv 方案看起来简单但数据量上来之后问题就来了。假设要导 50 万行数据每行 20 个字段字符串拼下来可能有几十 MB前端一次性生成这些字符串时UI 线程会被长时间占用用户看到的就是页面白屏、滚动卡顿、弹窗“无响应”。我的处理经验分三步第一改成分块生成。不要一次性map整个大数据集而是用一个循环分批把数据转成 csv 字符串每批处理 1000 行中间让出主线程用setTimeout或requestIdleCallback调度。第二把文件内容放到 Web Worker 里生成。Worker 里只负责拼字符串拼完把结果传回主线程再创建 Blob 下载。这样页面完全不会卡。第三如果真的到了百万行级别就别硬刚 csv 了。csv 本身就是文本协议百万行文本几十 MB用户下载了打开也费劲。这时候更合理的路径是后端把文件生成好存到临时存储前端拿一个下载地址直接触下载或者用流式接口分片拿数据。// 用 setTimeout 分批处理避免一次性阻塞主线程 function buildCSVInChunks(data, columns, chunkSize 1000) { const escapeCell (value) { const str value null ? : String(value); return /[,\r\n]/.test(str) ? str.replace(//g, ) : str; }; let csv ; let index 0; return new Promise((resolve) { function processChunk() { const chunk data.slice(index, index chunkSize); chunk.forEach(row { csv columns.map(col escapeCell(row[col.key])).join(,) \r\n; }); index chunkSize; if (index data.length) { setTimeout(processChunk, 0); } else { resolve(csv); } } processChunk(); }); }配合一个 loading 状态提示“正在生成文件中”体验会好很多。实测下来这种方式在 20 万行数据下页面整体流畅度比一次性拼接要好得多。3. 用 SheetJSxlsx导出真正的 Excel 文件3.1 前置准备库的选择与引入方式需要导出真正的 xlsx 时绝大多数场景我会选 SheetJS 社区版npm 包名xlsx。这个库能解析和生成多种表格格式纯前端运行不需要后端参与。安装方式npm install xlsx项目里引入import * as XLSX from xlsx;这里提醒一句SheetJS 社区版和 Pro 版功能边界要搞清。社区版能处理基本的数据导出、多 Sheet、单元格数组操作但样式相关能力很弱比如设置单元格背景色、字体加粗、合并单元格社区版基本做不了。如果你的需求里有“导出 Excel 必须带样式、带表头颜色、带边框”社区版会很吃力优先考虑后端用 Java/Python 的 Excel 库生成文件。3.2 从数据到工作表三行代码生成 xlsx先看最基础的对象数组导出import * as XLSX from xlsx; function exportXLSX(data, filename export.xlsx) { 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, filename); }三个核心 API 分别是json_to_sheet对象数组转工作表、book_new新建工作簿、book_append_sheet把工作表追加进工作簿最后writeFile在浏览器端会自己创建 Blob 并触发下载整个过程你不用手动管理 URL 对象。如果想要自定义列头并控制列顺序先建一个数组的数组二维数组第一行是表头其他行是数据function exportXLSXWithColumns(data, columns, filename export.xlsx) { const headerRow columns.map(col col.title); const bodyRows data.map(row columns.map(col row[col.key])); const aoa [headerRow, ...bodyRows]; const worksheet XLSX.utils.aoa_to_sheet(aoa); const workbook XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, Sheet1); XLSX.writeFile(workbook, filename); }注意json_to_sheet默认会按对象的 key 顺序生成列而且列名直接取 key多数场景需要专门映射成中文表头所以aoa_to_sheet这个二维数组方案其实更可控。3.3 样式需求怎么办社区版与专业版的边界我在第 3.1 节提过SheetJS 社区版不擅长样式。具体到什么程度呢你想让表头底色变灰、字体加粗社区版里就没有直接 API。网上会有一些通过修改worksheet[A1].s对象去尝试塞样式的技巧这类做法依赖内部数据结构不保证每种浏览器和版本都稳定。如果你真的需要在纯前端实现带样式的 xlsx有三个替代方向方向一引入 exceljs 库另一个常用 npm 库它支持设置单元格样式、合并单元格、列宽、行高等。生成的文件是流式写入的大文件性能也不错。代价是包体积更大API 风格和 SheetJS 不一样。方向二后端生成 xlsx。后端生态里有大量成熟的 Excel 库比如 Java 的 POI、Node 的 exceljs、Python 的 openpyxl样式控制能力远强于纯前端方案。方向三模板方案。提前在后端或本地维护一个带好样式的 xlsx 模板导出时只填充数据。这个方案生成的模板稳定但实现成本略高。我的建议是如果你的项目同时在多个前端页面都用到带样式导出值得花时间把 exceljs 用起来如果只是单个报表需求直接丢给后端更省心。3.4 导入的方向顺便说一下csv 导入、xlsx 解析导出和导入经常成对出现。既然说了 SheetJS就顺便把导入也讲一下因为这两个功能其实是同一套库的两个方向。导入 csv 或者 xlsx 时核心是读取用户的文件内容async function parseFileToArray(file) { const buffer await file.arrayBuffer(); const workbook XLSX.read(buffer, { type: array }); const sheetName workbook.SheetNames[0]; const worksheet workbook.Sheets[sheetName]; const json XLSX.utils.sheet_to_json(worksheet, { header: 1 }); return json; // 二维数组第一行是表头 }header: 1表示返回二维数组每行数据都保留原始顺序不给这个参数它会把第一行当表头返回对象数组适合“表头固定”的场景。如果用户上传的是 csv 文件XLSX.read也可以直接处理它会自动识别文本内容并解析。导入过程中几个坑大文件arrayBuffer会占大量内存导入前建议做文件大小限制和格式校验用户传的 xlsx 可能是 xls 老格式XLSX.read能兼容但字体编码偶尔会出现问题遇到解析异常要给用户一个清晰报错而不是直接白屏。4. 在 vue3 element plus 项目里落地4.1 封装一个可复用的 useExport 组合式函数实际项目里不建议每个页面复制一份导出函数我会把导出逻辑封装成一个组合式函数统一放在src/composables/useExport.ts中这样所有页面都能调。下面是一段 Vue3 TypeScript 风格的封装示例import { ref } from vue; import * as XLSX from xlsx; export function useExport() { const exporting ref(false); async function exportCSV(data: Recordstring, any[], columns: { key: string; title: string }[], filename: string) { if (!data.length) return; exporting.value true; try { const escapeCell (value: unknown) { const str value null ? : String(value); return /[,\r\n]/.test(str) ? str.replace(//g, ) : str; }; const headerRow columns.map(col escapeCell(col.title)).join(,); const bodyRows data.map(row columns.map(col escapeCell(row[col.key])).join(,)); const csvString \ufeff [headerRow, ...bodyRows].join(\r\n); const blob new Blob([csvString], { type: text/csv;charsetutf-8; }); const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download filename; a.click(); URL.revokeObjectURL(url); } finally { exporting.value false; } } async function exportXLSX(data: Recordstring, any[], columns: { key: string; title: string }[], filename: string) { if (!data.length) return; exporting.value true; try { const headerRow columns.map(col col.title); const bodyRows data.map(row columns.map(col row[col.key])); const worksheet XLSX.utils.aoa_to_sheet([headerRow, ...bodyRows]); const workbook XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, Sheet1); XLSX.writeFile(workbook, filename); } finally { exporting.value false; } } return { exporting, exportCSV, exportXLSX }; }在页面组件里的用法template el-button :loadingexporting clickhandleExport 导出 /el-button /template script setup langts import { ElMessage } from element-plus; import { useExport } from /composables/useExport; const { exporting, exportCSV } useExport(); const tableData ref([...]); function handleExport() { if (!tableData.value.length) { ElMessage.warning(当前没有可导出的数据); return; } exportCSV( tableData.value, [ { key: name, title: 姓名 }, { key: phone, title: 手机号 }, { key: amount, title: 金额 } ], ${Date.now()}-订单导出.csv ); } /script组合式函数的好处是 loading 状态统一管理每个页面都不用重复写“正在导出”的提示逻辑。而且后续想换成exceljs或者接入后端导出只需要改动这一个文件所有页面自动生效。4.2 大屏项目里的导出按钮异步 loading 与用户反馈如果你是做可视化大屏或者中后台系统导出这个动作往往是在用户点了按钮之后要等好几秒的。这期间如果没有反馈用户会再点好几次或者认为系统卡死了。在 element plus 里按钮加loading是标配。我通常还会在导出前先做一次数据数量提示。比如列表显示有 12 万条数据时用户点击导出我一般会先用ElMessageBox.confirm提示“当前将导出 12 万条数据可能需要较长时间是否继续”这样既能避免用户误触也能给导出动作争取几秒的缓冲时间。async function handleExportLarge() { const count tableData.value.length; if (count 50000) { try { await ElMessageBox.confirm( 当前将导出 ${count} 条数据数据量较大请耐心等待。, 导出确认, { confirmButtonText: 继续导出, cancelButtonText: 取消 } ); } catch { return; // 用户取消 } } await exportCSV(tableData.value, exportColumns, 数据导出-${Date.now()}.csv); ElMessage.success(导出完成); }大屏项目还有一个特殊点很多大屏是只读的展示页面但导出数据往往是最常被要求加的功能。这种情况下不要把导出逻辑写死在单个大屏组件里而是把数据获取和导出参数都收口到一个 API 层方便后续切换成后端导出。4.3 与后端配合的导出模式什么时候别自己造轮子有一个决策点很重要什么时候该放弃纯前端导出改由后端生成文件我的判断标准很简单数据量超过 5 万行或者导出前需要做大量跨表聚合计算或者文件需要复杂的汇总样式比如多个 Sheet 加透视表直接走后端。前端导出一个几十 MB 的文件用户下载后打开 Excel 都要加载半分钟体验很差而且浏览器内存扛不住。前端配合后端导出的标准流程如下前端携带查询参数请求后端接口。后端生成文件设置响应头Content-Disposition: attachment; filenamefilename.xlsx。前端用fetch拿到 Blob再走createObjectURL下载。async function downloadFromBackend(url: string, params: Recordstring, any, filename: string) { exporting.value true; try { const response await axios.post(url, params, { responseType: blob }); const blob new Blob([response.data]); const downloadUrl URL.createObjectURL(blob); const a document.createElement(a); a.href downloadUrl; a.download filename; a.click(); URL.revokeObjectURL(downloadUrl); } catch (error) { console.error(导出失败, error); ElMessage.error(导出失败请稍后重试); } finally { exporting.value false; } }这里有个细节后端返回的错误信息是 JSON 不是 Blob你直接用response.data创建 Blob 下载下来用户会得到一个损坏文件。所以拿到响应后先看Content-Type如果包含了application/json就要走错误处理分支。const contentType response.headers[content-type]; if (contentType contentType.includes(application/json)) { const reader new FileReader(); reader.onload () { const errorMsg JSON.parse(reader.result as string).message || 导出失败; ElMessage.error(errorMsg); }; reader.readAsText(response.data); return; }这种前后端协作方式也是中后台项目里最稳健的导出方案。尤其是公司有大量历史数据要导出统计报表时纯前端方案是真的扛不住。5. 现场复盘我踩过的坑和排查思路5.1 常见问题速查表我按问题现象、可能原因、解决办法整理了一个速查表基本覆盖日常导出会遇到的大部分问题。问题现象可能原因解决办法Excel 打开 csv 中文乱码文件没有 UTF-8 BOM内容前加\ufeffExcel 打开 csv 所有数据挤在一行换行符用了\n改用\r\n字段含逗号导致列错位没做 CSV 转义用双引号包裹并转义内部引号超长数字变成科学计数法Excel 默认将长数字转科学计数法数字文本化比如加前导\t或改用 xlsxxlsx 导出的文件打不开库版本太老或数据含非法字符升级 SheetJS 版本检查数据是否有异常对象下载的文件名是乱码浏览器对非 ASCII 文件名处理不当使用encodeURIComponent或后端设置 RFC 5987 编码用户反馈导出后 Excel 无法复制粘贴导出文件本身正常但 Excel 程序异常这是 Excel 自身问题指导用户重启 Excel 或修复 Office导出按钮连点多次生成多个文件没有做并发控制用 loading 状态禁用按钮这里面“Excel 无法复制粘贴”和“Mac 版 Excel 导出后打不开”这类问题很坑它们往往不是你的代码问题而是客户端软件状态坏了。遇到这种反馈我的处理是先自己把文件下载一遍确认文件能在 WPS 或在线表格里正常打开再判断是不是对方 Excel 软件环境的问题。排查问题时别一上来就怀疑自己的导出代码先拿到原始文件做验证。5.2 排查思路示范一个乱码问题的完整定位流程分享一个真实案例。有次接到反馈用户从系统导出的 csv 在 Windows 上 Excel 打开正常但同事用 Mac 版 Excel 打开就乱码。第一反应是 BOM 加上是不是就没事了但代码里明明已经加了\ufeff。后来我换了个思路先把导出文件下载下来用 VS Code 打开看编码结果显示文件确实是 UTF-8 且带 BOM。那为什么 Mac 版 Excel 还是乱码最后发现用户在 Mac 上不是直接用 Excel 打开文件而是把 csv 内容复制粘贴到 Excel 里。这个操作会丢失文件编码信息Mac 版 Excel 默认按系统编码解析中文自然就乱了。这个案例告诉我们排查问题要追到用户的实际操作路径。同样一份文件直接打开没问题复制粘贴可能就有问题。这不是你代码的锅但你可以从产品层面优化比如提示用户用“数据 → 自文本/CSV 导入”的方式或者干脆把导出格式从 csv 改成 xlsx一劳永逸。5.3 几个容易被面试官追问的点前端导出这个主题面试时出现频率特别高。核心问题无外乎这几个第一个是“CSV 的 MIME type 是什么”。答案是text/csv导出时可以写成text/csv;charsetutf-8;。这句话背后还藏着一个考点为什么要带 charset。因为浏览器和 Excel 要据此判断编码。第二个是“URL.createObjectURL和FileReader.readAsDataURL有什么区别”。导出下载一般用前者它生成的是一个内存引用 URL开销小后者会把整个文件转成 base64 字符串内存占用大很多适合预览图片之类的场景。面试时能把性能差异讲出来比死记概念效果好。第三个是“大文件导出怎么优化”。可以从分片处理、Web Worker、后端导出三个层次去回答注意一定要先说“先评估量级再选方案”。没有一种方案能通吃所有场景面试官想听的就是你能不能权衡取舍。还有一个细节容易被追问Excel 打开 csv 时身份证号、订单号这类长数字会丢精度。原因很简单csv 是文本格式没有数据类型概念Excel 自动把连续数字识别成了数值类型超过 15 位的部分就变了。解决办法有两个一是导出时在数字字段前加一个不可见字符比如\t强制 Excel 按文本处理二是直接导 xlsx并且把单元格类型设为字符串。实际项目中我倾向于后者因为加\t的字段在用户后续处理数据时经常引发其他麻烦。最后再说一个我在真实项目里反复用到的小技巧导出的列顺序、列名一定要由前端配置统一控制不要依赖后端返回字段顺序。后端的表结构调整频率通常比前端高一旦字段顺序变了你导出的报表可能就出现“时间列跑到姓名列前面”这种尴尬情况。把所有列配置收敛到一个columns数组里后续无论加字段、改字段名都只改一处这是我认为整个导出功能里性价比最高的一个设计。