ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Vue3导出PDF完整指南:html2canvas与jsPDF分页截断解决方案

Vue3导出PDF完整指南:html2canvas与jsPDF分页截断解决方案 做Vue3后台系统做导出PDF这个功能我相信踩过坑的人不在少数。网上搜一圈方案基本都是html2canvas配合jsPDF但真到自己动手不是内容被截断就是分页处狠心一刀切把表格行、卡片拦腰斩断。这篇我把整套排查过程写清楚包括pxtop的换算逻辑、逐页切割的数学原理、以及断点吸附这种进阶处理照着抄就能用。1. 这个需求到底卡在哪现象与根因1.1 两个库的职责分工先搞清楚html2canvas和jsPDF各自是干什么的这事就好办多了。html2canvas做的核心事情是把当前DOM节点“拍一张照”生成一个canvas画布。它并不是浏览器原生截图接口而是通过遍历DOM结构、读取计算样式再手动绘制到canvas上。所以最终输出的本质是一张位图凡是难以用canvas绘制的CSS效果某些滤镜、部分复杂的box-shadow等都可能丢失。jsPDF则负责把这张图变成PDF文件。它本身不解析DOM你给它什么图它就放什么图。真正要做分页、排版全靠我们写JavaScript算好位置和尺寸。所以这两个库的分工简单来说就是html2canvas负责“把网页变成图片”jsPDF负责“把图片放进多页PDF里”。分页逻辑html2canvas帮不了你jsPDF也帮不了你必须自己算。1.2 截断问题的三个根源我在实际项目里遇到的截断问题归根结底就三类第一类是比例不匹配。PDF内部的单位是pt网页CSS用的是px两者在A4纸上的换算关系如果搞错导出的内容要么被挤到一页上显得特别小要么溢出页面被裁掉。网上很多教程直接把canvas的高度除以2当页面高度用这种偷懒做法在小页面下看不出问题内容一多就露馅。第二类是“假分页”。很多人处理分页的方式是把canvas整体缩放后塞进PDF生成出来好像是一页其实内容早就被压缩变形了。真正的分页应该是一段一段地切canvas每一段对应PDF的一页。第三类是元素被腰斩。即使你正确逐页切割如果某表格行恰好跨过切割线导出的PDF里就会出现半截行、半截卡片、半截标题观感很差。这个必须靠断点吸附或者DOM层面的预留空位来解决。2. 动手前必须搞懂的尺寸换算2.1 A4的pt与px换算逻辑这里先把基础参数列清楚。A4纸的物理尺寸是210mm × 297mm。在PDF内部1英寸等于72pt1pt等于1/72英寸。所以A4纸换算成pt就是宽度210mm / 25.4mm × 72pt 595.28pt高度297mm / 25.4mm × 72pt 841.89pt这是jsPDF的a4内置尺寸直接用new jsPDF(p, pt, a4)就能拿到。网页端CSS的px定义是1px 1/96英寸也就是96dpi。那1px等于多少pt呢96/72 4/3也就是说1pt 1.333px。如果用网页像素尺寸直接当作PDF的pt尺寸PDF会偏小内容比例就对不上。那为什么html2canvas又要设置scale参数因为同样一个divscale1截出来的canvas像素宽度就等于元素CSS宽度乘以1scale2则像素翻倍。这个翻倍纯粹是提高位图分辨率和最终PDF落纸尺寸不能混为一谈。落到PDF里时我们始终以pt为准canvas像素只作为中间产物。2.2 scale参数对清晰度和体积的影响html2canvas的scale参数最直观的理解就是“采样倍率”。scale1时canvas像素数等于DOM元素CSS像素数。如果你的页面宽度是1200px那么canvas宽度就是1200px在A4纸的595.28pt宽度里放这张图等于每一pt要容纳约2个像素点清晰度其实够用。但如果页面特别宽或者导出的PDF要放大看细节scale1就不行了。scale2是我用得最多的值。清晰度提升明显文件体积也能接受。scale3会让生成的canvas非常大一张长页面截下来可能超过浏览器对canvas的尺寸限制页面高度特别长的时候还会导致内存溢出。关于canvas尺寸限制要额外提一句浏览器对canvas的宽高有上限一般是32767px左右。如果整个页面非常长scale又大canvas高度很容易撞到这个天花板。html2canvas截出来整张图是空白或者报错多半就是这个原因。这种情况后面我再说替代方案。2.3 换算代码里的常见错误我见过不少写法是把页面内容和A4纸按毫米直接换算比如const imgWidth 210 const imgHeight contentHeight * 210 / contentWidth思路是先把页面宽度对应A4的210mm再按宽高比得到高度。这个想法的方向是对的但如果你直接用contentWidthCSS像素宽度来计算而不考虑scale最终PDF里图的实际物理尺寸会和预期不一致。正确做法是拿canvas的像素宽度作为基准来算因为canvas像素才是真正会被addImage放进PDF的原始数据。const pageWidth 595.28 const pageHeight 841.89 const canvasWidth canvas.width const pageHeightCanvas pageHeight / pageWidth * canvasWidth这个pageHeightCanvas就是“每一页PDF对应canvas上的像素高度”。用它来切割canvas再加到PDF里才不会出现比例错乱。3. 第一版实现整页截图拼PDF的问题3.1 最基础的导出代码先看一个最简版实现很多项目一开始都是这么写的import html2canvas from html2canvas import { jsPDF } from jspdf async function exportPdf(elementId, fileName) { const element document.getElementById(elementId) const canvas await html2canvas(element, { scale: 2, useCORS: true, backgroundColor: #ffffff }) const pdf new jsPDF(p, pt, a4) const pageWidth 595.28 const pageHeight 841.89 const canvasWidth canvas.width const canvasHeight canvas.height const imgWidth pageWidth const imgHeight canvasHeight * pageWidth / canvasWidth pdf.addImage(canvas.toDataURL(image/jpeg, 0.95), JPEG, 0, 0, imgWidth, imgHeight) pdf.save(${fileName}.pdf) }这段代码跑起来内容能导出但问题非常明显无论页面多长全部内容都会被压缩到一页A4里。一个3000px高的页面导出来字小得跟蚂蚁一样。如果imgHeight超过pageHeight内容还会溢出页面底部超出部分直接消失。3.2 为什么这个版本必然出问题这个版本的错误在于把“整张canvas”当成一个整体塞进了单页PDF。如果你的业务页面内容只有两三百像素高这样导出倒是没毛病但后台管理系统里的报表、工单详情、数据大屏动辄上千像素甚至更长的滚动区域单页根本放不下。要分页就必须把canvas切成好几段每一段对应PDF的一页。切割逻辑也不是随便切要保证切割线处的视觉连续还要避免元素被从中切断。另外addImage图片格式也有讲究。很多人用canvas.toDataURL(image/png)PNG虽然无损但体积偏大。对于纯文本表格报表JPG加0.9左右的质量就够文件体积能小好几倍。如果用JPG记得backgroundColor必须设置成白色否则透明区域会变成黑色。4. 核心方案按A4高度逐页切割4.1 切割思路与关键计算先理清楚我们要做的事把一张长条canvas按A4比例切成多段逐段转成图片依次写入PDF的每一页。关键计算点有两个。第一个是“每页能容纳多少canvas像素”。这个值不是固定的而是由canvas宽度和A4宽高比共同决定const pageWidthPt 595.28 const pageHeightPt 841.89 const pageCanvasHeight Math.floor(pageHeightPt / pageWidthPt * canvasWidth)页面宽度“撑满”PDF宽度时每页canvas内容的高度就是pageCanvasHeight。第二个是“一共多少页”。直接用canvas总高度除以每页高度向上取整const pageCount Math.ceil(canvasHeight / pageCanvasHeight)这里特别容易出边界bug如果canvasHeight恰好是pageCanvasHeight的整数倍Math.ceil不会多算一页但如果计算过程有浮点误差比如得到的是3.0000000001就会多出一页空白。稳妥的办法是在除法结果基础上减去一个极小值或者对pageCanvasHeight做向下取整再比较。4.2 完整可运行的导出函数下面是稳定版本的核心导出代码我直接在项目里用的是这套import html2canvas from html2canvas import { jsPDF } from jspdf export async function exportPageToPdf(elementId, fileName export) { const element document.getElementById(elementId) if (!element) { console.error(未找到DOM节点, elementId) return } const canvas await html2canvas(element, { scale: 2, useCORS: true, backgroundColor: #ffffff, logging: false }) const pdf new jsPDF(p, pt, a4) const pageWidth pdf.internal.pageSize.getWidth() const pageHeight pdf.internal.pageSize.getHeight() const canvasWidth canvas.width const canvasHeight canvas.height // 每页PDF对应canvas上的像素高度 const pageCanvasHeight Math.floor((pageHeight / pageWidth) * canvasWidth) const totalPages Math.ceil(canvasHeight / pageCanvasHeight) for (let i 0; i totalPages; i) { if (i 0) { pdf.addPage(a4, p) } const startY i * pageCanvasHeight const currentHeight Math.min(pageCanvasHeight, canvasHeight - startY) const pageCanvas document.createElement(canvas) pageCanvas.width canvasWidth pageCanvas.height currentHeight const ctx pageCanvas.getContext(2d) ctx.drawImage( canvas, 0, startY, canvasWidth, currentHeight, 0, 0, canvasWidth, currentHeight ) const imageData pageCanvas.toDataURL(image/jpeg, 0.95) // 按比例计算该页图片在PDF中的显示高度 const imgHeight (currentHeight / pageCanvasHeight) * pageHeight pdf.addImage(imageData, JPEG, 0, 0, pageWidth, imgHeight) } pdf.save(${fileName}.pdf) }这段代码的关键点有两个。第一通过ctx.drawImage只截取原canvas的局部区域。drawImage的九个参数里前四个是源图的截取区域后四个是目标canvas的绘制区域。把源区域高度设为currentHeight就能做到把第N段内容“抠”出来。第二addImage时图片高度按currentHeight占pageCanvasHeight的比例缩放。这样最后一段内容不足一页时图片会等比缩小不会溢出也不会变形。4.3 针对分页线切在元素中间的处理上面的代码能解决“内容被截断”的问题但分页位置依然可能卡在表格行中间、卡片中间、标题下方半个字的位置。这是最影响观感的问题。根本原因在于pageCanvasHeight是一个固定值切割线不会考虑当前DOM里元素的边界。比如一个表格行高度30px切割线可能正好从这一行的中间穿过导出的PDF里这一行就只有上半截。处理思路是“调整切割点”当计算出默认切割位置后检查这个位置附近是否有不该分割的块级元素如果有就把切割点挪到元素边界之外。我常用的是一个相对简单的策略注册一个“不可分割”选择器列表例如行的class、卡片的class然后遍历这些元素计算它们相对于页面顶部的偏移量判断是否跨过了切割线如果是就调整切割线。interface SplitTarget { top: number bottom: number } function adjustBreakPoints( canvasHeight: number, pageCanvasHeight: number, splitTargets: SplitTarget[] ): number[] { const breakPoints: number[] [] let currentBreak pageCanvasHeight while (currentBreak canvasHeight) { let adjustedBreak currentBreak for (const item of splitTargets) { if (item.top currentBreak item.bottom currentBreak) { adjustedBreak Math.max(adjustedBreak, item.bottom) } } // 防止调整后超过总高度 if (adjustedBreak canvasHeight) { break } breakPoints.push(adjustedBreak) currentBreak adjustedBreak pageCanvasHeight } return breakPoints }拿到调整后的断点后切割canvas时就不再使用等分高度而是按断点数组来切。这个方案在表格类报表里效果非常明显每行都能整整齐齐落在同一页里。5. 进阶优化不可分割块与页面留白5.1 如何收集不可分割元素的位置要做断点吸附第一步得先收集哪些元素是不可分割的。我是通过一个可配置的选择器数组来收集的const noSplitSelectors [ .table-row, .card, .section-block, tr ]遍历这些选择器找到页面根节点内所有的匹配元素计算每个元素相对于根节点顶部的offsetTop和offsetHeight得到top和bottom坐标。function collectSplitTargets(root: HTMLElement): SplitTarget[] { const targets: SplitTarget[] [] for (const selector of noSplitSelectors) { const nodes root.querySelectorAll(selector) nodes.forEach((node) { const el node as HTMLElement const top el.offsetTop const bottom top el.offsetHeight if (bottom 0) { targets.push({ top, bottom }) } }) } return targets }注意offsetTop是相对最近的定位祖先元素计算的。如果页面里有多层嵌套的相对定位容器offsetTop会失真。稳妥做法是用getBoundingClientRect()取得当前视口坐标再减去根节点的视口坐标得到相对根节点的真实偏移。function getRelativeTop(el: HTMLElement, root: HTMLElement): number { const elRect el.getBoundingClientRect() const rootRect root.getBoundingClientRect() return elRect.top - rootRect.top }5.2 每页重复表头另一个很实用的优化是当表格跨多页时在每一页顶部重复显示表头。HTML表格本身有thead理论上浏览器打印时会自动重复但html2canvas对thead的重复处理并不理想。我的做法是在导出前把表头复制一份放到每一页内容的顶部。具体思路导出前克隆表格的thead每切割一页如果该页内容是从表格中间开始的就在该页内容顶部插入克隆的表头DOMhtml2canvas只截取当前页对应的DOM区域。这种方案从“切割canvas”变成“分段渲染DOM再截图”虽然性能开销更大但能彻底解决表头丢失和元素被切断两个问题。核心逻辑是先把页面按断点拆成多段容器再用html2canvas对每一段容器单独截图。我自己实测下来这种分段渲染法对复杂报表更稳但对页面布局要求高因为拆出来的每段容器宽高要严格保持一致否则截图比例会乱。如果你的页面结构简单用逐页切割canvas就够如果表格又长又复杂优先考虑分段渲染。5.3 导出体积与性能导出的PDF文件如果太大基本是两个原因canvas像素太高或者图片格式选了PNG。JPG质量参数0.95已经很接近无损体积却比PNG小得多。对于纯数字、纯文字的报表甚至可以降到0.85肉眼基本看不出区别。如果你的页面图片特别多每个截图都带大量图片建议先把页面上的大图临时压缩一下再截图或者把图片区域用占位色块替换。我之前处理过一个带大屏背景图的导出需求背景图导致canvas尺寸剧增导出的PDF动不动三四十MB最后把背景图换成纯色渐变体积直接降到原来的十分之一。6. 常见问题与排查速查表6.1 高频问题现象与处理现象根因解决方案内容全部挤在一页里没做分页切割整张canvas塞进单页按A4比例计算每页canvas高度逐段切割最后一页内容被截断pageCanvasHeight计算不准确浮点误差导致页数判断错误对pageCanvasHeight向下取整边界处加极小值判断表格行被拦腰截断固定高度切割没有考虑元素边界收集不可分割元素调整断点位置导出图片模糊scale太低scale提高到2或3注意canvas尺寸限制页面空白或报错页面高度超过canvas上限降低scale或改用分段渲染方式图片导出黑底JPG不支持透明背景设置backgroundColor为白色跨域图片导出空白或报错canvas被污染toDataURL抛异常useCORS设为true服务端配置CORS头生成PDF后出现大量空白页分页循环条件边界处理不当检查pageCount计算对canvasHeight/pageCanvasHeight结果做向下取整比较6.2 我实测踩过的坑第一个坑是scale参数。页面宽度1200pxscale2后canvas宽度2400px这个尺寸在A4纸595.28pt宽度下换算每英寸约122像素清晰度很好。但如果你把scale加到4canvas宽度4800px此时html2canvas解析时间和canvas占用内存都暴涨页面稍微复杂一点浏览器就会卡死。第二个坑是addImage的坐标。jspdf的坐标系原点在左上角y轴向下这跟canvas的习惯一致所以直接用0, 0不会出问题。但如果你通过pdf.html这类API混用坐标系就会变成PDF原生的左下角原点很容易把图片放反。第三个坑是导出文件名的中文问题。jsPDF的save方法对中文文件名处理在不同浏览器上表现不一致偶尔会出现乱码。稳妥做法是先把文件名用encodeURIComponent处理一遍再传给save。第四个坑是隐藏元素的导出。如果被导出的容器里包含v-if为false的节点html2canvas会直接忽略。反过来如果容器使用了overflow: hidden超出的部分也会被裁掉。我遇到过一个表格高度塌陷的问题就是因为表格外层有一个transition动画的类html2canvas读取到的样式是中间态。关于打印样式还有一个重要提醒html2canvas基于浏览器的布局快照如果你在导出前调整了窗口大小canvas内容是重新渲染后的结果。如果页面上有懒加载的图片导出时还没有加载完成导出结果里就会缺图。我一般在调用导出前先用document.fonts.ready等待字体加载完成再用图片预加载的方式确保所有img标签的src已经缓存到本地。最后分享一个经验别把html2canvas当万能工具。它终究是模拟浏览器渲染对box-shadow、backdrop-filter、canvas内部动画等支持都不完整。如果项目里导出要求特别高可以考虑用浏览器原生的window.print配合page设置来导出PDF那段路虽然也有打印样式要写但渲染结果的保真度是html2canvas给不了的。两个方案各有利弊我现在的做法是简单内容用html2canvasjsPDF内容复杂重大的报表直接走打印浏览器。
返回列表