尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Vue3+html2canvas+jspdf:解决PDF导出分页截断问题的完整方案

Vue3+html2canvas+jspdf:解决PDF导出分页截断问题的完整方案 写这个主题是因为上个月接了个Vue3后台管理系统的需求要一键把当前页面导出成PDF。需求本身不复杂但git上的历史问题全是“内容被截断了”“第二页开头被切掉了一半”看得我头皮发麻。html2canvas加jspdf的导出方案确实是目前前端最主流的组合但这个组合的默认行为是“把整个页面画成一张超长的图然后硬塞进PDF的一页里”结果就是要么内容缩得看不清要么页面一长就被砍掉一半。这篇文章我会完全围绕分页截断这件事讲清楚原因给出可以直接抄走的解决方案再把几个特别隐晦的坑一并说出来。1. 内容整体设计与思路拆解1.1 为什么会发生截断先梳理一下html2canvas加jspdf这条链路里发生了什么。html2canvas做的事情是把指定的DOM节点绘制到canvas上canvas的尺寸默认等于DOM节点的实际渲染尺寸。比如页面上有一块2000px高的内容生成的canvas就是对应的整数倍像素高度这没问题。问题出在jspdf身上。调用doc.addImage()时如果只执行一次addImage会把一张超长图片强行塞到A4纸的高度范围内。A4纸的像素尺寸按96dpi换算大约是794x1123px而页面内容经常是2000px、3000px甚至更高。jspdf的默认行为是等比缩放把整张图缩放到能放进一页文字小到没法看。有的人会想那把canvas拆成多段每段1123px高逐段addImage到不同页面不就分页了吗。于是就有了常见的粗暴做法for循环里每次截取1123px高度的canvas生成新页面。这个做法在小范围内能跑但会遇到两个致命问题。第一个问题是A4纸的尺寸换算。jspdf默认单位是mmA4是210mm x 297mm。直接把1123px当成分页高度等于假设96dpi下1px恰好对应1个pt但html2canvas生成的canvas的物理尺寸和PDF里的显示尺寸之间是有换算关系的。你不设置明确的缩放比例多页内容就会出现每页之间内容轻微错位、边缘被切掉半个字的情况。第二个问题是截断位置。内容不是永远从坐标0开始的。页面上有margin、padding顶部有header每个区块之间有间距。假如你无脑按每1123px切一刀刚好把一个段落的文字行从中间切断那导出PDF里这一页的底部就会出现半行字下一页顶部接着后半行观感极差打印出来更是没法用。1.2 正确思路先算坐标再切图片最后补页边距真正稳定可复用的方案需要分三层来设计。第一层统一坐标系。不能直接用window.innerHeight或者DOM的offsetHeight来算分页因为页面可能存在缩放、滚动条、或父级容器的transform偏移。必须基于html2canvas返回的canvas本身来做后续一切计算。canvas的高度就是内容的真实高度这个值不随外层样式干扰而变化。第二层按A4比例计算每页能容纳多少px内容。A4纸297mm高减去上下页边距比如各10mm剩下277mm可用。假设导出时的像素密度是96dpi那么1mm约等于3.78px。277mm约等于1046px而不是1123px。很多教程直接把1123当作每页高度忽略了边距导出结果每页底部都会被切掉一小条内容。第三层逐页截取canvas并写入PDF。这里要用canvas原生的drawImage方法从大图上截取区域再通过toDataURL转成图片交给jspdf添加。整个过程涉及三个关键参数每一页的起始Y坐标、截取高度、PDF页面的偏移量。这三者的计算逻辑必须完全一致否则内容会累积偏移。1.3 方案选型的对比为什么不用html2pdf.js或者printCss聊方案选型很多人会问为什么不直接用打包好的html2pdf.js库一行代码调完事。我试过它内部做了封装但遇到复杂的后台管理系统布局时并不顺手。一是因为它的分页策略同样是基于元素高度来计算遇到跨区块内容照样会切断二是它的可定制性弱想要手动控制每一页的绘制内容基本要改源码维护成本高。还有一个思路是非图片化方案完全用CSS分页配合浏览器原生打印能力导出PDF。这个方案的好处是文字清晰、PDF体积小、支持文本选择和复制。但受限于浏览器支持度部分内联样式、弹窗、canvas图表在打印时渲染不一致还要处理page-break-inside: avoid在各种浏览器下的兼容问题。如果你导出的页面本身包含很多图表和图片CSS打印方案的效果往往不如canvas截图方案稳定。html2canvas加jspdf的组合在这类动态可视化页面上优势比较明显所见即所得不管页面组件多复杂截图能拿到什么就导出什么不用额外处理打印样式。缺点就是上面的截断问题以及生成的是图片型PDF文字不可选中。两者取舍我作为后台管理系统场景的开发者更看重导出结果和页面视觉一致所以选了后者。接下来重点解决它的缺陷。2. 核心细节解析与实操要点2.1 A4尺寸与像素换算一步都不能错A4纸的标准尺寸是210mm x 297mm这个没争议。但换算成像素时必须明确一个基准html2canvas默认devicePixelRatio是1它的scale参数你可以额外设置。如果你不设置scalecanvas的每个像素对应的是CSS像素如果你设置了scale2canvas的实际像素是CSS像素的两倍分辨率和图片清晰度会上升但后续截图的坐标计算也要同步乘2否则截取的区域会错位。我建议统一用以下换算逻辑期望在PDF里每英寸显示96个CSS像素即96dpi这个数值在屏幕上看和实际打印效果比较均衡。1英寸25.4mm所以1mm96/25.4≈3.7795px。A4宽度210mm对应的像素宽度是210*3.7795≈794px。A4高度297mm对应的像素高度是297*3.7795≈1123px。但1123px是整页高度。设置页边距后上下边距各10mm则可用高度为297-20277mm约1047px。左右边距各10mm则可用宽度为210-20190mm约718px。这意味着如果页面内容宽度是800px需要缩放至718px宽才能放进可用区域。缩放比例就是718/800≈0.8975。全页高度1123px这个比例下实际可用的canvas像素高度为1047*800/718≈1166px为什么有这个差异因为我们要保持图片等比缩放不拉伸。这里有一个非常关键的认知jspdf的addImage方法可以指定图片在PDF页面上的显示尺寸。你可以把整张canvas截成N段每段的像素尺寸是固定的但当每段显示到A4页面上时显示高度要控制在277mm内那么每一段对应多少canvas像素取决于缩放比例。这个比例必须全局统一不能每页单独计算否则页与页之间的内容大小会不一致。2.2 分页截取的坐标计算原理假设配置如下页面内容容器宽度1200px。内容总高度canvas高度2600px。导出目标A4上下页边距10mm左右页边距10mm。第一步计算缩放比例 scaleX (210 - 20) * 3.7795 / 1200 ≈ 0.5984。这个scaleX表示图片放到PDF页面里宽度正好占满可用区域时的缩放因子。因为canvas图片宽高是同比缩放的所以高度方向也应用这个比例。第二步计算每一页能容纳多少canvas像素 每页可用canvas高度 (297 - 20) * 3.7795 / scaleX ≈ 277 * 3.7795 / 0.5984 ≈ 1749px。这意味着在1200px宽的原始内容里每一页可以放下约1749px高的内容区域。这个数不等于1123原因就是缩放比例的存在。如果你忽略了缩放比例直接按1123切那每页内容在PDF里会显得偏小因为相当于把1123px的原始内容压缩到了277mm里而原本应该是1749px的内容压缩到277mm才和宽度比例匹配。第三步从canvas的Y坐标0开始每次截取1749px高度直到截完第1页截取canvas的(0, 0, 1200, 1749)。第2页截取canvas的(0, 1749, 1200, 1749)。第3页如果剩余高度不足1749px则截取剩余部分。第三步有个坑如果最后一页剩余高度很小比如只有50px那PDF最后一页只有一条细长的图页面大片空白。这个问题的处理方式我放在后面“内容适配”里讲。2.3 为什么要从canvas取值而不是重新克隆DOM有一些实现方案是先克隆整个DOM设定一个A4比例的固定高度把内容分区渲染再分别截图。这种方案的思路是让每个区块在渲染时就符合A4一页的高度。但实操中问题非常多比如克隆后的DOM经常丢失组件内部状态弹窗内容、canvas图表、滚动条位置都和原页面不一致最终截图结果和用户当前看到的内容有偏差。我的建议是直接在原始页面的canvas上做裁剪。html2canvas已经完整地绘制了用户看到的内容canvas本身就是一瞬间的页面快照从这个快照上等比例截取能最大程度保证导出结果和用户所见一致。代码流程概括如下调用html2canvas生成完整canvas。根据容器宽度、A4尺寸、页边距计算缩放比例和每页canvas像素高度。循环截取canvas片段逐个转成图片逐个调用jspdf添加页面。输出保存。这套流程不修改DOM、不依赖外部样式覆盖稳定性和维护性都比较好。3. 实操过程与核心环节实现3.1 完整代码实现先装依赖npm install html2canvas jspdf我用的版本是html2canvas 1.4.1和jspdf 2.5.1这两个版本目前是各自库的稳定分支API上没有破坏性变更。直接给出一个可以放到Vue3组件里的完整实现。我这里为了缩减篇幅把逻辑集中到了一个函数里方便你复制后根据自己的业务调整。import html2canvas from html2canvas; import jsPDF from jspdf; interface ExportPdfOptions { /** 要导出的DOM节点 */ element: HTMLElement; /** 导出文件的文件名 */ fileName?: string; /** PDF上下左右页边距单位mm */ margin?: number; /** 是否启用背景色 */ backgroundColor?: string | null; /** 图片压缩质量 0-1 */ quality?: number; /** 是否将内容缩放至整页A4宽度无左右白边 */ fitToPageWidth?: boolean; } async function exportElementToPdf(options: ExportPdfOptions) { const { element, fileName export.pdf, margin 10, backgroundColor #ffffff, quality 1, fitToPageWidth true, } options; // ---------- 第一步生成canvas ---------- const canvas await html2canvas(element, { scale: 2, // 清晰度加倍导出图更清晰 useCORS: true, // 允许加载跨域图片 allowTaint: false, backgroundColor, logging: false, windowWidth: element.scrollWidth, }); // ---------- 第二步尺寸与比例计算 ---------- const pdf new jsPDF({ orientation: portrait, unit: mm, format: a4, compress: true, }); const pdfWidth pdf.internal.pageSize.getWidth(); // 210 const pdfHeight pdf.internal.pageSize.getHeight(); // 297 const marginTop margin; const marginBottom margin; const marginLeft margin; const marginRight margin; const contentWidth pdfWidth - marginLeft - marginRight; // 可用宽度 190 const contentHeight pdfHeight - marginTop - marginBottom; // 可用高度 277 const canvasWidth canvas.width; const canvasHeight canvas.height; // HTML页面在css里的实际宽度注意这里是div的布局宽度不是canvas物理像素宽度 const domWidth element.offsetWidth; // 计算内容宽度缩放比例PDF内容宽度 / DOM宽度 const scale fitToPageWidth ? contentWidth / domWidth : contentWidth / canvasWidth * (canvasWidth / domWidth); // 实际使用中canvas物理像素宽度 / DOM宽度 html2canvas的scale此处2 // 所以如果按照domWidth计算scale会偏小下面统一用canvas像素反推 const pixelToMmRatio contentHeight / (canvasHeight * (contentWidth / canvasWidth)); // 上面这个pixelToMmRatio不太好懂本质就是图片被等比缩放到contentWidth宽时 // 每个canvas像素对应的mm长度。所以每一页能容纳的canvas像素高度 const pageCanvasHeight contentHeight / pixelToMmRatio; }上面这段代码里我故意留了一些过渡计算是为了方便讲解。实际项目中下面这个版本更干净直接基于canvas像素尺寸计算async function exportElementToPdf(options: ExportPdfOptions) { const { element, fileName export.pdf, margin 10, backgroundColor #ffffff, quality 1, fitToPageWidth true, } options; const canvas await html2canvas(element, { scale: 2, useCORS: true, allowTaint: false, backgroundColor, logging: false, windowWidth: element.scrollWidth, }); const pdf new jsPDF({ orientation: portrait, unit: mm, format: a4, compress: true, }); const pdfWidth pdf.internal.pageSize.getWidth(); const pdfHeight pdf.internal.pageSize.getHeight(); const marginTop margin; const marginBottom margin; const marginLeft margin; const marginRight margin; const contentWidth pdfWidth - marginLeft - marginRight; const contentHeight pdfHeight - marginTop - marginBottom; // canvas像素 const cw canvas.width; const ch canvas.height; // 图片以contentWidth宽度放入PDF高度等比 const renderedHeight (ch * contentWidth) / cw; // 如果图片总高度小于一页可用高度不分页直接添加 if (renderedHeight contentHeight) { const imgData canvas.toDataURL(image/jpeg, quality); pdf.addImage( imgData, JPEG, marginLeft, marginTop, contentWidth, renderedHeight ); pdf.save(fileName); return; } // 计算每一页能容纳多少canvas像素 const pageCanvasHeight (cw * contentHeight) / contentWidth; const totalPages Math.ceil(ch / pageCanvasHeight); let positionX marginLeft; let positionY marginTop; for (let i 0; i totalPages; i) { const startY i * pageCanvasHeight; const currentPageHeight Math.min(pageCanvasHeight, ch - startY); // 保证最后一张图不会因为高度不足而模糊 const pageCanvas document.createElement(canvas); pageCanvas.width cw; pageCanvas.height Math.min(currentPageHeight, ch - startY); const ctx pageCanvas.getContext(2d)!; ctx.drawImage( canvas, 0, startY, cw, pageCanvas.height, 0, 0, cw, pageCanvas.height ); const imgData pageCanvas.toDataURL(image/jpeg, quality); const pageImageHeight (pageCanvas.height * contentWidth) / cw; if (i 0) { pdf.addPage(); positionY marginTop; } pdf.addImage( imgData, JPEG, positionX, positionY, contentWidth, pageImageHeight ); } pdf.save(fileName); }3.2 分页坐标的逐步推导过程上面这段代码里最关键的一行是const pageCanvasHeight (cw * contentHeight) / contentWidth;很多人在这一步犯迷糊为什么不是ch / totalPages也不是contentHeight直接转像素我们推导一遍。canvas宽度是cw像素PDF里期望图片宽度是contentWidth毫米。那么每毫米对应的canvas像素数就是cw / contentWidth。现在PDF一页可用高度是contentHeight毫米那么这一页能容纳的canvas像素高度就是每毫米canvas像素数 × contentHeight毫米 (cw / contentWidth) × contentHeight (cw × contentHeight) / contentWidth这个公式和具体缩放比例无关它仅取决于canvas宽高和PDF可用宽高。因为canvas的宽高包含了html2canvas内部scale2的物理像素但比例计算天然消掉了scale的影响所以无论scale是1还是2都能得到一致的页容像素高度。举个例子容器DOM宽800pxscale2canvas物理宽度1600px。内容渲染后总高1200pxscale2canvas物理高度2400px。contentWidth 190mmcontentHeight 277mm。pageCanvasHeight (1600 × 277) / 190 ≈ 2332px。总页数 ceil(2400 / 2332) 2。第一页截取canvas的Y从0到2332px第二页截取剩余68px。这种情况下最后一页只有68px的图片显然不太美观。如何处理下面第三节专门说。3.3 防止段落或卡片被切断的兜底策略如果只是按固定高度硬切遇到卡片式布局很容易把一个卡片从中间切断。比如页面上有个带边框的div切割线恰好落在div中间生成PDF后这一页底部是半个卡片下一页顶部是另外半个卡片视觉上非常不专业。处理思路是在固定分页基础上做“就近对齐”。在生成canvas之前先遍历目标DOM里的所有DOM元素记录它们相对于最外层容器的offsetTop和offsetHeight。然后在分页时如果切割线的位置附近有元素边界就把切割线微调到元素边界处。具体实现可以这样function findNearestBreakPoint( element: HTMLElement, startY: number, pageHeight: number, threshold 30 ): number { const breakPoint startY pageHeight; const candidates: number[] []; const walk (node: HTMLElement) { const rect node.getBoundingClientRect(); const containerRect element.getBoundingClientRect(); const offsetTop rect.top - containerRect.top element.scrollTop; candidates.push(offsetTop); if (node.children.length) { Array.from(node.children).forEach((child) { walk(child as HTMLElement); }); } }; walk(element); let best breakPoint; let minDist Infinity; candidates.forEach((top) { // 只考虑在切割点上方且距离切割点较近的边界 if (top breakPoint breakPoint - top threshold) { if (breakPoint - top minDist) { minDist breakPoint - top; best top; } } }); return best - startY; }这个函数会在默认切割点附近找最近的元素上边界如果找到就提前在元素上边界切割避免切断卡片。注意threshold不宜过大否则一页内容会变少导致页数过多。经验值设为20-50px比较合理。然后在导出函数里把截取高度替换成修正后的值let availablePageHeight pageCanvasHeight; let startY 0; while (startY ch) { // 根据DOM元素边界修正切割位置 const adjustedHeight findNearestBreakPoint(element, startY, pageCanvasHeight, 40); const pageHeight Math.min(adjustedHeight, ch - startY); // 截取canvas并处理图片... startY pageHeight; }这里有一个前提传入findNearestBreakPoint的element要和html2canvas的element是同一个并且该元素的子孙元素坐标要能准确映射到canvas坐标系。如果页面内部有横向滚动或transform坐标映射会偏移这种情况下建议放弃就近对齐使用固定切割即可。3.4 Vue3组件内的实际调用方式在Vue3 SFC中首先给需要导出的部分加一个ref然后按钮事件里调用导出函数。另外一个容易被忽略的点如果页面有弹窗或抽屉组件它们在DOM结构里通常挂在body下而不在目标容器内不会被截图。如果希望把弹窗内容也导出需要把弹窗内容放到目标容器内或者临时挂载到容器里再导出导出完成后移除。调用示例script setup langts import { ref } from vue; const exportContentRef refHTMLDivElement | null(null); async function handleExport() { if (!exportContentRef.value) return; await exportElementToPdf({ element: exportContentRef.value, fileName: 月度报表.pdf, margin: 12, quality: 0.95, backgroundColor: #ffffff, }); } /script template div button clickhandleExport导出PDF/button div refexportContentRef classexport-container !-- 需要导出的内容 -- /div /div /template4. 常见问题与排查技巧实录4.1 导出图片空白或者样式丢失html2canvas并不是100%还原所有CSS特性的。遇到比较典型的三类样式问题颜色函数如果你用了oklch、lab这类现代CSS颜色函数html2canvas可能解析失败导致颜色异常解决方案是在截图前临时降级为rgb/rgba或hex。混合模式mix-blend-mode相关样式无法渲染导出后表现为内容缺失或颜色偏差。部分现代布局subgrid、container queries等特性可能被错误解析导出的结果布局错乱。遇到样式丢失问题时不要急着调库的配置先在页面里用window.getComputedStyle检查目标元素的实际样式是否正常。很多情况是框架的动态样式类没生效或条件渲染导致截图时元素状态不对。4.2 导出后文字变模糊这个问题的根源在于canvas的物理像素不够。html2canvas默认scale是1也就是说一个CSS像素对应canvas里的1个物理像素。在高分屏下这个清晰度远低于屏幕实际渲染清晰度放到PDF里放大看就是糊的。解决方案是调大scale参数html2canvas(element, { scale: 2 })。如果内容特别复杂可以调到3但要注意canvas内存占用会指数级上升页面非常长的时候容易崩。同时图片转成jpg的quality参数也会影响清晰度。quality1最清晰但文件大quality0.92左右肉眼基本无差别建议取0.92-1之间。4.3 导出内容只有一页但后台管理页面设置了overflow滚动这是个特别经典的坑。如果目标容器有overflow: auto或者overflow-y: scroll并且容器内部有滚动条html2canvas默认只截取可视区域内的内容而非完整内容。导出结果就成了“只导出当前屏幕看到的那一小块”。有两种解决方式方式一截图前临时把容器的overflow改为visible并设置一个临时高度容纳所有内容截图完成后再恢复。这种方式改动直观但需要处理异步渲染时序。const originOverflow element.style.overflow; const originHeight element.style.height; element.style.overflow visible; element.style.height auto; // 等待浏览器重新布局 await new Promise(resolve requestAnimationFrame(resolve)); // 执行html2canvas... // 完成后恢复 element.style.overflow originOverflow; element.style.height originHeight;方式二给html2canvas传入windowWidth: element.scrollWidth和windowHeight: element.scrollHeight让它按完整尺寸渲染视口。这个方式在iframe或嵌套滚动场景下不一定有效但大多数后台页面够用。我推荐优先用方式二因为它不需要修改DOM样式更安全。如果方式二失效再考虑方式一。4.4 多页PDF里图片在每一页底部显示不全底部边框被切出现这个现象首先检查你的页边距是不是被吃掉了。jspdf的addImage如果你传入的y坐标是marginTop图片高度刚好等于contentHeight那图片底部应该正好落在marginBottom的上边缘看起来没问题。但如果你的图片高度计算有小数比如277.0000001mmPDF引擎可能因为浮点误差把最后一行挤出页面。解决办法是在计算pageCanvasHeight时留一点安全余量const safeContentHeight contentHeight - 2; // 上下各预留1mm余量 const pageCanvasHeight (cw * safeContentHeight) / contentWidth;别小看这2mm它解决了我遇到过的好几例底部边框线被切断的问题。而且对人眼来说每页底部少1mm几乎无感知但内容完整性的提升是实打实的。4.5 导出大页面时内存爆掉或者卡死如果页面内容非常高比如超过10000px整页canvas的内存占用就非常恐怖。此时建议分静态分段处理先把大容器按每2000px切分成多个小容器各自截图后再合并PDF。虽然html2canvas的定位是只能整体截图但通过临时包裹可以把单张canvas的尺寸控制在安全范围内。实现思路是把element内容复制到一个新容器隐藏原始内容依次显示每段内容分别截图。注意克隆容器时要保留所有样式否则截图结果会和原页面不一致。这个方法实现成本较高一般业务场景用不到。如果你的页面动辄几万像素高建议先和后端沟通走服务端渲染PDF的方案而不是死磕前端。4.6 跨域图片导致截图失败html2canvas跨域图片处理一直是个老大难。当你页面里有img标签引用第三方CDN地址时canvas会被污染toDataURL直接抛错或生成空白图。基础配置是useCORS: true和allowTaint: false同时图片服务器要返回正确的CORS头。如果第三方图床不返回CORS头可以在导出前把图片地址转换成base64或blobasync function loadImageAsBlob(url: string): Promisestring { const response await fetch(url); const blob await response.blob(); return new Promise((resolve, reject) { const reader new FileReader(); reader.onload () resolve(reader.result as string); reader.onerror reject; reader.readAsDataURL(blob); }); }拿到base64后用这个dataURL替换原始img的src。注意这个操作要在html2canvas执行之前完成并且图片加载完成后需要等待图片decode结束才能截图否则可能还是空白。4.7 分页时最后一片内容过少单独占一页很丑我前面提到过最后一页只有几十像素高时PDF里几乎是一片空白加一条细线。这个问题有几种处理方式。第一种把内容压缩到刚好整页。如果页脚部分内容不多可以微调图片宽度让总页数正好减少一页但整体内容会被缩小可能影响可读性。第二种把最后一页的内容“并入”前一页也就是强制内容跨页。但这样会把某个区块切得更厉害需要配合前面讲的就近对齐策略只对内容边界合并。第三种更实用的做法在导出前调整页边距或者设置最小高度保证最后一页至少有一定内容量。比如设定最后一页高度不足contentHeight/4时将前一页的切割位置稍微上移把一部分内容挪到最后一页让最后一页更饱满。这个方案实现起来也不复杂在循环里判断剩余高度如果剩余高度过小就把前一页的切割点往上偏移一段距离if (ch - startY pageCanvasHeight / 4) { // 剩余内容太少把切割点提前让最后一页多一点内容 // 具体提前量根据业务内容决定我这里保守取 pageCanvasHeight * 0.3 const shift Math.min(pageCanvasHeight * 0.3, ch - startY); // 重新截取当前页高度改为 pageCanvasHeight - shift 左右具体需要结合内容边界 }这个处理会让前一页内容稍微变短但整体视觉更均衡。实际编码时要结合你的就近对齐函数一起用。4.8 如果导出后发现每页内容位置逐渐偏移这种问题通常出在坐标累加逻辑上。很多人循环里写的是positionY pageImageHeight然后addImage时用这个positionY当Y坐标。但jspdf里一旦执行了addPage()新页面的坐标系是重新从0开始的你不需要累加Y坐标。只有当你不在第一页需要从marginTop重新开始时Y坐标才应该重置。正确做法是每次addPage后Y坐标都设置回marginTop只有同一页内连续添加多张图片时才累加Y。我的代码里用的是这个逻辑你如果自己写循环注意检查这一点。4.9 导出PDF后文件太大图片型PDF文件体积大是必然的。jpg格式比png格式小得多所以这里用了toDataURL(image/jpeg, quality)而不是默认的png。如果想进一步压缩把quality降到0.8左右肉眼通常还能接受但体积可以减少约40%。另外compress: true开启后jspdf会压缩PDF内部结构也能略微减小体积。如果页面图表颜色丰富可以尝试PNG转JPEG时把背景色和文字颜色拉大对比让压缩更高效。4.10 在Vue3组件卸载后执行导出报错找不到DOM节点如果你的导出按钮点击后组件被销毁比如路由跳转或者v-if切换html2canvas可能拿不到有效DOM。解决方案是确保导出过程中组件不会卸载或者先在内存中克隆一份DOM再做导出。实际操作中我一般会给导出按钮加一个loading状态同时用document.body.appendChild(cloneNode)把克隆节点挂载到body上导出完成后移除。这样即使原组件被销毁导出也能正常完成。5. 参数调优与经验补充5.1 不同页边距方案的效果对比我把几种常见配置的打印效果和适用场景整理成了表格方便你根据页面类型做选择页边距方案上下左右边距视觉特征适用场景紧凑型8mm内容利用率高页脚页码贴近边缘表单、表格类报表标准型12mm页边距舒适适合阅读大多数后台管理页面宽松型18mm四周大量留白适合正式文档提案、合同、封面页对称非等宽左20mm 右15mm 上15mm 下15mm类似书籍装订效果需要装订的项目文档我用12mm比较多因为太小的边距在打印时容易被部分打印机裁掉太大又浪费空间。如果你明确知道打印机的物理可打印区域也可以以此为准来定边距。5.2 scale参数与清晰度、性能的平衡建议html2canvas的scale参数直接决定canvas的分辨率但分辨率的提升是有上限的。我实测在普通配置的电脑上单张canvas超过8000px宽时内存占用就会显著上升页面可能出现卡顿。所以scale不是越大越好建议页面宽度在800-1400px之间scale2足够。页面宽度超过1600px, scale1.5即可。页面既有大图又有大量文字scale1.8是清晰度和性能的折中点。页面内容特别长高度超过8000pxscale不要超过1.5否则等到导出完成的耗时相当感人。另外如果你使用了scale2并且后续计算pageCanvasHeight时用的是canvas物理像素那么所有分页计算和截取都基于物理像素这没问题。只要不把物理像素和CSS像素混用就不会出现错位。5.3 页面存在懒加载图片或异步数据时怎么办如果目标区域有懒加载图片或者异步渲染的图表直接截图很可能截到占位符或空白。我的处理方式是在截图前先尝试等待所有图片加载完成async function waitForImages(element: HTMLElement) { const images element.querySelectorAll(img); const tasks Array.from(images).map((img) { if (img.complete) return Promise.resolve(); return new Promise((resolve) { img.addEventListener(load, resolve, { once: true }); img.addEventListener(error, resolve, { once: true }); }); }); await Promise.all(tasks); }对于ECharts这类canvas图表确保图表实例的resize已经完成否则截图可能是旧尺寸。可以强制调用一次resize再截图。6. 从一次真实需求看分页导出的完整流程拿我上个月做的一个“月度经营分析报表”页面举例。页面长这样顶部是筛选条件区下面是统计卡片一排四个接着是一个大的趋势折线图再往下是明细表格总共大概2500px高。直接导出时第二页就是表格的中间部分第一行被切成两半非常难看。排查过程我先看canvas总高度发现统计卡片区域的DOM上边界距离容器顶部约1100px页面切割线默认在1749px左右这条线正好落在表格前几行的位置。之后我启用了就近对齐threshold设为40px切割线就被吸附到了表格元素的上边界表格完整地移到了下一页不再有半行文字的情况。与此同时最后一页只剩表格后半部分约800px高。对于这个结果我是能接受的因为表格作为一个整体出现在同一页比强行填满页面更有可读性。如果你希望最后一页不那么空可以考虑把前面的统计卡片和折线图稍微压缩但内容大小不能随便压缩否则尺寸不一致。具体处理时我在导出函数里增加了onPageBreak回调方便外部感知每次分页的位置这样可以在特定分页处做额外的DOM调整比如插入分页符或修改背景色。这个回调实现起来也很简单在循环里把每一页的起始Y坐标传出去if (options.onPageBreak) { options.onPageBreak({ pageIndex: i, startCanvasY: startY, pageCanvasHeight: pageHeight, }); }这个扩展对后续排查和业务定制很有帮助建议保留。7. 常见专业术语与理解误区7.1 CSS像素、canvas物理像素、PDF毫米单位三者的关系这是新手最容易搞混的一组概念CSS像素页面上CSS定义的尺寸单位比如div宽度800px屏幕缩放不影响布局流的尺寸。canvas物理像素html2canvas渲染时生成的canvas的实际像素宽度。如果scale2物理像素CSS像素×2。PDF毫米单位jspdf里addImage的坐标和尺寸都是以mm为单位的。三者的换算关系可以概括成两个比例一个是canvas物理像素到PDF毫米的换算由你在addImage时传入的宽度决定另一个是canvas物理像素和CSS像素的比例由html2canvas的scale决定。只要addImage的宽度参数固定图片的显示大小就固定和scale无关scale只影响图片的细腻程度。7.2 为什么不能用window.print简单替代window.print配合CSSmedia print确实可以生成比较完美的PDF字体是矢量可复制可搜索文件小。但问题是后台管理系统的界面往往不是为打印设计的各种第三方图表库、富文本组件、自定义滚动容器在打印时表现不稳定你需要额外写大量print样式。而且打印逻辑和页面交互是耦合的用户点击导出按钮时你可能还需要确认弹窗、调整布局纯CSS方案很难做到动态控制。html2canvasjspdf的方案本质是“所见即所得”截图和页面渲染结果强相关。两者各有优缺点我建议根据页面复杂度去选而不是一上来就定用哪个。8. 后续扩展从单页导出到多页面合并这次分享只讲了单页内容导出为多页PDF。如果你的需求升级了比如一个流程要导出多个不同页面的内容到一个PDF文件里可以在循环外面先创建一个jsPDF实例然后每个页面分别调用html2canvas再往同一个pdf实例里addPage和addImage最后统一save。核心逻辑和单页导出完全一致只是把多次导出的步骤合并成了同一个pdf实例的操作。另外如果你需要给每页加页眉页脚、页码可以在addImage之后用pdf.text方法绘制文本pdf.setFontSize(10); pdf.setTextColor(150); pdf.text(第 ${i 1} 页 / 共 ${totalPages} 页, pdfWidth / 2, pdfHeight - 5, { align: center, });这个操作一定要在addImage之后否则文字可能被图片盖住。加页码这种小功能看起来不起眼但对正式报告之类的场景是刚需。9. 最后的调优心得我在实际项目里花时间最多的其实不是写导出函数本身而是对各种边界情况的排查。比如标题里提到的“分页处截断”你在解决它之前必须先弄清楚是哪种截断是图片直接没显示完整还是文字被拦腰切断还是某一页底部缺少了1mm的内容。三种情况的原因和解决办法都不一样。如果一上来就套用某个网上的代码块很可能修了A问题B问题还在。我的做法是在导出函数里加一个调试模式输出每个分页的坐标、截取高度、canvas总高度和图片实际渲染高度。这样所有问题都能定位到具体是哪个环节出了问题而不是靠肉眼反复试。代码层面就是简单地在循环里打console.log但排查效率翻倍。最后再分享一个小技巧导出的PDF打开后如果发现图片颜色比页面上看到的偏灰一点通常是因为canvas的默认背景色是透明PDF渲染时对透明区域做了白色叠加而页面本身是浅灰背景。这时只要在html2canvas配置里强制指定backgroundColor: #ffffff或者页面实际背景色颜色就会恢复正常。这个小问题我在最开始调试时花了整整一小时才找到原因希望你能避开。
返回列表