资讯动态

Vue自定义页面大小导出PDF:从html2canvas到jsPDF的完整实践

发布时间:2026/10/15 3:11:57 来源:尧图企业网站定制
接手 Vue 后台项目时导出 PDF 是躲不开的活儿。尤其当需求里带上“自定义页面大小”这几个字——标签纸、吊牌、工程报表、小票——很多人第一反应就直接上“截图方案”。我早期也这么干直到被测试拿着 40mm×30mm 的标签纸找上门浏览器打印按钮根本不认这种非标尺寸预览页面里纸张永远走 A4客户那边打印机吐出来的东西全错位。后来我把相关实现重新捋了一遍才发现“vue导出pdf(自定义页面大小)”这个话题真正讲清楚至少要回答三件事页面内容到底用什么方式进 PDF页面尺寸的参数怎么给以及超长内容切多页时怎么避免把数据切烂。这篇文章就是把我在项目里跑通的一整条链路拆开讲适合正在做打印、报表、标签导出功能的 Vue 开发者参考。内容顺着实际操作来代码都能直接拿回去改。1. 导出 PDF 前先回答一个问题你要“截图”还是要“文档”很多人踩坑的第一个原因不是代码写错而是没想清楚自己要的是“图片型 PDF”还是“文本型 PDF”。这两个方向底下是两套完全不同的技术栈后面几乎所有优化都受这个选择影响。1.1 截图派html2canvas 加 jsPDFVue 社区里最经典的做法是先用 html2canvas 把页面 DOM 画成 canvas再把 canvas 转成图片塞进 jsPDF。好处非常直观页面长什么样PDF 里就是什么样颜色、布局、图表状态几乎不需要二次处理适合仪表盘、图表报告、复杂卡片这类很难用代码逐行描述的内容。代价也很清楚。第一PDF 里装的是一整张图片文字不能复制、不能被搜索对“需要检索文件内容”的场景很不友好。第二文件体积偏大如果页面里还有高清大图导出一个十页的 PDF体积可能冲到十几兆。第三html2canvas 在处理跨域图片、CSS 滤镜、部分现代布局时还会出各种幺蛾子后面我专门列一节讲。1.2 数据派pdfmake 或 jsPDF 的文本绘制如果你要导出的内容是表格、单据、条款列表这类结构规整的数据我更推荐“数据派”不截页面而是把数据一行一行画进 PDF。比如 pdfmake它支持直接声明页面大小和行列结构样式由代码控制生成的 PDF 是真正的矢量文本放大不糊、文字可选中、体积很小。jsPDF 自身也支持doc.text()、doc.rect()这类绘图能力配合splitTextToSize还能做自动换行。缺点是对“现有页面样式”的还原能力很差你没法直接把一个写好的 Vue 组件扔进去必须按数据结构重写一套模板。1.3 还有一个被忽略的打印派浏览器自带的打印功能配合 CSSpage其实也算一种导出方案。通过window.print()在 CSS 里声明目标纸张尺寸用户可以直接把打印预览另存为 PDF。这套方案适合标签机、小票机、快递单这些“纸张尺寸固定、样式以黑白为主”的场景优势是零依赖、质量稳定。但它依赖浏览器打印对话框用户必须手动点击“另存为 PDF”而且不同浏览器对page的支持程度不一样不能完全按代码期望输出。所以我一般把它作为补充方案而不是主方案。三类方案对“自定义页面大小”的支持差异很大我直接用一张表对比。方案自定义页面尺寸视觉还原度文字可选中文件体积最佳场景html2canvas jsPDF可精确控制但要做像素换算高否大图表报告、复杂页面快照pdfmake / jsPDF 文本可精确控制按 pt 或自定义格式中是小表格、单据、票据打印window.print page受浏览器打印支持限制高是中标签纸、小票、固定纸型我的建议是如果需求描述里带“就是把这个页面原样打出来”那走截图派如果带“把这张表导出来”先想数据派如果带“客户要拿去直接打印标签”打印派更稳。接下来所有代码都围绕截图派展开因为自定义页面大小这个需求里它的参数坑最多。2. 自定义页面大小从单位换算到 format 数组的完整链路jsPDF 里创建一个自定义页面核心代码其实就三行const pdf new jsPDF({ unit: mm, format: [80, 50], }); const pageWidth pdf.internal.pageSize.getWidth(); // 80 const pageHeight pdf.internal.pageSize.getHeight(); // 50unit是单位format是宽高数组数组的顺序跟你传进去的宽高一致。很多人会忽略的细节是unit默认值是pt也就是磅而不是毫米。如果你在网上抄了一段代码里面直接写format: [80, 50]人家默认单位可能是mm你这里按pt理解页面尺寸就完全不一样了。单位换算关系是固定的1 英寸 25.4mm 72pt。所以 1mm 大约等于 2.8346pt。如果资料里把单位写成pt你需要先换算成目标单位再用。比如一个 80mm×50mm 的标签对应 pt 就是约 227×142。项目里我一般统一用mm因为需求方给尺寸时几乎都是说“几厘米几毫米”很少说“多少磅”。2.1 为什么不能把 html2canvas 原图直接塞进页面单个页面的情况很多人写完第一步就直接这么干const canvas await html2canvas(el, { scale: 2 }); const imageData canvas.toDataURL(image/jpeg, 0.95); pdf.addImage(imageData, JPEG, 0, 0, pageWidth, pageHeight);这行代码在页面比例和 canvas 比例一致时没问题但比例不一致时addImage会把整张截图强行拉伸到整页导致内容变形。标签纸、吊牌这类非标尺寸下这种变形特别容易被发现。正确做法是先算出一个关键换算系数每像素对应多少毫米。const mmPerPx pageWidth / canvas.width;有了这个系数就能把 canvas 的像素高度换算成 PDF 里的毫米高度const imageHeightMm canvas.height * mmPerPx; pdf.addImage(imageData, JPEG, 0, 0, pageWidth, imageHeightMm);这样写入 PDF 的图片比例和原始页面完全一致即使最后一张没有铺满整页也至少不会变形。注意这里pageWidth是 80mmcanvas.width是在 scale2 下生成的像素宽度所以mmPerPx已经包含了放大倍数不用再额外除以window.devicePixelRatio。这个换算关系是整个“自定义页面大小”方案里最关键的一步后面分页切割也要用它。2.2 不同业务尺寸的配置参考自定义页面大小最常见的几个尺寸我列一下参考值方便刚上手的人直接填纸张场景宽高配置适用单位热敏标签 40×30format: [40, 30]mm常见标签 80×50format: [80, 50]mm吊牌 60×120format: [60, 120]mm工程报表 300×200format: [300, 200]mm如果你的需求里给的尺寸是“宽 8 厘米、高 5 厘米”换算成毫米就是[80, 50]不要想当然把厘米直接传给 jsPDF传过去会变成一块二三十倍的超大页面。3. 长页面切割成多页的分页算法不让一行被腰斩自定义页面尺寸还有一个隐藏问题页面很短但内容很高。比如标签纸只有 50mm 高但页面上要打印的内容撑了 300px 甚至 500px直接截图放进去会超出页面边界PDF 只会显示第一页剩下的全部丢失。所以必须做分页切割。3.1 整个 DOM 先截成一张长图再按页高等分我实测下来比较稳的思路是先用 html2canvas 一次性抓下整个页面高度然后根据自定义页面高度计算出每一页对应多少像素循环切割。async function exportLongHtmlAsPdf(el, { widthMm 80, heightMm 50, fileName export.pdf }) { const { jsPDF } await import(jspdf); const html2canvas (await import(html2canvas)).default; await document.fonts.ready; const canvas await html2canvas(el, { scale: 2, useCORS: true, backgroundColor: #ffffff, windowWidth: el.scrollWidth, windowHeight: el.scrollHeight, scrollY: 0, }); const pdf new jsPDF({ unit: mm, format: [widthMm, heightMm] }); const pageWidth pdf.internal.pageSize.getWidth(); const pageHeight pdf.internal.pageSize.getHeight(); const mmPerPx pageWidth / canvas.width; const slicePx Math.floor(pageHeight / mmPerPx); const pageCount Math.ceil(canvas.height / slicePx); for (let page 0; page pageCount; page) { if (page 0) pdf.addPage([widthMm, heightMm]); const startY page * slicePx; const currentHeight Math.min(slicePx, Math.floor(canvas.height - startY)); const sliceCanvas document.createElement(canvas); sliceCanvas.width canvas.width; sliceCanvas.height currentHeight; const sliceCtx sliceCanvas.getContext(2d); sliceCtx.drawImage(canvas, 0, startY, canvas.width, currentHeight, 0, 0, canvas.width, currentHeight); const imageData sliceCanvas.toDataURL(image/jpeg, 0.95); const imageHeightMm currentHeight * mmPerPx; pdf.addImage(imageData, JPEG, 0, 0, pageWidth, imageHeightMm); sliceCanvas.width 0; sliceCanvas.height 0; } pdf.save(fileName); canvas.width 0; canvas.height 0; }这段代码里几个细节值得解释第一slicePx是每一页在 canvas 像素空间里能装下的高度它由页面高度除以mmPerPx得到保证每个分块和 PDF 页面宽高比保持一致。第二pageCount用Math.ceil计算最后一块内容可能不满一页这是正常的不要用Math.floor否则会丢内容。第三每次把sliceCanvas的宽高清成 0是为了尽早释放 canvas 占用的内存。页面特别长、分页特别多的时候这一步能明显减轻浏览器卡顿。3.2 更精密的分页让分页边界自动避开表格行按固定高度切片有一个隐患分页线可能正好压在一行文字中间视觉上数据被“腰斩”。标签和单据上这种情况非常扎眼。如果你抓的是表格类内容可以在分页前先拿到每一行的位置把分页点尽量往上吸到表格行的边界上。核心思路是如果切片边界落在某一行内部就把边界移到这一行开始的位置。function getSafeBreakPositions(el, slicePx) { const rows el.querySelectorAll(tr, .card, .row-item); const positions [0]; let cursor 0; rows.forEach((row) { const top row.offsetTop - el.offsetTop; const bottom top row.offsetHeight; if (bottom cursor slicePx top cursor) { cursor top; positions.push(cursor); } }); positions.push(el.offsetHeight); return positions; }拿到这些安全分页点之后再按positions数组去切 canvas而不是用固定slicePx。这样的分页结果会整齐很多。实现里要注意offsetTop是相对最近定位父级的如果元素本身嵌套很深需要用循环累加或者getBoundingClientRect()换算。这个属于锦上添花的优化不是必须但如果客户对单据格式很挑剔建议加上。4. 页面内容复杂时怎么办图表、长表格和动态中文字体的坑把基本导出跑通之后真实项目里大概率会遇到三类内容图表、跨页表格、加载不完整的字体。它们的坑不在 PDF 库本身而在 html2canvas 对页面状态的还原能力。4.1 图表被截成空白或变形很多 Vue 项目里的图表是 canvas 绘制的比如常见的大屏统计图。html2canvas 在 clone 页面时原始 canvas 上的内容不一定能被完整读取截出来经常是一块空白。我实测下来最简单的处理方式是截图前强制等待两帧让图表重新绘制并且进入稳定的渲染状态await new Promise((resolve) { requestAnimationFrame(() requestAnimationFrame(resolve)); });如果等待之后还是空白可以换个思路把图表实例单独导出成图片再用绝对定位临时放到要截图的区域里最后由 html2canvas 统一抓取。这种方式控制力最强缺点是临时插入 DOM 会影响页面布局截完图要记得清理。注意用 canvas 绘制的图表导出前尽量不要让它处于未初始化状态。尤其页面初始化时就拿到图表实例但从未激活过的情况html2canvas 截到的 canvas 很可能是一个 0 尺寸画布。4.2 长表格跨页时不能硬切表格是导出场景里的重灾区。固定像素切割很容易把某一行的上半部分留在上一页、下半部分塞进下一页。我的建议是表格内容优先用上一节的getSafeBreakPositions做分页如果数据量非常大可以直接考虑放弃 html2canvas改用 pdfmake 这种让表格自带分页逻辑的数据派方案。pdfmake 的表格导出本身就是按行分页的它天然知道一行不能被切开遇到跨页会自动整行挪到下一页。自定义页面尺寸也很简单把毫米换算成 pt 后传给pageSize即可const content { pageSize: { width: 80 * 2.8346, height: 50 * 2.8346, }, content: [ { table: { body: rows, }, layout: lightHorizontalLines, }, ], };这种方案生成的文件是真正的矢量文本拿到手机上放大也清晰。代价是表格样式要用 pdfmake 自己的格式重写一遍没法直接用 Element UI 或 Ant Design 的表格类名。项目里如果已经用成熟组件库把表格做得很花哨重写成本会很高所以要提前评估。4.3 中文和字体加载问题html2canvas 截图时如果页面里的自定义字体还没加载完截出来会用 fallback 字体替代导致行高、字距变化进而让整体内容错位。我一般会在截图前等document.fonts.ready这个方法能等当前页面的 Web Font 加载完成await document.fonts.ready;另外网页里如果用了font-family: PingFang SC, Microsoft YaHei在 canvas 截图里通常没问题但如果你用 jsPDF 的doc.text()画中文就绕不开中文字体嵌入的问题否则文本会变成乱码。纯文本方案想输出中文需要给 jsPDF 准备一个字体文件并把字符映射进去这一步工作量不小。4.4 图片加载和跨域页面里如果有一堆异步加载的图片直接截图会抓到大片空白占位符。稳妥做法是先让每张图片完整 decodeawait Promise.all( Array.from(el.querySelectorAll(img)).map((img) img.decode().catch(() {}) ) );跨域图片就交给useCORS: true并确保图片所在静态资源服务器返回了正确的 CORS 响应头。如果某个图片无论如何都截不出来最后的兜底做法是把图片先转成 base64用 JS 临时替换src再截图。还有一个很容易踩的坑如果导出按钮的 loading 动画是一层绝对定位浮层而且它覆盖在导出区域上方html2canvas 可能会把 loading 文案也截进 PDF 里。我一般在截图前先把这两个区域隔离或者延迟调用截图等 loading 层关闭后再执行。5. 封装成 Vue 异步导出函数按需加载、Loading 状态和文件命名在 Vue 项目里我不建议把 html2canvas 和 jsPDF 直接放进入口依赖它们两个加起来体积不小会影响首屏加载。更合理的做法是写一个组合式函数在用户点击导出时才动态加载这两个库。5.1 一个可直接复用的 usePdfExport下面这个组合式函数可以直接放到src/composables/usePdfExport.js里import { ref } from vue; export function usePdfExport() { const exporting ref(false); async function exportHtmlToPdf(el, options {}) { const { widthMm 210, heightMm 297, fileName export.pdf, onProgress, } options; if (!el || exporting.value) return; exporting.value true; try { const { jsPDF } await import(jspdf); const html2canvas (await import(html2canvas)).default; await document.fonts.ready; const canvas await html2canvas(el, { scale: 2, useCORS: true, backgroundColor: #ffffff, windowWidth: el.scrollWidth, windowHeight: el.scrollHeight, scrollY: 0, }); const pdf new jsPDF({ unit: mm, format: [widthMm, heightMm] }); const pageWidth pdf.internal.pageSize.getWidth(); const pageHeight pdf.internal.pageSize.getHeight(); const mmPerPx pageWidth / canvas.width; const slicePx Math.floor(pageHeight / mmPerPx); const pageCount Math.ceil(canvas.height / slicePx); for (let page 0; page pageCount; page) { if (page 0) pdf.addPage([widthMm, heightMm]); const startY page * slicePx; const currentHeight Math.min(slicePx, Math.floor(canvas.height - startY)); const sliceCanvas document.createElement(canvas); sliceCanvas.width canvas.width; sliceCanvas.height currentHeight; const ctx sliceCanvas.getContext(2d); ctx.drawImage( canvas, 0, startY, canvas.width, currentHeight, 0, 0, canvas.width, currentHeight ); const imageData sliceCanvas.toDataURL(image/jpeg, 0.95); pdf.addImage( imageData, JPEG, 0, 0, pageWidth, currentHeight * mmPerPx ); sliceCanvas.width 0; sliceCanvas.height 0; onProgress?.(Math.round(((page 1) / pageCount) * 100)); } pdf.save(fileName); canvas.width 0; canvas.height 0; } finally { exporting.value false; } } return { exporting, exportHtmlToPdf, }; }这里的fileName直接传给pdf.save()就行。如果文件名里带中文jsPDF 默认会用 UTF-8 编码大多数操作系统能正常打开个别老版本 PDF 阅读器可能显示乱码这时可以在传参前用encodeURIComponent(fileName)做一层处理或者后端生成后再下载。5.2 组件里怎么用在模板里把要导出的区域绑定成一个 ref按钮绑定导出事件script setup import { ref } from vue; import { usePdfExport } from /composables/usePdfExport; const printAreaRef ref(null); const { exporting, exportHtmlToPdf } usePdfExport(); const exportForm ref({ widthMm: 80, heightMm: 50, fileName: label-2025.pdf, }); async function handleExport() { await exportHtmlToPdf(printAreaRef.value, exportForm.value); } /script template div classprint-area refprintAreaRef !-- 标签内容、单据内容都放这里 -- /div button :disabledexporting clickhandleExport {{ exporting ? 导出中... : 导出 PDF }} /button /template从html2canvas开始到pdf.save()整个流程都比较耗时一定要把按钮 disabled 住避免用户连点导致同时导出多份文件。我在组件里还把onProgress回调透传出去这样长列表导出时可以在页面上显示“正在导出第 x / 共 y 页”体验会好很多。对于几百行表格分页切片可能生成很多 canvas中间注意用exporting做互斥保护不要并行执行。6. 如果不走截图路线标签纸、小票机和浏览器打印的配合打法如果你在项目里确认了“最终输出就是为了给客户直接打印”那自定义页面大小这件事浏览器打印其实也能做到而且效果可能更稳定。6.1 用 CSS page 声明非标纸张CSS 里可以直接声明页面尺寸。比如 80mm×50mm 的标签可以这样写media print { page { size: 80mm 50mm; margin: 0; } body { margin: 0; } .no-print { display: none !important; } }然后在 Vue 组件里调用window.print()。用户在弹出的打印预览里选择“另存为 PDF”导出文件的页面尺寸就会按 80mm×50mm 输出。Chromium 内核的浏览器对page size支持得不错这是我给标签机客户调试后的结果。这个方案最大的优点是几乎没有额外的库依赖CSS 里还能做精确到毫米的边距控制。缺点是没法完全绕过用户手动点击而且 Safari 对page尺寸的兼容性不如 Chrome。如果你的系统是内部的、用户统一用 Chrome这个方案非常省事。6.2 当需求明确要求“直接下载一个 PDF 文件”如果需求方要的是“点一下按钮浏览器直接下载 PDF”那打印预览方案就不合适。除了 html2canvas还可以考虑后端服务出文件前端只需要把一个包含数据和其他参数的请求发给服务端服务端生成 PDF 后再回传。这种方式对超大表格、复杂分页、精确自定义尺寸最友好代价是多写一个后端接口。我个人经历里的组合拳是这样前端预览先用 html2canvas 截图给用户看效果业务方拿去打印标签就用浏览器打印真正要归档留存的报表优先走数据派或者后端生成。不要试图让一套方案通吃所有“导出 PDF”需求先把使用场景定下来再决定用哪种方式实现自定义页面尺寸。这里有一个不起眼但影响很大的小技巧定义自定义尺寸前一定要先和需求方确认单位。我遇到过需求单上写着“8×5”等开发完才发现对方说的是厘米代码里却按毫米写成了 8mm×5mm最后打印出来比指甲盖还小。页面尺寸这种事情宁可多问一句也不要让整个模块返工。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑