
做前端这些年“导出Excel”真的是被产品经理提得最多的需求之一。业务方说起来特别轻松就一个表格嘛点一下按钮把数据导出来就行了。真做起来表头字段映射、列宽、合并单元格、下拉框、多个工作表每个细节都能折腾一晚上。这篇文章我围绕 vue xlsx 这套技术栈把基础表格导出、带下拉框的导出、多个工作表导出这三个层次完整写一遍代码可以直接复制也会把背后的原理和我在实际项目里踩过的坑都讲清楚。1. 需求拆解与方案选型思路1.1 这个需求到底在解决什么问题大多数中后台项目里的“导出表格”并不是简单生成一个 CSV 就完事。CSV 本质是纯文本一个单元格内容里如果带逗号、换行、超过 255 个字符处理起来就非常麻烦中文编码也经常出问题。更不用说业务方后面还会加一句“这里要能下拉选择”“给我分几个 sheet 放不同数据”。真正的 .xlsx 文件本质是一个 zip 压缩包里面是一堆 xml 文件。前端要做的事情就是把你手里的 JSON 数据组装成符合 Excel OOXML 结构的文件然后触发浏览器下载。xlsx 这个库项目名叫 SheetJS就是干这个的它把最复杂的 xml 拼接、压缩、编码细节都封装好了我们只需要调用几个方法就能生成浏览器能下载的 Excel 文件。这个方案最大的价值是完全不依赖后端。数据已经在页面上了用户点导出前端直接生成文件没有接口联调成本也没有服务器文件生成和传输的延迟。对于数据量可控、实时性要求高的场景体验是最好的。1.2 为什么选择 xlsxSheetJS而不是其他方案前端生成 Excel 的主流库其实没几个我对比过 exceljs、xlsx、以及后端生成这几条路线。xlsxSheetJS社区版最大的优势是轻量和上手快。一条XLSX.utils.json_to_sheet()就能把对象数组转成工作表再配合book_new()、book_append_sheet()、writeFile()四个 API 就能完成一次导出学习成本几乎为零。exceljs 功能更全面支持单元格样式、公式、数据验证这些高级能力但 API 更复杂打包体积也大不少如果你的需求里没有特别复杂的样式用 exceljs 属于杀鸡用牛刀。后端导出方案比如 Node 端打包、Java 用 POI需要前后端联调要定义接口协议、处理导出任务异步化一旦数据权限复杂后端还得专门做权限过滤。它的优势是能处理超大文件、能套用服务端模板但这个成本对于常规的中后台导出需求来说太高了。所以我的选型结论很明确数据量在几万行以内、以数据展示为主、需要前端快速迭代的业务场景直接用 xlsx 库准没错。1.3 三个层次的导出需求拆解根据标题我把这个需求拆成三个递进层次第一层基础表格导出。把页面表格数据原样导成 xlsx 文件这是日常需求的大头官方 API 就够了。第二层进阶玩法导出带下拉框的表格。这个需求一出来性质就变了。下拉框在 Excel 里叫“数据验证”是单元格级别的功能。问题在于 SheetJS 社区版对这类扩展属性的写入支持有限网上很多教程讲得模棱两可直接照搬很容易导出后没有下拉效果。第三层进阶玩法导出多个工作表。这个相对简单一个工作簿里追加多个 sheet 就行但要注意 sheet 命名、批量生成的循环组织、以及大数据量下的性能问题。我会按这个顺序依次展开每一层都给出能直接落地的方案。2. 基础功能从零实现表格导出2.1 环境准备与依赖安装先安装依赖在 vue 项目根目录执行npm install xlsx如果你的网络环境允许也可以直接用 SheetJS 官方 CDN 上提供的最新版本版本更新、修 bug 更及时。但国内项目用 npm 安装老版本其实也够日常使用这个库的更新对基础 API 影响很小。安装完成后在你的组件或者工具文件里引入import * as XLSX from xlsx;注意这里用的是命名空间引入。因为 xlsx 这个库导出的是一个整体对象用import XLSX from xlsx在一些构建环境下也能用但import * as XLSX是最稳的不会出现默认导出 undefined 的问题。2.2 最核心的四行代码基础导出的核心 API 就四个记住它们就够用了// 1. 把一个对象数组转成工作表worksheet const worksheet XLSX.utils.json_to_sheet(students); // 2. 创建一个新的工作簿workbook const workbook XLSX.utils.book_new(); // 3. 把工作表追加到工作簿里并给这个 sheet 命名 XLSX.utils.book_append_sheet(workbook, worksheet, 学生名单); // 4. 触发浏览器下载第二个参数会作为文件名自动补上 .xlsx 后缀 XLSX.writeFile(workbook, 学生名单.xlsx);json_to_sheet这个方法名非常直白就是把 JSON 数组转成 sheet。它默认会拿对象里的 key 作为表头value 作为单元格内容。比如[{ name: 张三, age: 18 }]导出来就是第一行是name和age第二行是张三和18。2.3 接入真实业务数据时表头中文怎么处理实际业务中后端接口返回的字段一般是英文比如studentName、classId但 Excel 表头要的是中文“姓名”“班级”。这里有几种处理方式。方式一把接口返回的数据重新映射成中文 key 的对象数组const rawData await getStudentList(); const rows rawData.map(item ({ 姓名: item.studentName, 班级: item.className, 学号: item.studentId, 成绩: item.score })); const worksheet XLSX.utils.json_to_sheet(rows);这种写法的好处是直观但如果你要导出的字段有十几个map 函数会很长。方式二用aoa_to_sheet它接受的是二维数组第一行是表头后面的行是数据。这种方式更适合表头列名需要固定、列顺序需要完全可控的场景const header [姓名, 班级, 学号, 成绩]; const body rawData.map(item [item.studentName, item.className, item.studentId, item.score]); const worksheet XLSX.utils.aoa_to_sheet([header, ...body]);我个人更推荐方式二。原因有二第一列的顺序完全由 header 数组决定不会被对象的 key 顺序带偏第二后续如果要动态决定导出哪些列只需要改 header 和 body 的组装逻辑灵活很多。2.4 导出文件名的细节处理XLSX.writeFile(workbook, 学生名单.xlsx)这个 API 在 Chrome、Edge、Firefox 下都能正常工作。它会自动根据文件名后缀生成对应的 MIME 类型并触发下载。但我在某些项目里遇到过两个问题一是文件名带特殊字符比如/、\会被某些系统拦截或者乱码。所以文件名最好统一处理一下过滤掉/\:*?|这些非法字符。二是如果业务方要求在导出前做权限校验或者下载动作需要走自定义逻辑writeFile的可控性就不够了。这时可以用XLSX.write先生成 ArrayBuffer再手动创建 Blob 下载const buffer XLSX.write(workbook, { bookType: xlsx, type: array }); const blob new Blob([buffer], { type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet }); const link document.createElement(a); link.href URL.createObjectURL(blob); link.download 学生名单.xlsx; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(link.href);这个写法后面还会用到因为做下拉框导出时我们需要在生成 xlsx 数据之后、下载之前插入一步二次加工没法直接走writeFile。3. 进阶玩法导出带下拉框的表格3.1 下拉框的本质是数据验证在 Excel 里“下拉框”这个交互其实叫“数据验证Data Validation”。它不是一个独立的图片或控件而是作用在单元格上的一条规则作用是限制用户只能从你给定的一组选项里选择。手动在 Excel 里设置一遍下拉框然后把这个文件后缀改成 zip 解压开你会看到每个 sheet 对应的xl/worksheets/sheet1.xml文件里多了一段类似这样的内容dataValidations count1 dataValidation typelist allowBlank1 showDropDown0 formula1本科,硕士,博士/formula1 sqrefA2:A100/sqref /dataValidation /dataValidations理解这段 xml 的结构是做带下拉框导出的关键。typelist表示这是一个列表类型的验证formula1里的内容用双引号包起来里面的英文逗号分隔的就是选项sqref是作用范围A2:A100表示这一列从第 2 行到第 100 行都能下拉。3.2 直接用 xlsx 库设置 dataValidations 为什么容易翻车很多教程会告诉你给 worksheet 对象加上一个!dataValidations属性就能导出下拉框。但这里有个坑这个方案在不同版本的 xlsx 里表现不一致直接导出的文件经常出现下拉箭头不显示、甚至文件损坏的情况。我在项目里也试过同一个写法在某次依赖升级后突然就失效了。原因在于SheetJS 社区版对 Excel 扩展功能样式、数据验证、合并单元格的复杂场景的支持是有边界的。!dataValidations这个属性能不能被正确写入 xlsx 文件取决于库内部对 OOXML 序列化的实现而这个实现并不稳定。之所以绕开它还有一个现实考虑带着!dataValidations写完文件后你根本没法验证它到底写没写进去除非手动把文件解压开看 xml。与其赌库版本的行为不如我们自己在文件生成后、下载前直接对 xml 做一次精准修改这样结果完全可控。3.3 稳妥方案用 JSZip 对 xlsx 文件二次加工这个方案的思路是先用 xlsx 导出正常的 xlsx 数据再用 JSZip 把它当作 zip 包解压找到目标 sheet 的 xml 文件手动插入 dataValidation 节点最后重新压缩并下载。先安装 JSZipnpm install jszip完整代码我封装成了一个函数直接传入数据和一个下拉框配置就能用import * as XLSX from xlsx; import JSZip from jszip; async function exportWithDropdown({ rows, sheetName Sheet1, fileName 导出.xlsx, dropdown }) { // 1. 先生成基础数据 const worksheet XLSX.utils.json_to_sheet(rows); const workbook XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); // 2. 生成 xlsx 文件对应的 ArrayBuffer const buffer XLSX.write(workbook, { bookType: xlsx, type: array }); // 3. 用 JSZip 解压 const zip await JSZip.loadAsync(buffer); // 4. 读取第一个工作表的 xml let sheetXml await zip.file(xl/worksheets/sheet1.xml).async(string); // 5. 构造数据验证 xml const dropdownXml dataValidations count1 dataValidation typelist allowBlank1 showDropDown0 formula1${dropdown.options.join(,)}/formula1 sqref${dropdown.cellRange}/sqref /dataValidation /dataValidations; // 6. 把 dataValidations 插入到 /worksheet 之前 sheetXml sheetXml.replace(/worksheet, dropdownXml /worksheet); // 7. 写回 zip 并生成新的 Blob zip.file(xl/worksheets/sheet1.xml, sheetXml); const blob await zip.generateAsync({ type: blob }); // 8. 触发下载 const link document.createElement(a); link.href URL.createObjectURL(blob); link.download fileName; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(link.href); }调用方式const rows [ { 姓名: 张三, 学历: 本科 }, { 姓名: 李四, 学历: 硕士 }, ]; exportWithDropdown({ rows, sheetName: 人员信息, fileName: 人员信息.xlsx, dropdown: { options: [本科, 硕士, 博士], cellRange: B2:B100 } });这里cellRange要写成真实数据的区域比如你的数据有 50 行就写B2:B51多写几行留白也没关系只要用户填到那里时能看到下拉就行。3.4 下拉框三个关键参数的避坑说明第一个坑showDropDown0反而表示显示下拉箭头。这是 OOXML 规范里最反直觉的一个点。在数据验证节点里showDropDown默认值是 true而 true 的含义是“隐藏下拉按钮”。所以必须显式写成showDropDown0才能看到右侧的下拉箭头。很多教程没讲清楚照抄的结果就是导出后 Excel 没有任何下拉效果。第二个坑formula1里的选项如果有英文逗号会被当成选项分隔符。比如你要提供“苹果, Inc.”这个选项里面带的英文逗号会把选项切成两半。实际项目里遇到这种情况要么选项文案里避免英文逗号要么改成从另一个工作表引用区域的方式这个下面单独说。第三个坑下拉范围sqref如果写的是整列比如A:AExcel 表情会很大但也支持如果数据就 50 行我建议精确写A2:A51这样不会出现用户拖动下拉到底时出现几百行空白选项的怪异体验。3.5 选项太多时从另一个工作表引用如果下拉选项有几百个把它们全部拼进formula1会让 xml 文件很大而且选项内容里有逗号基本就没法处理。这时候的标准做法是在同一个工作簿里放一个辅助 sheet里面写满选项然后在数据验证里引用这个 sheet 的单元格区域。对应的 dataValidation 写法是dataValidation typelist allowBlank1 showDropDown0 formula1选项表!$A$1:$A$100/formula1 sqrefA2:A100/sqref /dataValidation注意如果辅助 sheet 的名字包含空格引用时要加单引号formula1下拉选项!$A$1:$A$100/formula1在用 JSZip 二次加工时你可能需要同时创建一个辅助 sheet 的 xml 文件或者用 xlsx 库先把这个辅助 sheet 放进 workbook 里再让 dataValidation 引用它。实现上会稍微复杂一些但遇到大数据量选项时这是最可靠的方案。通常我还会把这个辅助 sheet 隐藏掉避免用户看到多余的 sheet。不过隐藏 sheet 需要额外的 sheet 状态属性这在纯前端实现里并不复杂只是 xml 里要多写一个sheetStatehidden/sheetState实际操作时注意别把主数据 sheet 给隐藏了。4. 进阶玩法导出多个工作表4.1 多工作表创建与写入多工作表的导出在 API 层面其实最简单就是book_append_sheet多调用几次把不同的数据分别放进不同的工作表const workbook XLSX.utils.book_new(); const studentSheet XLSX.utils.json_to_sheet(studentRows); const courseSheet XLSX.utils.json_to_sheet(courseRows); const scoreSheet XLSX.utils.json_to_sheet(scoreRows); XLSX.utils.book_append_sheet(workbook, studentSheet, 学生名单); XLSX.utils.book_append_sheet(workbook, courseSheet, 课程表); XLSX.utils.book_append_sheet(workbook, scoreSheet, 成绩表); XLSX.writeFile(workbook, 教务数据.xlsx);这里有个新手很容易踩的坑sheet 名称不能为空不能包含\ / ? * [ ] :这些字符长度不能超过 31 个字符。如果项目里 sheet 名是动态生成的比如按日期生成“2026-05-01 数据汇总”你最好先做一次清洗把冒号、斜杠这些都去掉否则导出时直接报错或者生成的文件打不开。4.2 多工作表的典型业务场景实际项目里多工作表的导出经常不是“手动写 5 个 sheet”而是根据一组数据循环生成。比如按月份导出全年报表后台返回的数据是按月分组的对象数组const monthData { 1月: [{ 部门: 研发部, 支出: 10000 }], 2月: [{ 部门: 研发部, 支出: 12000 }], // ... };循环生成多个 sheetconst workbook XLSX.utils.book_new(); Object.entries(monthData).forEach(([month, rows]) { const worksheet XLSX.utils.json_to_sheet(rows); XLSX.utils.book_append_sheet(workbook, worksheet, month); }); XLSX.writeFile(workbook, 月度报表.xlsx);再比如按部门导出员工信息每个部门一个 sheet部门下面就是该部门的员工数据。这种模式本质上是一个sheetName - rows的映射循环 append 就行。多 sheet 导出时还有一个实用技巧不同 sheet 可以设置不同的列宽。列宽的设置是在 worksheet 上挂!cols属性worksheet[!cols] [ { wch: 15 }, { wch: 20 }, { wch: 10 } ];这个属性不设置也能导出但默认列宽显示很挤中文表头经常被截断。每个 sheet 的列宽是独立的所以循环生成时要注意每个 sheet 的!cols要单独设置。4.3 大批量数据下的性能与内存优化如果你要生成多个 sheet每个 sheet 里还有几万行数据直接json_to_sheet一把梭很容易导致页面卡死或者内存暴涨。我在一次导出三张表、每张表 5 万行左右的数据时页面直接卡了十几秒最后把分页表格都带崩了。优化思路有几个方向。第一个方向是分片写入。sheet_add_json这个 API 可以往已经存在的 worksheet 里追加数据不用一开始就把所有数据转成一个大 sheetconst worksheet XLSX.utils.aoa_to_sheet([header]); const chunkSize 5000; for (let i 0; i rows.length; i chunkSize) { const chunk rows.slice(i, i chunkSize); XLSX.utils.sheet_add_json(worksheet, chunk, { origin: -1, header }); }origin: -1表示追加在最后一行之后header参数用来指定表头的字段顺序。这样每个 5000 行的小批次处理完就释放内存波动会小很多。第二个方向是控制导出规模。Excel 单个工作表最多支持 1048576 行但前端生成这么大数据量的文件用户也受不了。如果数据确实很多我一般会限制导出条数上限或者提示用户使用后端异步导出。导出这个动作应该是给用户提供方便而不是让浏览器崩溃。第三个方向是注意不要重复生成对象。比如你在循环里做了rows.map(...)生成新数组又去转 sheet数据量大时这些中间对象都会占用内存。可以尽量在得到最终数据后直接处理减少不必要的复制。5. 工程化封装与高频问题排查5.1 完整工具函数封装实际项目里我不会在 vue 组件里堆一大坨导出逻辑而是会抽成一个独立的utils/excel.js文件把基础导出、带下拉框导出、多工作表导出统一封装。// utils/excel.js import * as XLSX from xlsx; import JSZip from jszip; function triggerDownload(blob, fileName) { const link document.createElement(a); link.href URL.createObjectURL(blob); link.download fileName; document.body.appendChild(link); link.click(); document.body.removeChild(link); setTimeout(() URL.revokeObjectURL(link.href), 5000); } // 基础导出 export function exportSingleSheet(rows, sheetName Sheet1, fileName 导出.xlsx) { const worksheet XLSX.utils.json_to_sheet(rows); const workbook XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); XLSX.writeFile(workbook, fileName); } // 多工作表导出 export function exportMultiSheet(sheetList, fileName 导出.xlsx) { const workbook XLSX.utils.book_new(); sheetList.forEach(({ sheetName, rows, columns }) { const worksheet columns ? XLSX.utils.aoa_to_sheet([columns, ...rows]) : XLSX.utils.json_to_sheet(rows); XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); }); XLSX.writeFile(workbook, fileName); } // 带下拉框导出 export async function exportWithDropdown({ rows, sheetName Sheet1, fileName 导出.xlsx, dropdown }) { const worksheet XLSX.utils.json_to_sheet(rows); const workbook XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); const buffer XLSX.write(workbook, { bookType: xlsx, type: array }); const zip await JSZip.loadAsync(buffer); let sheetXml await zip.file(xl/worksheets/sheet1.xml).async(string); const dropdownXml dataValidations count1 dataValidation typelist allowBlank1 showDropDown0 formula1${dropdown.options.join(,)}/formula1 sqref${dropdown.cellRange}/sqref /dataValidation /dataValidations; sheetXml sheetXml.replace(/worksheet, dropdownXml /worksheet); zip.file(xl/worksheets/sheet1.xml, sheetXml); const blob await zip.generateAsync({ type: blob }); triggerDownload(blob, fileName); }组件里的调用就变得非常干净import { exportMultiSheet, exportWithDropdown } from /utils/excel; function handleExport() { exportMultiSheet([ { sheetName: 学生名单, rows: studentRows }, { sheetName: 课程表, rows: courseRows }, ], 全校数据.xlsx); } async function handleExportWithDropdown() { await exportWithDropdown({ rows: studentRows, sheetName: 学生名单, fileName: 学生信息.xlsx, dropdown: { options: [本科, 硕士, 博士], cellRange: B2:B200 } }); }这样封装之后组件只需要关心业务数据是什么、要导出成什么文件名底层的 xlsx 组装和 xml 加工细节都被隐藏了。后续如果项目里要替换导出库也只需要改这一个文件。5.2 高频问题速查表把我在实际开发中遇到的问题整理成了表格遇到类似情况可以直接对号入座。问题现象根本原因解决办法导出的文件打开提示“格式错误”文件后缀名不对或者 Blob 的 MIME type 写错确保下载文件名以 .xlsx 结尾Blob type 使用application/vnd.openxmlformats-officedocument.spreadsheetml.sheet下拉框完全看不到showDropDown没有设置为 0在 dataValidation 节点里显式加showDropDown0下拉框看到了但选项是空的formula1里引用的区域没有数据或 sheet 名写错检查辅助 sheet 是否存在检查$A$1:$A$100范围是否真的有值下拉选项里英文逗号被错误切分list 类型的分隔符就是英文逗号选项文案里去逗号或者改为引用辅助 sheet 单元格区域sheet 名带冒号/斜杠导致导出失败这些字符是 Excel 不允许出现在 sheet 名里的生成 sheet 名时统一过滤\ / ? * [ ] :中文文件名在部分浏览器乱码浏览器下载链接对中文支持不一致用 Blob URL.createObjectURL方式下载不要直接用 writeFile数据超过 5 万行页面卡死一次性把大数据转 sheet 占用太多内存用sheet_add_json分片追加或者限制导出规模带下拉框导出后 WPS 里看不到下拉WPS 对 dataValidation 的兼容性和 Excel 有差异检查 showDropDown 属性和声明位置WPS 打开后重新保存一次通常能恢复5.3 前端导出与后端导出的边界判断最后说一个很多开发者容易忽略的问题什么时候不应该用前端导出。如果你的数据量动辄几十万行前端生成文件会非常吃力如果导出前需要服务端做复杂的权限过滤、数据脱敏前端拿到的数据本身就不完整导出结果也会有问题如果业务方要求的是一份带定制样式、套用固定模板的文件前端去拼装成本很高用后端模板引擎会更可靠。前端导出最适合的场景是数据已经完整呈现在页面上用户只需要把当前视图导出来数据量不大点击导出后 1 到 2 秒内能出结果或者产品要求实时性很强不希望等后端异步生成再下载。这也是我个人的一个判断准则能用前端做的、效果好的就前端做一旦发现这个导出需求开始为了兼容各种边界条件而堆代码就停下来评估一下是不是该让后端介入了。回到下拉框这个需求如果你在项目里反复遇到多级联动下拉、跨 sheet 校验、复杂公式这些高级功能我不建议再硬用 xlsx 库加 JSZip 去 xml 里手动拼。这种场景下换成 exceljs 或者后端导出是更省事的路子。文章里这套方案的真正价值在于让你理解 xlsx 文件内部到底长什么样以及当开源库的能力边界达不到需求时你有能力自己动手去补齐。