
做后台管理系统开发的朋友应该都有同感把当前表格导出成Excel这个需求几乎在每个项目里都会撞见。以前我习惯把活甩给后端后端拼文件、返回地址、前端触发下载一条链路看似顺畅但一旦碰样式就难受了。今天要带个筛选条件、明天表头要多加一行、后天某个列要标红来回沟通的成本比写代码还高。后来在某个Vue3项目中我彻底切到了ExcelJS导出逻辑完整放在前端做自己取数、自己排版、自己导出样式随手就能控制再也不用夹在后端和产品之间传话了。这篇文章就围绕ExcelJS在Vue3里的原生导出和样式设置展开我会从方案选型、样式API拆解、完整模块实现到常见坑位排查把我实际跑通的一整套东西整理出来。适合正在用Vue3写后台、被导出需求反复折腾的开发者参考。不管你是刚接触ExcelJS还是已经用过但被样式细节卡住这篇应该都能帮你省下不少时间。1. 方案选型与整体设计思路1.1 为什么最终选了ExcelJS而不是其他方案前端生成Excel的库其实不止一个早期我试过直接拼HTML表格再转xls的老套路那玩意生成的文件打开会带格式警告兼容性也差。也用过一个比较流行的轻量库它的文件体积确实小但核心能力是表格数据序列化没有workbook、worksheet这种完整的电子表格对象模型更别提操作样式。想实现合并单元格、设置冻结窗格、控制某个单元格的字体颜色它基本上是无能为力的顶多套一层最原始的样式模板。ExcelJS吸引我的点是它建立了一整套完整的Excel对象模型。Workbook对应整个工作簿Worksheet对应底部标签页Cell对应每个格子同时提供了从行列、边框、字体到填充、条件格式、批注、图片的全体系API。这意味着我可以用接近Excel操作习惯的思维方式来写代码而不是为了导出一个文件去想各种奇技淫巧。它在NPM上的周下载量、社区活跃度和文档完整度也是当时几个候选方案里最稳的。另外ExcelJS生成的是真正的xlsx文件基于OpenXML标准不需要依赖Excel环境用WPS、Office或者在线文档打开都没问题。这一点在团队协作和跨平台场景里非常重要。后端导出Excel通常要专门部署模板文件、维护服务而ExcelJS可以全部放在浏览器端完成对后端资源零占用甚至可以把导出逻辑沉淀成前端公共模块以后每个页面复用就行。1.2 Vue3里的导出架构思路在Vue3项目里用ExcelJS我通常不是把它直接散落在各个业务组件里而是单独做一个导出模块。这个模块做的事情是接收原始数据、接收列配置、内部完成workbook的创建、sheet数据的填充、样式的批量应用最后统一输出Blob文件。业务组件只需要传数据和配置不用关心Excel细节。这样设计的好处很明显一是样式逻辑收敛在一个地方以后调表头颜色、换字体不用每个页面都去改二是统一的入口方便做扩展比如批量导出多个sheet、导出带图片的报表都能在模块层面集中处理。我习惯把列配置定义成数组每项包含字段名、表头文字、列宽、对齐方式、数字格式等元信息然后由导出模块统一解析这样的扩展性最好。在实际项目中导出模块还会处理好几个跨页面通用的细节比如文件命名加日期、导出成功后的用户提示、大数据量场景下前端分批处理的逻辑等。这些看起来是小点但散落在每个页面里写很容易不一致集中放在模块里会省心很多。2. ExcelJS样式体系逐项拆解2.1 从单元格对象说起ExcelJS的样式操作核心单元是Cell。每个Cell本身是一个对象上面挂着一系列样式相关的属性font、alignment、border、fill、numFmt以及保护相关的lock等。操作方式很直接拿到单元格对象后直接给属性赋值例如const cell sheet.getCell(A1); cell.value 标题; cell.font { name: 微软雅黑, size: 14, bold: true, color: { argb: FFFF0000 } };这里面有个细节值得注意ExcelJS的字体颜色、填充颜色这类颜色值使用的是ARGB格式也就是8位十六进制最前面两个字节是Alpha通道实际写的时候通常直接写FF加6位色值。很多人第一次用会写#FF0000或者只写6位色值结果颜色不生效就是这个原因。颜色值同样适用于边框、填充。另一个习惯是先定义好一个样式对象再赋给多个单元格避免一个个手写。不过这里有个需要意识到的点赋值的是对象引用后续如果修改这个对象已经赋过值的单元格样式也会跟着变。如果希望每个单元格独立建议用浅拷贝的方式处理。2.2 字体、对齐与填充的完整参数字体这块常用属性包括name字体名、size字号、bold加粗、italic斜体、underline下划线、color颜色。实际项目里最常用的就是中文环境中设置微软雅黑或者宋体表头加粗。这里有一个中文场景容易踩的坑如果导出文件在别人电脑上打开但对方机器没有你在代码里指定的字体Excel/WPS会自动替换成默认字体导致排版看起来不一样。所以在字体选择上尽量用通用性强的字体。对齐方式通过alignment属性配置常用的包括cell.alignment { horizontal: center, // 水平left、center、right、fill、justify、centerContinuous vertical: middle, // 垂直top、middle、bottom wrapText: true, // 是否自动换行 textRotation: 0, // 文字旋转角度 indent: 1 // 缩进 };horizonta和vertical这里特别容易写错拼写我发现实际项目里很多坑都是拼写导致样式不生效。另外wrapText: true这个属性很实用像地址、描述这类长文本列有了自动换行才能保证行高撑开内容不会溢出到旁边格子。填充样式分为两种模式最常用的是纯色填充cell.fill { type: pattern, pattern: solid, fgColor: { argb: FFF4B183 } };如果要做渐变或者双色斜纹效果需要调整pattern为其他枚举值但实际业务报表里很少用到。表头加底色、奇数偶数行交替配色、合计行标灰用solid模式就完全够用了。这里补充一下fgColor是前景色在solid模式里就是你看到的主颜色bgColor是背景色在pattern模式下才会作为衬底颜色显示不要搞反了。2.3 边框、合并与冻结边框是最能体现表格专业感的样式。默认导出的Excel是没有边框的数据一多看起来就像散装文本。ExcelJS里边框分上下左右四个方向每个方向可以设置style和colorcell.border { top: { style: thin, color: { argb: FF000000 } }, left: { style: thin, color: { argb: FF000000 } }, bottom: { style: thin, color: { argb: FF000000 } }, right: { style: thin, color: { argb: FF000000 } } };style支持的值包括thin、medium、thick、dashed、dotted、double等日常报表用thin就够合计行上方用medium或thick做分隔线会更有层次感。这里有一个效率上的建议与其给每个单元格单独设置四边不如按行列批量遍历设置后面我会在实操部分给出具体写法。合并单元格在报表里非常常见比如大标题跨多列、分组表头跨两行。ExcelJS里合并操作很简单sheet.mergeCells(A1:F1);合并之后值只需要写在合并区域左上角那个单元格里其他单元格会变成合并区域的一部分不能再单独赋值。如果有边框需求合并后只需要给左上角单元格设置边框整块合并区域会统一渲染。这里有个小坑合并单元格后再通过getCell去拿区域内其他格子的值会拿到null因为它们在合并区里没有独立存在。遍历数据时要注意别去操作合并区域内的其他单元格。冻结窗格也是后台报表的高频需求尤其数据行数多的时候。配置方式如下sheet.views [{ state: frozen, xSplit: 1, ySplit: 1 }];xSplit和ySplit分别表示冻结列数和冻结行数。比如ySplit: 1是冻结第一行表头滚动数据时表头永远可见。配合xSplit: 1可以同时冻结第一列适合字段多、需要横向滚动的宽表。注意这个赋值是给整个sheet的view配置而不是某一行或某一列。2.4 行列级别的样式控制除了单元格ExcelJS还支持行列级别的操作这在设置列宽、行高和整行整列样式时非常高效。列宽设置有两种方式。一种是在定义columns时直接带widthsheet.columns [ { header: 姓名, key: name, width: 15 }, { header: 年龄, key: age, width: 8 } ];另一种是后续通过列索引调整某个列宽度sheet.getColumn(3).width 20;行高默认情况下Excel会根据内容自动调整但设置了固定行高后内容再多也不会撑开。所以如果某个列开了自动换行建议给对应的行设置足够的高度或者干脆不设置行高让它自适应。需要给所有数据行统一样式时可以遍历行sheet.eachRow((row, rowNumber) { row.height 22; row.eachCell((cell) { cell.alignment { vertical: middle }; }); });注意eachRow遍历的范围默认是存在内容的行。如果中途通过row.height ...设置了高度但这个行本身没有数据遍历时不会到达。更保险的做法是先填完所有数据最后再统一处理样式。3. 从零搭建带样式的导出模块3.1 安装与基础封装安装ExcelJS很简单一条命令npm install exceljs它有自己的TypeScript类型定义Vue3项目一般也都用TS类型提示很全。初始化一个workbook的常规流程是import ExcelJS from exceljs; const workbook new ExcelJS.Workbook(); workbook.creator 某公司前端导出模块; workbook.created new Date();creator和created这两个属性会写入文件元信息虽然不是必须的但每次导出都带上方便文件管理。接着添加工作表const sheet workbook.addWorksheet(数据报表, { properties: { defaultRowHeight: 20 } });工作表名称有长度限制而且不能包含\ / ? * [ ] :这些字符命名的时候最好做一层校验。addWorksheet的第二个参数还支持很多配置比如views冻结设置、pageSetup打印设置、tabColor标签页颜色这些按需使用。3.2 表头与标题区的处理实际项目中一份稍微像样的报表通常会有一个总标题、一个表头行再加数据区。我用一个完整的导出函数来演示它接收数据数组和列配置数组function exportExcel(rows, columns, fileName) { const workbook new ExcelJS.Workbook(); const sheet workbook.addWorksheet(Sheet1); // 列配置 sheet.columns columns.map(col ({ header: col.title, key: col.field, width: col.width || 15 })); // 添加标题行并合并 const titleRow sheet.addRow([销售数据报表]); sheet.mergeCells(A1:${sheet.getColumn(columns.length).letter}1); titleRow.height 30; const titleCell sheet.getCell(A1); titleCell.font { size: 16, bold: true }; titleCell.alignment { horizontal: center, vertical: middle }; // ... }这里有个关键点sheet.columns设置了列头信息之后sheet.addRow添加数据会自动按照配置里的key和header来映射。但如果我们顶部插入了标题行表头行的位置会往下挪。纯用addRow的话ExcelJS会默认把第一行当成表头并写入header值这与我们插入标题行会有冲突。所以这里建议换一种处理方式先通过getRow拿到指定行号手动赋值单元格。const headerRow sheet.getRow(2); columns.forEach((col, index) { const cell headerRow.getCell(index 1); cell.value col.title; cell.font { bold: true, color: { argb: FFFFFFFF } }; cell.fill { type: pattern, pattern: solid, fgColor: { argb: FF4472C4 } }; cell.alignment { horizontal: center, vertical: middle }; }); headerRow.height 24;这种手动定位行的方式虽然代码多一点但对于复杂报表的排布其实更可控。表头背景色我一般用深蓝色系、白字加粗打开后视觉效果比较清晰。3.3 数据区的样式编排数据区紧跟着表头往下排。最简单的方式是把原始数据逐行addRow再统一设置样式。逐行添加有一个好处ExcelJS会根据sheet.columns的key自动匹配每个字段填充到对应列字段缺失就留空不用担心列错位。rows.forEach(item { sheet.addRow(item); });但这里有个样式会话的细节如果先全部加完数据再遍历设置样式数据量大时会产生多次对象访问如果每加一行立刻设置该行样式代码会显得重复。我的做法是先把数据全部写入再统一遍历处理样式这样逻辑清晰性能也更可控。sheet.eachRow((row, rowNumber) { if (rowNumber 2) return; row.height 22; row.eachCell(cell { cell.border { top: { style: thin, color: { argb: FFD9D9D9 } }, left: { style: thin, color: { argb: FFD9D9D9 } }, bottom: { style: thin, color: { argb: FFD9D9D9 } }, right: { style: thin, color: { argb: FFD9D9D9 } } }; cell.alignment { vertical: middle, horizontal: center }; }); });交替行配色我习惯通过判断行号奇偶来实现sheet.eachRow((row, rowNumber) { if (rowNumber 2 rowNumber % 2 0) { row.eachCell(cell { cell.fill { type: pattern, pattern: solid, fgColor: { argb: FFF2F6FC } }; }); } });这里有个性能上的考虑如果是几千行的大表eachRow加eachCell遍历单元格的次数会非常多。可以改为按整行设置样式ExcelJS会把这些样式记录在行级别而不是逐个格子都存一份样式对象文件体积和内存占用都会收敛不少。数字格式是容易被忽略但很重要的部分。金额、百分比、日期这些类型如果没有正确设置numFmt导出后要么变成一串数字要么是时间戳格式看起来很业余。比如金额列cell.numFmt #,##0.00;日期列cell.numFmt yyyy-mm-dd;百分比列cell.numFmt 0.00%;这些格式等同于Excel里的设置单元格格式-数字-自定义效果。日期类还有一个坑ExcelJS中写入日期建议用new Date()对象底层会自动做OADate序列化但需要配合numFmt才会显示成年月日否则打开就是原始数字。3.4 收尾workbook到文件的完整流程所有数据和样式都填充完毕后生成文件有统一的收尾流程const buffer await workbook.xlsx.writeBuffer(); const blob new Blob([buffer], { type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet }); const url URL.createObjectURL(blob); const link document.createElement(a); link.href url; link.download ${fileName || export}.xlsx; link.click(); URL.revokeObjectURL(url);writeBuffer在不同版本里返回类型略有差异有些需要buffer或处理二进制类型但现代浏览器环境下使用这个方法基本都是稳的。URL.revokeObjectURL一定要在a.click()之后调用否则部分浏览器可能还没完成下载就已经把地址回收了。文件名方面我会在模块内部拼上当前日期比如销售报表_20250718.xlsx这样导出多份不会互相覆盖。这里有个容易出问题的点如果文件名包含中文直接给link.download赋值是没问题的现代浏览器都能处理但如果项目通过接口下载头部Content-Disposition就需要额外处理URL编码反正ExcelJS方案不走接口省掉了这层麻烦。4. 常见问题与排查经验4.1 样式被覆盖是最大的坑实际开发中明明设置了样式打开文件却没有这类问题出现频率最高。排查下来大部分原因是对同一个单元格的样式属性做了多次赋值后一次赋值覆盖了前一次的部分字段。比如先赋了font后面又整体赋了一个新的font对象这个新对象里漏掉了color颜色就丢了。有一种情况特别隐蔽ExcelJS大概在3.x版本之后有了styles管理机制同一个单元格如果先设置fill再设置font正常不会互相影响。但如果你在一次循环里先给单元格赋值cell.value随后马上设置样式而这个单元格之前已经被其他逻辑赋过样式则完全覆盖的风险就存在。我的习惯是每个单元格的样式统一用一次对象赋值完成而不是拆成多次。如果确实需要分步设置每次都显式把需要的字段写全。另外尽量用cell.style {...}直接覆盖整个样式对象比一个个cell.font ...、cell.fill ...更可控。不过cell.style在旧版本里支持新版API推荐直接断言属性具体以当前版本为准两种方式都可以。4.2 列宽、行高的单位问题ExcelJS里列宽和Excel界面上的显示表现有差异。Excel界面上看到的列宽单位是英文字符数ExcelJS用的是这个逻辑的近似值。如果你设置width: 20打开Excel后大概就是20个字符的宽度。中文字符比英文字符宽所以如果传输中文列需要手动加大width系数不然中文会显示不全。我的经验是中文表头宽度系数给1.5左右比如操作人这种列给15到18打开就能完整显示。行高方面ExcelJS的默认单位是磅point和Excel的行高是同一套体系所以不需要额外换算。但如果某个单元格字体比较大、内容超过两行而设置了固定行高内容会被截断显示。这时要么去除该行的高度设置要么在设置wrapText: true的同时手动把行高算到足够容纳内容的值。一个粗暴但有效的做法是依据该行可能最长的字段字数乘上一个估算系数设置足够大的行高。4.3 大数据量的性能优化当数据量达到几千行甚至上万行时逐格设置样式会有明显的卡顿。ExcelJS本身在前端做序列化需要时间样式越多、writeBuffer的耗时越长这是正常的但仍有一些优化空间。首先是样式压缩尽量复用同一个样式对象不要每行都创建新的。比如交替色、边框、对齐方式这些分别抽成常量循环里只把常量赋值给单元格ExcelJS内部会根据样式内容做去重处理文件体积明显变小生成速度也能快一些。其次避免频繁调用getCell这种按坐标取单元格的方式在eachRow里用row.eachCell或直接按row.getCell(index)更高效。上万行数据时节点遍历的调用次数是几何级增长的任何多余操作都会被放大。如果数据量真的巨大比如说几十万行前端导出本身就是不合理的方案。这种场景应该限制导出条数或者在后端做异步导出任务。ExcelJS虽然有stream写文件的能力但在浏览器端受限于jszip的内存开销稳定性并不好不如在服务端做。4.4 文件损坏、日期序列化等其他杂症配置了合并单元格后生成文件打不开或者打开提示文件已损坏多数原因是合并区域跨度过大并且区域内左上角单元格无值或者合并区域与其他合并区域产生了重叠冲突。ExcelJS不会在生成时主动检测这种逻辑错误需要自己确认。常见错误比如mergeCells(A1:C1)后又对B1单独赋值或者两个合并区域部分重叠都会导致文件结构异常。日期序列化问题前面提到过不设置numFmt、直接赋值Date对象会变成OADate数字。还有一种情况是后端接口返回的日期是字符串格式ExcelJS不会自动识别为日期导出后就是文本。要在前端先转成Date对象再赋值。注意时区问题如果项目有UTC偏移处理转成Date对象后写入Excel的日期可能与预期相差8小时最好确认接口返回的时间字段已经经过了统一处理。还有一点如果单元格的值以开头ExcelJS会把整段内容当作公式处理。比如导出分类编号精装这种文本打开文件时会显示为公式错误。需要转成字符串的格式。处理方案有两种一种是写入时手动加单引号前缀Excel打开时会当作文本另一种是使用cell.value { text: 精装 }这种对象形式写入。后者是更明确的写法。4.5 表格视图、打印设置与隐藏列这部分不算坑但用好了很加分。如果报表打开后希望用户第一眼就看到特定区域可以用sheet.views配置缩放比例sheet.views [{ zoomScale: 90 }];导出前如果希望隐藏某些辅助列比如ID字段可以设置列隐藏sheet.getColumn(1).hidden true;打印场景下通过pageSetup设置横向打印、A4纸、缩放比例可以让报表打印出来不带多余的空白页sheet.pageSetup { orientation: landscape, fitToPage: true, fitToWidth: 1, fitToHeight: 1, paperSize: 9 // A4 };这些配置都是元数据级别的不会影响导出内容但对用户体验和之后的纸质归档很有帮助。我一般在通用导出模块里默认把fitToWidth设置为1这样无论多少列打印时总会缩到一页宽避免打印出来被切掉大半。5. 导出模块的整体收益与后续扩展Vue3 ExcelJS这套组合对我来说最大的收益是真正把导出这个能力还给了前端。产品提需求、改样式我直接在组件里改配置一条数据流的改动几分钟重新导出验证整个迭代效率比之前跨团队沟通高很多。对中小型团队来说沉淀一个导出模块能覆盖绝大多数报表页面性价比相当高。扩展方向也很明确。比如多sheet导出workbook.addWorksheet可以创建多个工作表每个sheet里放一组数据一键生成月度汇总文件。又比如导出带图片的报表workbook.addImage加sheet.addImage就能在指定位置插入Logo、签名或者数据图片配合editAs参数控制图片锁定于单元格或浮于上方。另一个常用扩展是数据校验sheet.dataValidations可以为某列设置下拉选项这在做模板类导出时特别有用。我个人在实际操作中的体会是ExcelJS的API设计挺贴合Excel本身的逻辑学习成本不高真正的难点在细节。颜色搞错格式、样式被覆盖、日期变序列号、合并区域重叠这些坑每个都会浪费一两个小时。但只要你把这些经验固化到统一模块里后面基本就是一劳永逸。这篇内容里涉及的代码片段我在项目里都反复用过组合起来可以直接搭出一套够用的导出能力。如果你也正在Vue3项目里捣鼓Excel导出照着这套思路去封装应该能避开我踩过的大部分坑位。