做后台管理的你多半被问过这句话“这个列表能不能导成 Excel”我接过的项目里十个有八个都有导出需求而且一旦提到“表头带个颜色”“合计行加粗”“列宽别太挤”官方版的xlsx库就开始力不从心。今天聊的这套sheetjs-style就是在 vue 项目里专门用来解决“带样式导出 Excel”这套问题的方案。它不是新东西但实用程度非常高尤其是你要做复杂报表、多级表头、多 Sheet 导出的时候比自己在字符串层面硬拼要省心得多。这篇文章我会从选型思路讲起把安装引入、核心函数封装、合并单元格、多 Sheet、常见踩坑、性能取舍一次聊完。无论你是刚接触 vue 的初学者还是被导入导出坑过几次的老手照着文章里的代码改一改基本能覆盖日常 90% 的导出需求。1. 为什么最后选了 sheetjs-style 这套方案1.1 官方 SheetJS 能导出但样式是硬伤先说个很多人都遇到的场景用官方xlsx库导出的文件表格能打开内容也对但你在前端页面里精心设置的表头背景色、边框、居中、加粗导出后全没了。原因很简单官方社区版的定位是“数据交换”不是“报表呈现”它对单元格样式cell.s的支持被砍掉了或者说不完整。有人会走另一条路把table元素直接转成 Excel也就是把 HTML 表格复制到剪贴板再粘到 Excel或者用一些库读取 DOM 的table节点。这个方式适合极简单的表格一旦遇到合并单元格、自定义列宽、打印设置就非常不稳定导出的文件在不同电脑上打开差异巨大。我自己就在交付现场踩过这种雷客户用 WPS 打开表格样式直接乱掉当时别提多尴尬。sheetjs-style就是针对这个痛点出现的解决方案它保留了 SheetJS 的 API 习惯同时把样式写入能力补了回来。你不需要重新学一套对象模型book_new、aoa_to_sheet、book_append_sheet这些函数照用只是在单元格对象上加一个s属性样式就能写进最终的文件里。1.2 sheetjs-style 到底是什么严格点说sheetjs-style是 SheetJS 社区 fork 出来的一个版本所以核心 API 和官方版几乎一样。你在代码里看到的XLSX.utils.aoa_to_sheet、XLSX.writeFile在sheetjs-style里都能找到对应实现。它最大的扩展点是每个单元格对象可以这样写ws[A1] { v: 合计, t: s, s: { font: { bold: true, sz: 12 }, fill: { fgColor: { rgb: FFF2CC }, patternType: solid }, alignment: { horizontal: center, vertical: center }, border: { top: { style: thin, color: { rgb: 999999 } }, bottom: { style: thin, color: { rgb: 999999 } }, left: { style: thin, color: { rgb: 999999 } }, right: { style: thin, color: { rgb: 999999 } } } } }v是单元格的值t是类型s就是样式对象。我把这段代码称作“最小样式单元”很多复杂报表的样式本质上是把同样的结构复制到不同的单元格上。网上有观点说这库“年久失修”这话不完全错但要看使用场景。如果你只是做常规的 B 端管理后台数据量没过万样式要求不涉及图片它非常够用而且 API 极其稳定项目里锁死版本号基本不会出幺蛾子。1.3 什么时候应该换 exceljssheetjs-style不是万能的。我一般会拿一张简单的表做决策参考需求sheetjs-styleexceljs前端导出带样式的普通表格推荐可以多级表头合并单元格推荐可以嵌入图片到单元格不支持支持超过几万行的流式写入一般容易卡更稳后端 Node 环境生成文件可以推荐exceljs的功能确实更全面文档也更活跃但它体积更大API 风格和 SheetJS 差异不小。如果你只是给 vue 管理后台加一个导出按钮没必要为了一个按钮引入一个重型方案。反过来如果业务里明确要求导出的报表里带公司 Logo 图片或者单表数据量动不动就是十几万行那就直接选exceljs别在sheetjs-style上硬扛。选型这件事最忌“哪个火用哪个”。我见过一个项目因为过度追求功能全面前端打包体积增加了差不多 300 KB就为了导出个几十行的表单。没必要。2. 环境准备在 vue 项目里把它装进来2.1 安装依赖与版本注意在 vue2 或 vue3 项目里安装很简单npm install xlsx-js-style或者你下载sheetjs-style.js这个文件放到src/utils下直接引入也行。我更推荐走 npm方便锁版本。需要提醒的是如果你项目里之前已经装过官方xlsx两个库同时存在容易搞混最好先卸载一个npm uninstall xlsx因为xlsx-js-style的包名虽然不同但导出对象的形状跟官方xlsx几乎一样。如果不小心在某个文件里import XLSX from xlsx样式不生效也排查不出来。有段时间 npm 上xlsx官方包停更在 0.18.5安全审计会报漏洞很多项目被迫迁移。xlsx-js-style社区维护相对积极我建议装完后看一眼package.json里的版本号固定下来避免某个小版本更新把 API 改没影了。2.2 引入方式的差异vue2 与 vue3vue2 项目大多是 webpack 构建常见写法是const XLSX require(xlsx-js-style)vue3 项目用 vite推荐import XLSX from xlsx-js-style这段代码在 vue3 里能直接跑不需要额外配置。如果你用的是 TypeScript可能会遇到没有类型声明的问题最简单的处理方式是在项目里新建一个shims-xlsx.d.tsdeclare module xlsx-js-style { export * from xlsx const XLSX: any export default XLSX }这不是最优雅的解法但见效快。我实际项目中就这么干过没出过什么问题。2.3 先把三个核心对象搞清楚动手写代码之前我建议先把三个对象的关系在脑子里过一遍。workbook是整个 Excel 文件可以理解成一个文件夹worksheet是文件里的一个页签cell是页签里的单元格。每个worksheet上除了正常的“行列网格”之外还有几个特殊属性以!开头是 SheetJS 约定好的命名空间。ws[!cols]控制列宽ws[!rows]控制行高ws[!merges]记录合并区域ws[!ref]表示这个 Sheet 的数据范围。我刚接触时老忘记!merges是个数组合并多个区域时直接赋了一个对象导致后面的合并全部失效。你在封装工具函数时一定要留意这些下划线开头的属性都是“数组形态”。一个最小的导出流程是const wb XLSX.utils.book_new() const ws XLSX.utils.aoa_to_sheet([ [姓名, 年龄, 部门], [张三, 18, 技术部] ]) XLSX.utils.book_append_sheet(wb, ws, 员工表) XLSX.writeFile(wb, 员工表.xlsx)这段代码跑通之后再往表单里补样式就顺理成章了。3. 核心实现把带样式 Excel 导出真正跑通3.1 先写一个最小可用版本我习惯先写一个“只保证能下载”的函数然后再一层层加东西这样调试起来思路清晰。import XLSX from xlsx-js-style export function exportExcel({ sheetName Sheet1, header [], body [], fileName 导出.xlsx }) { const data [header, ...body] const ws XLSX.utils.aoa_to_sheet(data) const wb XLSX.utils.book_new() XLSX.utils.book_append_sheet(wb, ws, sheetName) XLSX.writeFile(wb, fileName) }调用方式const header [姓名, 年龄, 部门] const body [ [张三, 18, 技术部], [李四, 22, 产品部] ] exportExcel({ sheetName: 员工表, header, body, fileName: 员工列表.xlsx })这个函数值得注意的一点是我特意用aoa_to_sheet而不是json_to_sheet。json_to_sheet适合后端直接返回对象数组的场景但列顺序并不总是你想要的还需要额外提供一个headers数组去重排字段。而aoa_to_sheet接收的是二维数组第一行是什么表头就是什么顺序完全可控。做复杂表格时这个可控性极其重要。3.2 单元格样式属性逐个拆解很多新手看到样式对象就头大其实拆开看就五类字体、填充、对齐、边框、数字格式。const style { font: { name: 微软雅黑, sz: 11, bold: true, color: { rgb: FFFFFF } }, fill: { fgColor: { rgb: 4472C4 }, patternType: solid }, alignment: { horizontal: center, vertical: center, wrapText: true }, border: { top: { style: thin, color: { rgb: BFBFBF } }, bottom: { style: thin, color: { rgb: BFBFBF } }, left: { style: thin, color: { rgb: BFBFBF } }, right: { style: thin, color: { rgb: BFBFBF } } } }font.name建议写系统中常见的字体微软雅黑、宋体都行但字体文件由打开文件的电脑决定前端设置只是写入一个偏好。sz是字号单位是磅。fill这里有一个最常见的坑patternType必须写成solid不然背景色不显示。我第一次用的时候就漏了这个属性颜色写了半天导出后怎么都是白的。alignment里的wrapText是自动换行长文本的单元格建议打开不然文字会溢出到旁边格子。horizontal和vertical分别控制水平和垂直方向的对齐方式。边框的写法看起来啰嗦但这是 Excel 文件格式的约束四条边要分开设置想偷懒写一个border: { style: thin }是没用的。我把这段样式对象称之为“一份通用样式模板”实际封装工具时可以直接复用。3.3 表头样式和数据区样式怎么分工导出时表头和数据区的样式一般是不同的。表头需要加粗、底色、居中数据区可能只需要边框和左对齐。我一般这样处理function applyHeaderStyle(ws, { rowCount, colCount }) { for (let r 0; r rowCount; r) { for (let c 0; c colCount; c) { const cell ws[XLSX.utils.encode_cell({ r, c })] if (!cell) continue cell.s { font: { bold: true, color: { rgb: FFFFFF } }, fill: { fgColor: { rgb: 4472C4 }, patternType: solid }, alignment: { horizontal: center, vertical: center }, border: createBorder() } } } } function applyBodyStyle(ws, { startRow, rowCount, colCount }) { for (let r startRow; r rowCount; r) { for (let c 0; c colCount; c) { const cell ws[XLSX.utils.encode_cell({ r, c })] if (!cell) continue cell.s { font: { sz: 11 }, alignment: { horizontal: left, vertical: center }, border: createBorder() } } } }XLSX.utils.encode_cell({ r, c })这个函数的作用是把行列索引转换成 Excel 的单元格地址比如{ r: 0, c: 0 }会得到A1。我早期不知道这个函数自己写列名映射后来发现完全没必要。样式分层的好处是表头改配色只动一处数据区的样式逻辑独立后续加斑马纹、条件标记也方便。这里我习惯把样式封装成createBorder函数避免每个格子都手写一遍边框结构。3.4 列宽行高表格能不能看的另一关键很多人导出后发现文字挤成一团或者单元格高到离谱问题往往出在没设置列宽行高。列宽通过ws[!cols]控制它是一个数组索引对应列序号ws[!cols] [ { wch: 12 }, { wch: 20 }, { wch: 30 } ]wch是字符宽度的意思。对于中文内容一个汉字大约占 2 个字符宽度所以最笨但最有效的计算方式是遍历每一列的数据取最长内容的长度再留一点余量function calcColumnWidths(data) { const widths [] data.forEach((row, r) { row.forEach((cell, c) { const len calcTextWidth(cell) widths[c] Math.max(widths[c] || 0, len) }) }) return widths.map(w ({ wch: Math.min(Math.max(w 2, 8), 50) })) } function calcTextWidth(value) { const str value null ? : String(value) let width 0 for (const ch of str) { width ch.charCodeAt(0) 255 ? 2 : 1 } return width }2是给单元格留的左右内边距Math.min(Math.max(...))把列宽限制在 8 到 50 之间避免过窄或者过宽。行高类似通过ws[!rows]设置单位是磅ws[!rows] [ { hpt: 24 }, { hpt: 20 }, { hpt: 20 } ]表头行高一般 24 到 28 比较合适数据区 18 到 20 够用。如果某些单元格开了自动换行行高最好也调大一点否则打开文件后文字会被截断。4. 复杂报表合并单元格、多级表头、多 Sheet 实战4.1 合并单元格的坐标陷阱合并单元格的写法如下ws[!merges] [ { s: { r: 0, c: 0 }, e: { r: 0, c: 3 } } ]{ s: { r: 0, c: 0 }, e: { r: 0, c: 3 } }表示从左上角(0,0)一直延伸到右下角(0,3)也就是把第一行的前四列合并成一个大单元格。这个 0 索引跟 Excel 界面里的 1 索引差 1我经常在写复杂表头时把r和c算错导致合并到旁边一列。更隐蔽的坑是合并单元格之后背景色和边框并不会自动在整个合并区域生效。Excel 的合并单元格最终只保留左上角单元格的值和样式但 SheetJS 在写入样式时需要你在合并区域内的每一个单元格上都写同样的s否则颜色会缺一块。我处理合并区域样式时会写一个辅助函数function applyMergedCellStyle(ws, merges, style) { merges.forEach(({ s, e }) { for (let r s.r; r e.r; r) { for (let c s.c; c e.c; c) { const addr XLSX.utils.encode_cell({ r, c }) const cell ws[addr] || { t: s } cell.s style ws[addr] cell } } }) }这个方法我每次做合并表头都会用到也算吃过亏后的条件反射了。4.2 多级表头的数据构造思路多级表头比如“产品 - 一月 - 新增/留存”这种两层甚至三层的结构用aoa_to_sheet手动构造二维数组是最直观的。先把表头按层级排好const headerRows [ [产品, 一月, , 二月, , 合计], [, 新增, 留存, 新增, 留存, 汇总] ]第一行里空字符串表示这个格子不需要值它只是占位为后续合并做准备。然后按坐标把需要合并的表头区域加进去const merges [ { s: { r: 0, c: 0 }, e: { r: 1, c: 0 } }, // 产品跨两行 { s: { r: 0, c: 1 }, e: { r: 0, c: 2 } }, // 一月跨两列 { s: { r: 0, c: 3 }, e: { r: 0, c: 4 } }, // 二月跨两列 { s: { r: 0, c: 5 }, e: { r: 1, c: 5 } } // 合计跨两行 ]这里最关键的一点是合并的坐标一定是你二维数组里的真实索引不是页面上看到的行列号。我建议写完之后先在浏览器里console.log(JSON.stringify(merges))检查一遍再触发下载能少很多来回调试的时间。4.3 多 Sheet 导出的几个约束多 Sheet 的逻辑很简单就是多次book_append_sheetconst wb XLSX.utils.book_new() XLSX.utils.book_append_sheet(wb, ws1, 汇总) XLSX.utils.book_append_sheet(wb, ws2, 明细) XLSX.writeFile(wb, 季度报表.xlsx)真正容易踩的是 Sheet 名称的合法性。Excel 的页签名不能包含: \ / ? * [ ]这些字符长度不能超过 31 个字符而且不能为空。后端返回的数据里经常出现奇怪的字符串我吃过一次亏接口给了一个库存/销售这样的名字直接用来当 sheetName生成的 Excel 在本地打开时提示文件损坏。现在的做法是写一个清洗函数function cleanSheetName(name) { return String(name || Sheet1) .replace(/[:\\/?*\[\]]/g, ) .slice(0, 30) || Sheet1 }4.4 身份证、日期、超链接这些边界项身份证号和手机号这类超过 15 位的数字如果在数据中是 number 类型导出后极大概率变成科学计数法或者末尾几位变成 0。这是 JS 数字精度丢失导致的跟 Excel 没关系。解决办法是把它们作为字符串传入或者强制设置单元格类型为文本。我一般在接口层就把这类字段处理成字符串const row [ String(user.idCard), userName, new Date(user.joinDate).toLocaleDateString(zh-CN) ]日期字段也别直接丢一个 Date 对象进去导出结果时好时坏。最稳妥的方式是预格式化成本地字符串至少保证打开文件时看到的是人能读懂的日期。如果需要在单元格里加超链接可以这样写ws[B2] { v: 点击查看详情, t: s, l: { Target: https://example.com, Tooltip: 详情页面 } }这些边界项单独看不难问题在于它们经常混在一个大表格里容易被忽略。我的经验是写导出工具前先把字段类型列一遍哪些是金额、哪些是百分比、哪些是长文本再决定怎么处理。5. 实战踩坑导出 Excel 不生效的常见问题5.1 样式“没生效”的排查顺序样式没生效是出现频率最高的问题。我根据自己经验整理了一个排查清单按这个顺序查90% 的情况五分钟内能找到原因。先看patternType有没有写。背景色必须配合solid才能显示这是新手最容易忽略的。再看边框是不是四条边都写了只写一个top其他三边自然没有。然后看合并区域单元格被合并后非左上角的格子样式有可能被覆盖需要用前面说的循环补齐。最后检查代码里是不是在给cell.s赋值后又对同一个单元格做了整体覆盖比如后面又调了一次aoa_to_sheet把之前的样式对象冲掉了。我给每个导出功能做验收时会在导出前打印ws[!ref]和ws[!merges]确认数据范围和合并范围符合预期再点下载按钮。这个方法听起来原始但比盲猜高效得多。5.2 生成的文件打开提示修复还有 WPS 兼容性有时候导出的文件在 Excel 里打开正常在 WPS 里却提示“文件已损坏”或者“上次启动失败安全模式”。最常见的元凶之一是 Sheet 名称里带了特殊字符另一个是合并区域的坐标超出了!ref的范围。!ref表示数据范围比如A1:H20。如果你合并了一个r从 0 到 25 的区域但!ref只到 20 行文件内部就会出现“越界引用”打开时修复提示就来了。解决办法是保证!ref覆盖所有写入过的单元格或者干脆在最后重新算一下ws[!ref] XLSX.utils.encode_range({ s: { r: 0, c: 0 }, e: { r: rowCount - 1, c: colCount - 1 } })我现在写导出工具时已经习惯性加上这句等于给文件的“边界”上了一道保险。5.3 大数据量下载卡死甚至浏览器崩溃很多管理后台动辄导出几万条数据前端一次性把几十 MB 的数据变成 Blob 再触发下载页面就卡住了。这是因为xlsx库本身先要构建整个 workbook 对象再把所有 cell 序列化成字符串内存峰值非常高。我对数据量做了一个简单分级数据量方案1 万行以内前端导出完全没问题1 万到 5 万行前端导出可行但要去掉不必要的样式体验一般5 万行以上建议后端生成文件前端直接下载如果确实只能前端做有几条优化思路去掉合并单元格和大量边框样式减少font和fill对象的重复创建数据分批push进二维数组不要用map里嵌套map再合并导出的 Sheet 数量控制在合理范围别一个工作簿塞二三十个页签。5.4 Electron 环境下下载失败的特别处理热词里有人搜“electron 打包 vue 项目”这正好是另一个容易踩坑的场景。Electron 里的渲染进程通过XLSX.writeFile触发下载时有时候文件没有正常弹出需要自己处理 Blob 和保存逻辑const wbout XLSX.write(wb, { bookType: xlsx, type: array }) const blob new Blob([wbout], { type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet })在纯浏览器环境里可以用createObjectURL加a标签模拟点击在 Electron 里则可以走主进程的dialog.showSaveDialog配合fs.writeFileSync。这个方案还有个额外的好处是可以通过XLSX.write拿到原始字节做签名、打印、归档等后续处理不必非得依赖writeFile的内部下载机制。6. 工程化建议把导出能力沉淀成一个工具6.1 统一封装utils/exportExcel.js我现在的做法是把导出相关代码统一收进src/utils/exportExcel.js不在每个页面里重复写。这个工具文件里通常放四个函数exportExcel、applyHeaderStyle、applyBodyStyle、calcColumnWidths。页面里只需要传header、body、merges、fileName这些业务参数不用关心样式细节。这样封装的好处很明显。第一样式调整只改一处全局生效不会出现一个项目里两种表头字体。第二可以针对不同导出场景扩展比如某些表格不需要边框传一个showBorder: false进去就行。第三后续如果要从xlsx-js-style切换到exceljs只改工具文件内部实现页面代码不用动。6.2 前端导出还是后端生成要早做决定跟后端同学配合时经常出现一个局面后端已经写好了 Python 或 Java 导出接口前端这边又单独用sheetjs-style做了一版。两边维护成本翻倍还容易出现数据口径不一致。我的判断标准很简单数据量超过 5 万行或者需要做复杂的数据加工、跨 Sheet 引用、权限过滤就交给后端。前端只负责拼参数、调接口、拿文件流下载。如果只是把当前页面的表格状态原样导出样式要求又比较高前端方案反而更快更爽。还遇到过一种情况客户要求导出后直接推送到钉钉群或者企业微信那本质上已经不是“前端导出”了而是后端生成文件再调机器人接口上传。这时候前端能干的就是把请求参数准备好剩下的事别硬扛。6.3 将来想支持导入解析怎么扩展导出做完了需求大概率会跟着来一个“导入”也就是用户上传 Excel后端解析入库。sheetjs-style同样可以处理读取方向const workbook XLSX.read(arrayBuffer, { type: array }) const firstSheet workbook.Sheets[workbook.SheetNames[0]] const rows XLSX.utils.sheet_to_json(firstSheet, { header: 1 })sheet_to_json的header: 1表示把每一行都转成数组这种格式方便前端做初步校验。导入的坑主要集中在空行、合并单元格、日期字段上但那就是另一个话题了。我的建议是在第一次做导出的时候就把读取的通用函数也放进utils里为后续需求留个口子。有个项目我一开始也是用官方xlsx加各种 hack 来给单元格染色后来又遇到合并表头代码绕来绕去最后换成sheetjs-style导出模块的代码量少了差不多三分之一。这里也可以给个建议每次导出的数据结构和样式组合变化大的时候别急着复制旧代码先花十分钟把!ref、!merges、!cols这三个属性在控制台里全部打出来看一眼再决定怎么改。我做导出功能时一定会先确认这几个隐藏属性的值跟产品经理给的表格模板对上了才触发下载。最后分享一个非常小但很加印象分的细节导出文件的页签名一定要带上业务名称别让客户一打开 Excel 看到默认的Sheet1。从工具函数层面把sheetName作为必传参数文件名也尽量带上日期比如2025年经营报表.xlsx。这些细节不会写进需求文档但验收的时候往往就卡在这些点上。希望这篇文章能帮你把 vue 前端导出 Excel 这件事做得更稳一点少走几个弯路。