☰
Vue前端高效导出带样式Excel:用sheetjs-style搞定复杂报表
2026/10/13 16:19:37 网站建设 项目流程

做后台管理的你,多半被问过这句话:“这个列表能不能导成 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 什么时候应该换 exceljs

sheetjs-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 与 vue3

vue2 项目大多是 webpack 构建,常见写法是:

const XLSX = require('xlsx-js-style')

vue3 项目用 vite,推荐:

import XLSX from 'xlsx-js-style'

这段代码在 vue3 里能直接跑,不需要额外配置。如果你用的是 TypeScript,可能会遇到没有类型声明的问题,最简单的处理方式是在项目里新建一个shims-xlsx.d.ts:

declare 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_sheet:

const 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 这件事做得更稳一点,少走几个弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询