ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vue3后台系统打印与PDF导出实战:html2canvas+jsPDF实现指定区域导出

Vue3后台系统打印与PDF导出实战:html2canvas+jsPDF实现指定区域导出 1. 需求拆解与方案选型1.1 这个需求到底在解决什么问题做Vue3后台管理系统的朋友应该都有体会业务方最常提的需求里排在“导出Excel”后面的就是“打印”和“导出PDF”。而且不是简单地整页打印通常是指定某个区域——比如一张订单详情卡片、一份合同模板、一个统计报表区块——要能干净地打印出来或者生成一个PDF文件发给别人。我在实际项目里接到过几次类似需求第一次踩了不少坑。当时想得比较简单以为用window.print()一行代码就能搞定结果打印出来的是整个页面侧边栏、顶部导航、按钮全都印在了纸上内容还被截断。后来才意识到这里有两个独立的问题要分别解决一是“打印预览”本质上是在浏览器原生的打印对话框里对内容做样式裁剪二是“PDF导出”是把DOM区域转成图片或者矢量内容再封装成PDF文件。两者实现路径完全不同。如果你也在做Vue3项目并且正好需要类似能力这篇文章会把两种方案的实现细节、代码示例和坑点都讲清楚。适合有一定Vue3基础、但没怎么碰过打印和导出需求的同学当然老手也可以直接跳到第5节看问题排查部分。1.2 打印预览与PDF导出的两条技术路线先说打印预览。目前主流的做法没有太多花里胡哨的选项就是利用浏览器自带的window.print()方法辅以media printCSS样式来隐藏非打印区域的元素。用户点击按钮后浏览器会弹出打印预览窗口用户可以选择打印机、调整纸张方向、边距等。整个过程其实是“借用”浏览器的原生能力。但这里有个分工问题预览是浏览器干的活如何让它在预览里只显示你指定的区域则是我们的CSS和DOM结构需要配合完成的。最常见的方案是给需要打印的区域加一个特定的class或者id在打印样式中用display: none把所有非目标区域的元素隐藏掉只保留目标区域。听起来简单实际操作起来有不少细节比如弹窗、遮罩层、表格样式、分页位置都需要单独处理。再说PDF导出。虽然浏览器打印窗口也提供“另存为PDF”的选项很多用户也确实会这么用但问题在于这种方式生成的PDF质量不稳定——页边距、纸张大小设置都在浏览器手里你控制不了。真正的“PDF导出按钮”一般有两种技术路线基于html2canvas把DOM区域截成图片再用jsPDF把图片按页插入PDF文件。这是国内项目最常用的方案实现快、视觉效果接近截图缺点是生成的PDF文字不可选中本质上是图片PDF。基于pdfmake、react-pdf这类库通过数据驱动重新构建PDF文档结构。这种方式生成的是文本型PDF体积小、文字可选但需要把DOM内容“翻译”成PDF库能识别的文档模型对于复杂表格、动态样式的页面来说成本很高。1.3 为什么我推荐“混合方案”先亮出我的结论打印预览用原生浏览器方案PDF导出用html2canvas jsPDF方案。短期需求选一条路能应付但如果你预见到后续会有更复杂的打印需求比如分页、页眉页脚、指定纸张尺寸混合方案才扛得住。为什么不用打印窗口直接导PDF我遇到的情况是业务方反馈“在公司电脑上另存为PDF和在自己电脑上另存为PDF效果不一样”这确实是原生打印的一个痛点——它依赖浏览器的设置。而html2canvas jsPDF的方案能把DOM转成固定尺寸的图片再按A4比例分页输出结果是确定的、可控的。有一点要提前说明这种方式生成的PDF是图片格式文字内容不可搜索复制。如果业务方要求文字可以选中需要另走数据驱动方案那通常是项目立项早期就要考虑的不建议中途切换。从开发成本来看混合方案的核心代码量其实不大打印预览只需要处理打印样式PDF导出只需要写一个封装函数两者加起来不超过200行核心代码。下面我会把完整实现过程逐步拆开讲。2. 前置准备与依赖安装2.1 环境与工具选型本文示例基于Vue 3 Vite element-plus的项目结构。之所以提element-plus是因为后台管理系统用它的比例很高弹窗、表格、表单这些组件在打印场景下都有一些特定表现后面讲坑点时会涉及。如果你用的是其它UI库或者干脆不用UI库实现思路完全一样只是选择器要跟着变。需要安装两个npm包html2canvas和jspdf。老项目也可以用vue-print-nb这类封装好的打印库但我个人不太建议——打印需求往往定制化很强封装库在样式控制上比较死板出了问题也难排查。自己写一个打印样式和导入函数可控性是最高的。npm install html2canvas jspdf这里提醒一句html2canvas安装后默认引入的是打包版本如果你在Vite项目中遇到模块解析相关的报错可以改用import html2canvas from html2canvas如果还有问题检查vite.config.js里是否有特殊配置。我在一个老项目里碰到过html2canvas is not a function的报错最后是清空了node_modules重新安装才解决大概率是依赖缓存损坏。2.2 按需引入与基础封装核心功能建议统一封装成一个工具模块比如在src/utils/print.js里维护打印相关逻辑在src/utils/pdf.js里维护导出逻辑。不要散落在各个页面组件里否则后续加功能、改样式会比较痛苦。先看打印工具的基础结构// src/utils/print.js export function printElement(element) { if (!element) { console.error(未找到需要打印的DOM元素); return; } // 触发浏览器原生打印 window.print(); }打印本身的代码就是这么简单真正的功夫在CSS上。不过既然做了封装我们可以做得更精细一点比如支持传入一个标题、在打印前做额外处理。完整版本会在第3节给出。再看PDF工具的基础结构。这个就需要认真规划了因为涉及到异步入库、图片等待加载、分页计算等问题// src/utils/pdf.js import html2canvas from html2canvas; import jsPDF from jspdf; export async function exportAreaToPDF(element, fileName 导出.pdf) { if (!element) { console.error(未找到需要导出的DOM元素); return; } // 1. 把DOM转成canvas // 2. 计算分页 // 3. 逐个写入PDF }先到这里第4节会把完整实现展开。现在我们应该先把打印样式做好因为无论是打印预览还是后续的PDF导出样式的稳定性直接影响结果质量。3. 核心实现指定区域打印预览3.1 标记目标区域与外部辅助DOM打印预览的第一步是让浏览器知道“你要打哪个区域”。我习惯的做法是给目标区域加一个id或者固定的class比如template div classpage-container !-- 非打印区域 -- div classprint-hide el-button typeprimary clickhandlePrint打印当前卡片/el-button el-button typeprimary clickhandleExportPDF导出PDF/el-button /div !-- 目标打印区域 -- div idprintArea classprint-area h2订单详情/h2 !-- 表格、信息卡片等业务内容 -- /div /div /template这里已经用了一个关键约定print-hide这个class会在打印样式里统一隐藏。实际操作中业务页面上往往不止顶部按钮需要隐藏还有侧边栏、导航栏、页脚、弹窗遮罩等。与其在打印样式里一个个写选择器不如约定一套固定的隐藏规则。我的经验是写打印样式时不要直接依赖element-plus内部的复杂结构最好在业务组件里就给需要隐藏的元素加上print-hide标记在需要打印的根节点上加print-area标记。堆砌一串.sidebar、.navbar、.footer { display: none }这种选择器看起来很高效但一旦组件结构变化样式就会失效。3.2 打印样式表的编写与作用域控制在Vue3项目中打印样式有两种安放位置。如果整个项目只有少数几个页面需要打印可以直接写在对应组件的style里如果很多地方都要用建议抽成全局print.css在main.js里引入。完整的一套打印样式模板如下media print { /* 隐藏非打印区域 */ .print-hide { display: none !important; } body.printing { /* 隐藏页面其他区块 */ } /* 目标区域基础样式重置 */ #printArea { position: absolute; left: 0; top: 0; width: 100%; margin: 0; padding: 0; box-shadow: none !important; } /* 避免表格内容被截断 */ .print-area table { break-inside: avoid; } /* 避免标题和表格分离 */ .print-area h2, .print-area h3, .print-area tr { break-inside: avoid; } /* 背景色打印支持 */ * { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }有三个细节需要重点说明。第一-webkit-print-color-adjust: exact是很多同学容易忽略的。默认情况下浏览器为了省墨打印时会自动移除背景色和背景图导致表格的表头行、状态标签颜色消失。加上这个属性才能保留背景颜色。第二break-inside: avoid是控制分页的。如果你发现表格的某一行被从中间劈开上半页半行下半页半行就是没有加这个属性。但要注意break-inside: avoid加多了也有问题——当表格行数太多无法在一页放下时浏览器会强制分页并可能出现大片空白。所以一般只对tr和标题元素加不对整个大表格加。第三position: absolute和left: 0; top: 0是为了解决一个经典问题——打印时页面滚动位置不同导致打印区域出现在页面的某个奇怪偏移位置。把目标区域绝对定位到页面左上角可以保证打印内容从第一页顶部开始。3.3 触发打印的完整函数样式准备好了触发函数也需要做得更健壮一点。我封装的完整版本是这样的// src/utils/print.js export function printElement(printId, options {}) { const element document.getElementById(printId); if (!element) { console.error(未找到ID为 ${printId} 的打印区域); return; } // 保存当前页面的body类名结束打印后恢复 const bodyClass document.body.className; // 标记当前正在打印一些全局样式可能需要做对应调整 document.body.classList.add(printing); // 延迟执行确保样式生效 setTimeout(() { window.print(); // 恢复body状态 document.body.className bodyClass; }, 100); }这里有一个我踩过的坑window.print()会阻塞JavaScript线程但不会等待所有样式和图片加载完成。如果你在打印按钮点击事件里直接调用而目标区域里有一张还没加载完的远程图片打印预览里就会出现图片缺位。稳妥的做法是先用await确保图片加载完成或者至少给setTimeout一个合理的延迟。实践中100ms通常不够——如果你页面里有较多异步加载的资源建议用300ms左右或者在图片的onload事件后再触发打印。3.4 打印预览的体验细节优化如果说上述内容是“能用”那么接下来这些细节是“好用”。第一打印标题。浏览器打印对话框里的文档标题默认取document.title你可以临时改成业务名称打印结束后再恢复const originalTitle document.title; document.title 订单详情打印; window.print(); document.title originalTitle;第二分页内容避免孤立。比如一个卡片正好跨在两页之间样式上应该让整个卡片尽量保持完整。除了break-inside: avoid还可以用page-break-after: always强制在某些节点后分页比如一个报表的每个章节之间。这个在业务上非常有用客户可能希望每个业务员的数据单独一页方便分发。第三打印时隐藏滚动条和固定定位元素。后台系统页面可能有回到顶部的悬浮按钮、消息弹窗等这些固定定位元素在打印时可能被重复打印到每一页。通用的处理法是media print { .el-popper, .fixed-toolbar, .back-top { display: none !important; } }如果项目里弹层比较多最好的办法还是在打开弹层的时候给body加一个类名在打印时统一隐藏。4. 核心实现PDF导出功能4.1 html2canvas的原理与局限讲PDF导出的实现之前先花点时间说清楚html2canvas的工作原理。它并不是“截屏”——它不是从屏幕层面做截图而是“重新绘制”DOM。html2canvas会遍历目标DOM节点读取节点的样式、位置、尺寸等信息然后通过Canvas API把元素逐个绘制到canvas上。这意味着它识别的是浏览器计算出来的样式值对于超出它能力范围的CSS特性绘制结果可能和实际渲染不一致。常见的局限有两个一是box-shadow可能会丢失或者表现为实心阴影二是某些渐变背景可能绘制不全。另外一个很重要的点html2canvas不支持跨域图片。如果目标区域里有跨域图片canvas会被污染导出的PDF里图片位置可能是空白。解决办法是给图片加crossoriginanonymous属性同时后端服务器需要返回正确的CORS响应头。理解这些局限对排查问题很有帮助。比如你发现导出的PDF里某个图标的背景颜色不对不要怀疑代码写错先去确认这个样式是否被html2canvas支持。4.2 单页场景的PDF导出实现先做一个最基础的版本——目标区域内容较少一页A4就能放完。这种场景网上代码很多但很多写得不够严谨。我整理了一个可以直接用的版本// src/utils/pdf.js import html2canvas from html2canvas; import jsPDF from jspdf; export async function exportElementToPDF(element, fileName 导出.pdf) { // 1. 确保页面中的图片都加载完成 await ensureImagesLoaded(element); // 2. 使用较高缩放比生成canvas保证清晰度 const canvas await html2canvas(element, { scale: 2, // 2倍缩放防止导出模糊 useCORS: true, // 允许跨域图片 backgroundColor: #ffffff, // 背景色统一设置为白色 logging: false, // 关闭日志避免控制台太多输出 }); // 3. 初始化A4竖版PDF单位为mm const pdf new jsPDF(p, mm, a4); const pageWidth pdf.internal.pageSize.getWidth(); // 约210mm const pageHeight pdf.internal.pageSize.getHeight(); // 约297mm // 4. 计算图片等比缩放后的实际尺寸 const imgWidth pageWidth; const imgHeight (canvas.height * imgWidth) / canvas.width; // 5. 转成图片数据 const imgData canvas.toDataURL(image/jpeg, 0.95); // 6. 如果内容不超过一页直接添加 if (imgHeight pageHeight) { pdf.addImage(imgData, JPEG, 0, 0, imgWidth, imgHeight); } else { // 多页处理见下一节 } // 7. 保存文件 pdf.save(fileName); }scale: 2这个参数很关键。html2canvas默认按1倍像素生成canvas但在高分屏上1倍的canvas导出到PDF后会明显发虚。特别是文字边缘会糊。经过我多次测试scale: 2是比较合适的值清晰度和性能之间的平衡性最好。如果你导出的内容是大尺寸广告图可以试scale: 3但生成速度会明显变慢。图片加载的辅助函数是这样的async function ensureImagesLoaded(element) { const images element.querySelectorAll(img); const tasks Array.from(images).map((img) { if (img.complete img.naturalWidth 0) return Promise.resolve(); return new Promise((resolve, reject) { img.onload resolve; img.onerror reject; }); }); await Promise.all(tasks).catch(() { console.warn(部分图片加载失败可能影响导出效果); }); }注意这里的容错处理Promise.all直接reject会导致整个导出中断我选择捕获错误并提示而不是让导出功能直接崩溃。线上环境一个图片挂了导致无法导出这个体验是很差的。4.3 分页导出的核心算法固定高度切片法内容超过一页的时候很多刚入门的朋友会直接用一个错误的做法——把整个目标区域生成一张超长的canvas然后pdf.addImage时手动分成多页每页加一行代码。这样做看起来能导出但实际效果是一张长图被硬生生“切”成几段中间分页处的内容会被裁断信息丢失。正确的思路是固定高度切片先把目标区域克隆一份把克隆体放在一个固定宽度的容器里然后按A4页面高度对应的像素高度把克隆体切成多个片段。每个片段分别调用html2canvas生成独立canvas再一页一页写入PDF。这样每一页的内容都是完整的、独立的。关键在于两个数值的换算。A4纸在jsPDF中的单位是mm宽210、高297。我们要决定每页对应的像素高度。我通常先确定一个目标宽度比如900像素然后按比例计算高度// 目标区域宽度像素需要预先设定 const targetWidth 900; // A4比例换算 const pageHeight 297; // mm const pageWidth 210; // mm // 每页对应的像素高度 const targetHeight (pageHeight * targetWidth) / pageWidth;得到targetHeight之后克隆目标区域往容器里塞然后把容器切分成Math.ceil(cloneHeight / targetHeight)个片段每个片段裁剪出对应高度的内容。完整的实现我放在下面这段代码在项目里可以直接复制使用export async function exportElementToPDF(element, fileName 导出.pdf) { // 初始化PDF const pdf new jsPDF(p, mm, a4); const pageWidth pdf.internal.pageSize.getWidth(); const pageHeight pdf.internal.pageSize.getHeight(); // 像素换算比例按目标宽度换算 const targetWidth 900; // 根据需要调整 const targetHeight (pageHeight * targetWidth) / pageWidth; // 克隆目标节点 const clone element.cloneNode(true); // 确保克隆体的样式和原节点一致否则可能渲染错位 const originalStyle window.getComputedStyle(element); clone.style.width ${targetWidth}px; clone.style.position absolute; clone.style.left -9999px; clone.style.top 0; clone.style.transform none; // 把克隆体挂到页面里但不可见 document.body.appendChild(clone); const canvas await html2canvas(clone, { scale: 2, useCORS: true, backgroundColor: #ffffff, logging: false, }); const imgData canvas.toDataURL(image/jpeg, 0.95); const totalPages Math.ceil(clone.scrollHeight / targetHeight); for (let pageIndex 0; pageIndex totalPages; pageIndex) { // 计算当前页的裁剪区域 const srcY pageIndex * targetHeight; const srcH Math.min(targetHeight, clone.scrollHeight - srcY); // 从大canvas中截取当前页的内容 const pageCanvas document.createElement(canvas); pageCanvas.width canvas.width; pageCanvas.height (srcH * canvas.width) / targetWidth; const ctx pageCanvas.getContext(2d); ctx.drawImage( canvas, 0, srcY * (canvas.width / targetWidth), // 源裁剪起点注意缩放比例 canvas.width, srcH * (canvas.width / targetWidth), // 源裁剪尺寸 0, 0, pageCanvas.width, pageCanvas.height ); const pageImgData pageCanvas.toDataURL(image/jpeg, 0.95); if (pageIndex 0) { pdf.addPage(); } const imgWidth pageWidth; const imgHeight (pageCanvas.height * imgWidth) / pageCanvas.width; pdf.addImage(pageImgData, JPEG, 0, 0, imgWidth, imgHeight); } // 移除克隆体 document.body.removeChild(clone); pdf.save(fileName); }这里有个细节必须注意html2canvas生成的canvas宽度是targetWidth * scale源canvas中的每个像素对应原始DOM中的尺寸是scale倍。所以在用drawImage裁剪时裁剪的起始Y坐标和高度都要乘上scale值即canvas.width / targetWidth否则截出来的位置会偏移。另一个细节是克隆体的样式。如果直接克隆原节点而不带position: absolute和left: -9999px克隆体出现在页面视口中会影响布局甚至导致样式计算异常。有的同学会问隐藏元素html2canvas能画出来吗这里没有用display: none而是放到视口之外html2canvas是可以正常绘制的。如果你用了display: nonecanvas里会是空白这是html2canvas的一个已知限制。4.4 导出清晰度与文件体积的调优导出清晰度和文件体积是一对矛盾。默认情况下html2canvas以1倍比例绘制导出PDF在屏幕上放大看会有比较明显的锯齿。我建议按以下原则调整一般业务场景用scale: 2即可文字清晰体积适中。如果目标区域包含大量细表格线建议scale: 3否则表格线容易出现断线。toDataURL(image/jpeg, 0.95)的0.95是一个经验值数值太大比如0.98文件体积会飙升但清晰度提升肉眼几乎看不出来。如果导出的内容以文字为主可以保持image/jpeg因为白色背景压缩率很高如果内容有大面积颜色、渐变色可以考虑image/png避免出现细小的色彩噪点。文件体积方面我做过统计一个800px宽、3页A4的内容scale: 2导出大约300~500KBscale: 3则可能到1MB以上。如果系统要频繁导出体积需要控制scale: 2是性价比最高的配置。5. 常见问题排查与避坑指南5.1 打印样式没生效或布局错乱打印样式没生效最常见的原因是media print规则写在了Vue组件的style scoped里。scoped是Vue给DOM元素加了一个>const pageCount pdf.internal.getNumberOfPages(); for (let i 1; i pageCount; i) { pdf.setPage(i); pdf.setFontSize(10); pdf.setTextColor(150); pdf.text(第 ${i} 页 / 共 ${pageCount} 页, pageWidth / 2, pageHeight - 5, { align: center }); }注意setPage要放到addImage之后否则图片会把文字盖住。我一开始就遇到过这个问题——页码在图片下面被遮得严严实实。6.2 导出过程中的防重复点击与Loading导出是个异步过程从截取DOM到生成文件中间可能需要一秒钟左右内容多的时候可能要几秒钟。如果不加防重复点击用户连续点两次导出就会生成两个文件或者产生性能问题。我习惯的策略是在导出函数内部用一个布尔值标志或者在组件中用loading状态控制按钮。推荐后者因为还能顺便在按钮上显示加载动画提示用户体验。template el-button :loadingexporting clickhandleExport {{ exporting ? 正在导出... : 导出PDF }} /el-button /template script setup import { ref } from vue; import { exportElementToPDF } from /utils/pdf; const exporting ref(false); async function handleExport() { const el document.getElementById(printArea); if (!el) return; exporting.value true; try { await exportElementToPDF(el, 订单详情.pdf); } finally { exporting.value false; } } /scripttry/finally是必须的——即使导出失败按钮也要恢复可点击状态否则用户会被卡在loading里。6.3 大内容区域的内存优化如果你要导出的区域非常长比如一个几十页的报表单次生成一整张canvas再切分内存占用会非常高甚至浏览器崩溃。这种情况下需要分片处理按每页高度逐步移动克隆体的scrollTop分别生成小canvas而不是一次性生成大canvas。代价是遍历次数增加、速度变慢但内存稳定大型报表场景只能这样取舍。html2canvas对超高DOM也有自身的限制生成超高canvas本身就可能失败。我在一个数据大屏项目里导过超过6000px高度的区域用整图方案在部分用户电脑上直接崩溃后来改成按页分片生成才稳定下来。如果你的内容高度会超过视口的几倍优先考虑分片方案不要等线上崩了再改。7. 实测效果与经验总结拿我们实际的订单详情页举例整页包含客户信息、商品明细表格、金额汇总、备注等区域高度大约1200px导出后正好两页A4。使用scale: 2时整张PDF约400KB表格边框清晰文字缩放后无毛边。打印预览调出的对话框里页面内容也无偏移break-inside: avoid保证了商品明细的行不会被切断。在实际使用中有一个细节我建议团队约定下来统一在按钮上区分“打印”和“导出PDF”两个入口功能不要合在一个按钮里。虽然浏览器打印对话框里也有“另存为PDF”但业务方的理解是两个完全不同的操作——打印是纸张输出导出是文件输出合并入口会让业务方困惑到底选哪个。分开入口各自对应的实现也更清晰。这个方案后续还可以继续扩展比如加上水印、把Excel导出和PDF导出放在同一个工具栏里、在导出时动态追加公司Logo抬头等。原理都是相通的先锁定目标DOM再考虑样式表现最后是输出格式的转换。踩过几次坑之后你会发现打印和PDF导出的核心问题从来不是代码困难而是对“DOM在打印机/Canvas环境下表现不一致”的理解。把这条主线想透了再复杂的打印需求也能拆解成样式、坐标系、资源加载这三件事逐一搞定就好。
返回列表