☰
Vue+exceljs实现表格导出预览打印,前端报表全流程指南
2026/10/9 4:18:53 网站建设 项目流程

做后台管理系统时,表格数据导出、预览、打印这三件事几乎绑定出现。我之前一直用纯前端把数据拼成HTML table,再靠浏览器打印,导出就用CSV。CSV虽然简单,但遇到合并单元格、样式、多sheet,基本没法用。后来换成exceljs,配合Vue组合式API,把生成表格、预览、导出、打印四条链路全部打通,实测下来比预想中稳定不少。这篇文章就把我的实现思路和踩坑过程完整分享出来,适合在Vue 2或Vue 3项目里需要做报表导出、数据预览、打印的场景,前端开发者可以直接参考。

1. 整体思路与方案选型

1.1 为什么选择exceljs而不是CSV或html转xls

先说一个很多新人容易踩的坑:直接用HTML table改成.xls后缀,或者套一个XML字符串,浏览器确实能打开,但Excel会弹“格式与扩展名不匹配”的警告,而且样式、公式、多sheet全靠hack,导出到手机上经常乱码。CSV更不用说,合并单元格、富文本、单元格样式全都做不到。

exceljs是一个纯JavaScript的Excel读写库,浏览器和Node环境都能跑。它直接操作Workbook、Worksheet、Cell这些对象,生成的是真正的.xlsx文件(基于OpenXML标准),样式、公式、图片、批注、数据验证都能写。最关键的体验是:我们可以用同一套数据对象,先渲染一个HTML表格做预览,再用同一份数据喂给exceljs生成xlsx导出,最后打印时又借助HTML表格的CSS排版,三个功能共用一套数据源,逻辑不会分裂。

1.2 预览、导出、打印三个环节怎么配合

我的整体设计是这样的:

  • 预览:在页面里用数据渲染出一个HTML表格,所见即所得,用户可以先确认数据对不对、字段全不全。
  • 导出:点击导出按钮后,用exceljs把同一份数据生成xlsx,通过Blob触发下载。
  • 打印:点击打印按钮时,不导出文件,而是调用window.print(),配合打印样式只输出预览表格区域。

为什么导出不用预览表格?因为浏览器端HTML转Excel的工具要么收费要么维护不积极,而且打印样式的HTML和Excel的样式本来就是两套体系。exceljs负责生成真正符合Excel规范的xlsx,HTML表格负责预览和打印,分工明确,互不干扰。

1.3 Exceljs的适用边界

exceljs能做的很多,但也不是万能的。它不能解析老的.xls文件,需要.xlsx格式;它不支持Excel里的所有内置透视表功能;在浏览器端读取用户上传的Excel大文件时,性能会比Node端差一些。如果你的场景只是“前端生成表格数据并导出”,它完全够用。如果你的需求是复杂报表模板解析,建议后端配合处理。

2. 环境准备与基础概念

2.1 依赖安装与引入方式

我用的是Vue 3 + Vite项目,安装命令很简单:

npm install exceljs

也可以装到指定版本,目前我用4.4.0版本,稳定没有大坑。安装完成后,在组件里引入:

import ExcelJS from 'exceljs';

如果你的项目是Vue 2,引入方式一样,只是组件逻辑从setup换成data/methods而已。Vite环境下,exceljs会自动打成一个异步模块,如果担心首屏体积,可以用动态导入:

const ExcelJS = await import('exceljs');

放到“导出/预览”按钮的事件处理函数里,这样避免影响首屏加载速度。我在实际项目中就是这么做的,首屏资源体积能少几十KB。

2.2 必须搞懂的Workbook、Worksheet、Cell模型

使用exceljs之前,先建立一个对象模型:

  • Workbook:整个Excel文件,相当于一本“工作簿”。
  • Worksheet:工作簿里的一个“工作表”,一个文件可以有多个sheet。
  • Row:工作表里的行,行号从1开始。
  • Cell:行里的单元格,通过行列坐标定位。
  • Column:工作表的列,可以设置列宽、样式。

生成一份最简单的表格,只需要三步:

const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet('第一个Sheet'); sheet.addRow(['姓名', '城市', '业绩']); sheet.addRow(['张三', '上海', 1000]);

第一行是表头,第二行是数据。如果你不手动设置列宽,exceljs会用默认宽度,中文经常挤在一起。所以后续我们一般会显式定义列配置。

2.3 版本兼容与TypeScript类型问题

如果是TypeScript项目,exceljs自带类型定义,但有些API类型偏宽松。比如worksheet.eachRow()返回的行类型,你需要自己做类型断言。我在使用中没有遇到严重阻碍,但建议给导出的表格数据提前定义一个interface,比如:

interface RowData { name: string; city: string; value: number; }

这样在装配数据时不会手滑传错字段。

3. 核心功能实现

3.1 用exceljs构建带样式的表格数据

先说最核心的导出逻辑。假设页面里有一份订单数据,我们需要生成一个带标题、表头、边框、隔行底色的报表。

数据源大概长这样:

const tableData = [ { name: '张三', department: '销售部', salary: 12000, joinDate: '2023-01-15' }, { name: '李四', department: '市场部', salary: 11000, joinDate: '2023-03-20' }, { name: '王五', department: '技术部', salary: 15000, joinDate: '2022-11-02' }, ];

构建工作簿时,我的习惯是封装一个buildWorkbook(data)函数,方便测试和复用:

async function buildExcelWorkbook(data) { const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet('人员工资表'); // 设置列配置,宽度单位是字符宽度,中文建议15-20 sheet.columns = [ { header: '姓名', key: 'name', width: 15 }, { header: '部门', key: 'department', width: 18 }, { header: '工资', key: 'salary', width: 15, style: { numFmt: '#,##0' } }, { header: '入职日期', key: 'joinDate', width: 20 }, ]; // 先加一行标题,再合并单元格 sheet.addRow(['员工工资报表']); sheet.mergeCells('A1:D1'); const titleRow = sheet.getRow(1); titleRow.height = 30; titleRow.getCell(1).font = { size: 16, bold: true, color: { argb: 'FFFFFFFF' } }; titleRow.getCell(1).alignment = { vertical: 'middle', horizontal: 'center' }; titleRow.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4472C4' } }; // 第二行是表头,exceljs会读取columns里的header sheet.getRow(2).font = { bold: true }; sheet.getRow(2).alignment = { horizontal: 'center' }; // 数据从第3行开始 data.forEach((item) => { sheet.addRow({ name: item.name, department: item.department, salary: item.salary, joinDate: item.joinDate, }); }); // 给数据区域加边框 const lastRow = sheet.lastRow.number; for (let rowIndex = 3; rowIndex <= lastRow; rowIndex++) { const row = sheet.getRow(rowIndex); row.alignment = { vertical: 'middle', horizontal: 'left' }; row.eachCell((cell) => { cell.border = { top: { style: 'thin', color: { argb: 'FFB0B0B0' } }, left: { style: 'thin', color: { argb: 'FFB0B0B0' } }, bottom: { style: 'thin', color: { argb: 'FFB0B0B0' } }, right: { style: 'thin', color: { argb: 'FFB0B0B0' } }, }; }); } return workbook; }

这里有几个关键点:

  1. sheet.columns定义列时,header不能和后面的sheet.addRow(['员工工资报表'])混在一起,因为第一行被标题占用了。我在第二行单独设置表头样式,数据从第三行开始。
  2. 合并单元格用sheet.mergeCells('A1:D1'),合并后的样式只作用于左上角单元格,其他单元格会被隐藏,所以标题文字写在getRow(1).getCell(1)。
  3. 边框用cell.border逐个设置,工作量大,但效果好。如果表格行数上千,不建议循环里做太多样式,性能会下降。

3.2 表格数据的前端预览实现

预览最简单的方式是直接渲染HTML table。这里我做一个“所见即所得”的预览区,数据改动后表格立即更新。

Vue 3代码段:

<template> <div class="table-preview"> <table> <thead> <tr> <th>姓名</th> <th>部门</th> <th>工资</th> <th>入职日期</th> </tr> </thead> <tbody> <tr v-for="item in tableData" :key="item.name"> <td>{{ item.name }}</td> <td>{{ item.department }}</td> <td>{{ formatSalary(item.salary) }}</td> <td>{{ item.joinDate }}</td> </tr> </tbody> </table> </div> </template>

为什么不用exceljs去做预览?因为exceljs生成的是xlsx二进制文件,要预览必须先把Blob交给浏览器打开,在Vue页面里做不到内嵌Excel网格。HTML table可以完全控制样式,方便附加操作按钮、勾选列、分页逻辑。而且打印时直接复用这个表格结构,配合CSS分页就能打印出干净的报表。

如果你就是想要“点击预览后在浏览器新标签页打开Excel文件”的效果,exceljs也能实现,后面我在“备用预览方案”里会讲。

3.3 导出.xlsx文件的完整代码

预览没问题后,导出代码就顺理成章了:

async function handleExport() { const ExcelJS = await import('exceljs'); const workbook = await buildExcelWorkbook(tableData); // 把workbook转成ArrayBuffer const buffer = await workbook.xlsx.writeBuffer(); // 创建Blob并触发下载 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 = `员工工资表_${Date.now()}.xlsx`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); }

有几个细节我必须强调:

  1. writeBuffer()返回的是ArrayBuffer,不是二进制字符串。有些老博客写writeFile(),那是Node环境,浏览器里没有文件系统。
  2. Blob的type必须设置为xlsx对应的MIME,否则下载下来的文件打开会罢工。
  3. URL.revokeObjectURL(url)要在click()之后调用,但别太急,偶尔有浏览器还没完成下载就释放URL导致下载失败。稳妥做法是放到setTimeout里:
setTimeout(() => URL.revokeObjectURL(url), 100);
  1. 文件名带中文时,现代浏览器基本没问题,但如果你要兼容旧版ie,需要用encodeURIComponent处理。我一般直接写中文,放心点。

3.4 打印预览与打印样式控制

打印功能不需要exceljs,直接调用window.print(),但要配合CSS让打印内容只包含预览表格区域。

我的做法是给需要打印的主体加一个class,比如.print-area,然后在style里写:

@media print { body * { visibility: hidden; } .print-area, .print-area * { visibility: visible; } .print-area { position: absolute; left: 0; top: 0; width: 100%; } .no-print { display: none !important; } }

打印按钮:

function handlePrint() { window.print(); }

这样点打印时,浏览器弹出的打印预览里只有表格区域。如果你希望每个表格自动分页,可以在tr上设置:

tr { break-inside: avoid; }

对于特别宽的表格,可以设置打印方向为横向:

@media print { @page { size: A4 landscape; margin: 10mm; } }

Excel导出的表格列多,打印横向会比较舒服。

3.5 备用预览方案:用浏览器打开XLSX文件

如果产品经理要求“预览时必须看到Excel原样效果”,我们可以换个思路:用exceljs生成Blob后,不触发下载,而是放一个iframe,让src指向那个Blob URL,或者用window.open()打开一个新窗口。

const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); const url = URL.createObjectURL(blob); window.open(url, '_blank');

这个方案能打开Office在线预览或浏览器自带插件,但渲染效果取决于用户浏览器环境,有的电脑装了办公套件能预览,有的则直接下载。所以我在项目里没有把它作为主方案,只是留了一个“在新页面打开”的备用入口。

4. 实操过程与细节优化

4.1 列宽、行高、合并单元格的设置细节

列宽的单位是字符宽度,英文占1个宽度,中文占2个宽度。实际测试,表头“部门”用15宽度够,但内容很长的“备注”字段至少20起步。如果你不想手算,可以根据数据内容自动计算列宽:

function getAutoWidth(content, minWidth = 12) { let maxWidth = 0; for (const row of content) { const length = row.length + 2; // 留点余量 if (length > maxWidth) maxWidth = length; } return maxWidth < minWidth ? minWidth : maxWidth; }

注意,exceljs的width并不是精确像素,而是基于默认字体下的平均字符宽度,所以中文场景要适当加宽,我一般会在计算结果上再乘1.5。

行高设置为数字时,单位是point,默认行高约14.5。标题行我习惯设成25~30,让标题不拥挤。打印时如果行高太小,打印出来字会挨在一起,所以打印样式的表格里也要给td设置padding。

合并单元格有个容易忽略的问题:如果你合并的是A1:D1,然后你又要给A1设置样式,exceljs会有三种状态:

  • cell.master:合并区域的左上角单元格
  • cell.merged:当前单元格是被合并的成员
  • cell.isMerged:布尔值

给合并区域设置样式时,只对master单元格生效。如果想给合并后的边框设置样式,要遍历整个合并区域逐个设置border。

4.2 导出时处理日期、金额等常见格式

xlsx里日期本质是“日期序号”,exceljs写入日期时有个坑:如果你用new Date()作为cell的value,导出的文件往往看起来是一个数字。这时候必须设置单元格的数字格式numFmt。

常见的格式写法:

格式表达式含义
#,##0千分位数字
#,##0.00保留两位小数
yyyy-mm-dd日期格式
yyyy-mm-dd hh:mm:ss日期时间
0%百分比
¥#,##0.00人民币金额
@文本格式(当数字含前导0时用)

给一列统一设置格式,可以在columns定义时写:

sheet.columns = [ { header: '工资', key: 'salary', width: 15, style: { numFmt: '¥#,##0.00' } }, ];

如果数据源里已经是字符串'2023-01-15',我会在写入前先转成Date对象:

const dateValue = new Date(item.joinDate.replace(/-/g, '/'));

旧版iOS对new Date('2023-01-15')解析有问题,用replace(/-/g, '/')可以规避。字符串转Date后,再配合numFmt: 'yyyy-mm-dd',打开Excel就能显示成标准日期。

4.3 大文件导出和内存优化

当表格数据超过几千行,直接在前端一次性构建全部样式会卡顿甚至白屏。我实测Vue页面里渲染5000行HTML table,配合exceljs生成xlsx,整体时间大概在3秒多,可以接受。但如果数据上两万行,你就得考虑优化。

我的优化顺序是:

  1. 放弃给每一行单元格单独设置border,改为用exceljs的worksheet.properties整体设置默认样式?实际上exceljs没有全局默认边框,所以我们要么不设边框,要么按列设置,减少对eachCell的调用。
  2. addRow时不传对象,传数组,数组的方式比对象键映射快一些:
sheet.addRow([item.name, item.department, item.salary, item.joinDate]);
  1. 使用worksheet.addRows(bigData)批量添加,避免一行为一次操作带来的开销。
  2. 在数据特别大的时候,把生成过程放到Web Worker里,主线程不阻塞,UI不卡。不过exceljs在Web Worker里要用独立构建,配置略麻烦,我一般做数据量预估,超过1万行才启用。

还有一个容易忽略的点:window.print()打印几千行HTML表格时,浏览器也会卡。建议打印前截断数据,只打印前50条,并提示用户完整数据用导出功能获取。

4.4 导出按钮的状态反馈

导出是异步操作,至少要给用户一个“正在生成”的反馈。我在项目里用了个简单状态:

const exporting = ref(false); async function handleExport() { exporting.value = true; try { // 导出逻辑 } finally { exporting.value = false; } }

按钮绑定的文案可以动态显示导出中...,避免用户重复点击。

5. 常见问题与排查实录

5.1 导出文件打不开或报格式错误

出现这个问题,九成是Blob的type写错,或者writeBuffer没有加await。我第一次写的时候就漏了await,导致拿到了一个Promise对象,下载下来的文件只有几百字节,直接提示损坏。

另一个坑是文件名后缀带了.xlsx但文件内容还是旧格式。检查代码里link.download是否是.xlsx结尾,Blob的type是否正确。如果用了动态导入,确认ExcelJS已经加载完成再执行后续逻辑。

5.2 中文文件名乱码

浏览器下载时,中文文件名一般没问题,但如果你要在HTTP场景里通过响应头设置Content-Disposition,后端需要做URL编码。纯前端方案下,我只遇到过Windows下老版本Edge偶发乱码,解决方案就是给下载名做个兼容处理:

const fileName = `报表_${Date.now()}.xlsx`; link.download = fileName;

如果是异常严格的业务场景,可以用encodeURIComponent(fileName),但大多数现代浏览器反而会显示成utf8编码后的字符串,我更推荐直接用中文。

5.3 导出的日期字段显示成数字

前面说过了,必须设置numFmt。还有一种情况:数据源里的日期是dayjs对象,我习惯在构建workbook前统一转成原生Date或字符串,避免exceljs解析出奇怪结果。

5.4 打印预览里表格宽度超出页面

表格列太多时,打印预览会左右截断。解决方法是给打印样式加一个缩放:

@media print { .print-area { transform: scale(0.8); transform-origin: top left; } }

或者设置表格为自适应宽度,但不要用百分比太激进,因为单元格内容过长会被挤压。更实用的方案是让用户打印前选择横向,或者在页面上提示“建议横向打印”。

5.5 表格样式对不齐,导出后和预览不同

这是个概念性问题:预览是HTML表格,用的是CSS;导出是Excel,用的是exceljs的样式API。这两者永远不可能100%一致。所以我的做法是:明确告诉业务方,预览只代表数据正确,字体、边框、颜色是导出模板里固定的一套。如果业务方要求“导出和预览一模一样”,那建议连预览也用canvas画,但那样成本高,不如接受当前的差异。

6. 个人实操体会与扩展建议

这个项目做完之后,最大的体会是不要把exceljs当成“表格渲染器”,它更准确的角色是“xlsx文件生成引擎”。页面里的实时预览、筛选、编辑,交给HTML table才是正道。打印也是浏览器的事,exceljs帮不上忙,但如果你要生成一份带样式的Excel模板给别人下载,exceljs就是首选。

最后再分享一个小技巧:我通常会把“构建workbook”和“触发下载”拆成两个函数。这样单元测试可以直接测buildExcelWorkbook,断言workbook的sheet数量、行数、单元格值,不用真的触发浏览器下载。如果你接的是自动化测试框架,这个拆分能省很多事。

这个扩展方向上,你还可以加两个免费功能:支持多sheet导出、支持tableData里的超链接和图片。多sheet就是多addWorksheet,图片用worksheet.addImage,但图片导出对图片格式有限制,建议只支持png/jpeg。等哪天产品经理再提报表需求,你知道该往哪个方向加了。

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

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

立即咨询