ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Vue3 页面导出 PDF 分页截断问题解决方案

2026/9/18 8:46:27 拓冰建站 浏览量
Vue3 页面导出 PDF 分页截断问题解决方案 先说下背景接了个需求要把系统里的工单详情页导出成 PDF 发给客户。页面内容包含基础信息表单、状态时间线、统计图表和一个大表格实际高度接近三屏。技术选型上没多想vue3 html2canvas jsPDF 是团队最熟的组合第一版大概二十分钟就写出来了结果导出一看内容超过一屏直接丢PDF 里只剩下第一页有东西后来硬加了分页循环内容是不丢了可每页交界处文字被拦腰切断、表格行只剩半个。这篇文章就是我当时排查和解决这两类截断问题的完整记录内容对 Vue3 项目里需要 A4 格式导出 PDF 的场景基本可以直接照搬。1. 先想明白html2canvas 和 jsPDF 组合到底是怎么工作的1.1 这条技术链路的本质先拍照再贴相册很多同学是一上来就复制代码报错了才回头查原理。我的建议是反过来先把这条链路想清楚后面所有故障都很好解释。html2canvas 做的事情是重绘它不直接调用浏览器截图接口而是重新解析目标 DOM 的标签、样式、图片、背景然后把这个页面画到一张 canvas 上。所以它输出的本质是一张位图而且是一整张长图不受视口高度限制。你页面有多长canvas 就有多高。jsPDF 做的事情更单纯创建一个 PDF 文件对象然后把图片、文字、线条等内容按坐标放进去。它本身完全不理解 DOM也不会帮你分页你给它一张多高的图它就按你传的坐标和尺寸放。所以这两个库配合起来本质就是先把网页拍成一张长照片然后再按 A4 版面的大小把这张长照片一段一段贴进 PDF 的每一页里。截断问题的根源全在这个贴的过程中。1.2 内容截断和分页截断是两层完全不同的故障我第一次遇到的是内容截断表现是页面三分之一之后的内容在 PDF 里消失了。当时的代码只调了一次pdf.addImage()没有加addPage()。因为 jsPDF 不会自动分页图片超出 A4 高度的部分就直接不显示了。后来加了循环分页第二个问题马上冒出来分页截断。表现是页面交界处刚好把一行文字切断、把一张图片劈开、把表格行从中间切成两半。这两个问题我建议你在排查时明确分开问题本质解决方向内容截断分页缺失一页塞不下整张图加addPage()循环按 A4 高度切图分页截断切割点固定等分切到了元素内部让切割点匹配元素边界第二层才是这篇文章要重点解决的核心因为它不是加一个while循环就能过去的。1.3 什么样的页面最容易踩这两种坑从我的经验看只要目标页面满足以下任一条件几乎必踩内容高度超过一屏比如带详情列表、表格、图表的后台页面导出的容器内部有滚动条内容是通过滚动才能看全的页面里有懒加载图片、异步图表截图时机不对导致白屏页面结构里存在跨页的卡片、折叠面板、表格行等高容器如果你的页面只是几行文字那直接整页截图导出一张图就够了不需要分页。但凡是内容超过 A4 高度分页逻辑就绕不开。2. 第一版实现整页截图加循环分页先解决内容丢失2.1 A4 尺寸换算和关键参数jsPDF 支持两种单位一种是点pt一种是毫米mm。我做 A4 导出习惯用毫米因为 A4 的标准尺寸就是 210 × 297 mm不需要额外换算。像素和毫米之间的关系取决于屏幕的 DPI。一般前端按 96dpi 算1 英寸 25.4mm所以 1mm ≈ 3.7795px。这意味着不缩放的情况下A4 宽度大概是 794px高度大概是 1123px。但在实际导出时我们不直接用 A4 的像素值去切图而是按图片宽度 A4 宽度来做等比缩放再算出对应高度的毫米值。因为 html2canvas 生成的 canvas 宽度和 DOM 宽度不一定完全一样尤其设置scale之后canvas 的像素宽度会翻倍。我在第一版里就是用这个思路图片宽度固定 210mm图片高度按 canvas 比例换算成毫米然后用 A4 高度当步长循环分页。import html2canvas from html2canvas; import { jsPDF } from jspdf; async function exportPdf() { const dom pageRef.value; const canvas await html2canvas(dom, { scale: 2, useCORS: true, backgroundColor: #ffffff, logging: false, }); const pdf new jsPDF({ orientation: p, unit: mm, format: a4 }); const pageWidth 210; const pageHeight 297; const imgWidth pageWidth; const imgHeight (canvas.height * imgWidth) / canvas.width; // 先把整张图加在第一页y 坐标是 0 pdf.addImage(canvas.toDataURL(image/jpeg, 0.95), JPEG, 0, 0, imgWidth, imgHeight); // 从第二页开始每次把同样的长图向上偏移一页的高度 let heightLeft imgHeight - pageHeight; let position 0; while (heightLeft 0) { position - pageHeight; pdf.addPage(); pdf.addImage(canvas.toDataURL(image/jpeg, 0.95), JPEG, 0, position, imgWidth, imgHeight); heightLeft - pageHeight; } pdf.save(工单详情.pdf); }这段代码里最关键的是position变量。addImage的第二个坐标是图片左上角的 y 坐标传负数表示图片向上偏移这样就能把长图的后半段显示在当前页面上。循环到没有剩余高度为止。2.2 实测效果内容不丢了但切痕很明显跑完这个版本PDF 可以正常输出多页内容也不再丢失。但问题非常扎眼几乎每一页的底部文字都被齐刷刷地切断表格行经常只剩一个标题栏或者半行内容图片有时也被从中间劈开。当时我截了一张生成的 PDF 拿给同事看同事第一句话是这表格怎么只有一半这说明分页逻辑已经从不分页走到了乱分页的阶段。内容不丢只是第一步切割线位置才是决定输出质量的关键。2.3 为什么第一版必然产生切痕因为切割点是固定的每隔一页的高度就切一刀这一刀可不管底下是文字、是图片还是表格直接一刀切。一个页面元素的高度和位置是布局决定的而切割点是算术等分决定的这两者之间没有任何关联。我举个例子某行表格的顶部在距页面顶部 1100px 的位置底部在 1150px而我的切割线在 1123px那么这一行就会从中间被切掉上一页看到上半行下一页看到下半行。更糟的是如果页面有 margin 和 padding行高还会动态变化你根本无法预估哪一行会被切。3. 定位根因切割线穿过元素才是分页截断的源头3.1 我当时的完整排查过程遇到切痕问题我没有马上改代码而是先确认两个问题到底是 html2canvas 渲染阶段出了问题还是 jsPDF 分页阶段出了问题。第一步把 html2canvas 生成的完整 canvas 转成 base64在浏览器新标签页打开。结果发现页面完整内容都在没有任何渲染缺失。这排除了 html2canvas 阶段的问题。第二步把循环里每一页的图片切片单独保存下来按页签逐张比对。我写了一个临时函数把每页对应的 canvas 区域单独导出成图片然后用缩略图模式一张张滑过去。很快发现问题集中在每页底部和下一页顶部的交界处。第三步我在代码里加了几行调试日志打印当前切割线的像素位置然后遍历页面里跨这条线的元素把它们的tagName、offsetTop、offsetHeight打出来。结果非常直观切割线位置: 1123px 跨越切割线的元素: tr top1100 height50 p top1150 height24切割线 1123px 正好落在tr中间。这行表格被切成两半根因确认。3.2 为什么说必然而不是偶尔因为只要页面内容不是 A4 高度的整数倍最后一定有一段内容位于切割线附近。而正常业务页面的元素高度是随意的不可能凑巧都对齐到 A4 高度的整数倍上。所以只要用固定高度等分切割切到某个元素内部只是时间问题而不是概率问题。3.3 哪些元素最容易中招从我实际踩坑的经验看最容易出问题的元素排序是表格行tr行内容多、行高高切到一半时视觉破坏感最强文本段落文字行被横切上一页底下一半字下一页顶上一半字图片直接劈开完全不能接受卡片组件内部有 padding被切后上下 padding 丢失样式像破了一个口这三个方向确认之后解决方案就清晰了让分页的切割线吸附到元素的边界上而不是固定在等分位置。4. 核心解决基于元素边界的智能切割算法4.1 获取元素相对容器的真实顶部偏移要判断一个元素是否跨越切割线首先要知道它相对导出容器的顶部位置。直接拿el.offsetTop是不行的因为这个值只代表元素相对其offsetParent的距离不是相对我们导出容器的距离。必须沿着offsetParent链向上累加直到遇到导出容器为止。function getElementTop(el, container) { let top 0; let node el; while (node node ! container) { top node.offsetTop; node node.offsetParent; } return top; }这里有一个容易踩的细节如果导出容器自身不是定位元素offsetParent链可能会继续向上走到 body那累加出来的值就包含了非导出区域的内容偏移。所以我建议在导出容器上显式设置position: relative让offsetParent链在容器处终止。我踩过一次这个坑容器没设定位计算的元素偏移一直偏大导出后每一页的切割点都不对。后来定位原因就是这个。4.2 遍历元素找跨线节点把切割点吸附到元素顶部核心算法分三步从当前切割点开始算出原始的下一切割点rawNext遍历导出容器内所有可见元素找出所有满足top rawNext且bottom rawNext的元素如果存在这样的元素就取其中top最大的一个把这个元素的顶部作为新的切割点为什么取top最大的因为最靠近原始切割点的元素才是真正被切的那个。把它整体推送到下一页上一页只是多留了一点空白但不会把大段内容空着。function getSplitPoints(container, pageHeightPx) { const elements Array.from(container.querySelectorAll(*)).filter((el) { if (el.offsetHeight 1) return false; const style window.getComputedStyle(el); return style.display ! none style.visibility ! hidden; }); const maxHeight container.offsetHeight; const points [0]; let guard 0; while (points[points.length - 1] pageHeightPx maxHeight) { if (guard 2000) break; const current points[points.length - 1]; const rawNext current pageHeightPx; let bestTop -1; for (const el of elements) { // 元素本身超过一页高的时候无法通过移动避免截断跳过 if (el.offsetHeight pageHeightPx) continue; const top getElementTop(el, container); const bottom top el.offsetHeight; if (top rawNext bottom rawNext top current - 1) { bestTop Math.max(bestTop, top); } } const next bestTop current ? bestTop : rawNext; points.push(next); } return points; }这里有几个边界条件和设计取舍我展开说一下。首先是元素本身超过一页高的情况。如果一个元素高度比一页还大你把它整体推到下一页也是放不下的必然还是要切在它内部。这种情况我的处理是跳过它按原始切割线切。比如一个超长的文本说明块就只能允许中间断开这在实际业务里是可以接受的但不能让它导致死循环。其次是bottom rawNext这个判断保证元素真的被切割线穿过而不是刚好线在元素上方或下方。最后是top current - 1这个条件防止切割点倒退回到当前点之前导致循环卡住。4.3 按切割点切片写入 PDF拿到切割点集合后就不需要再做整张图负偏移的技巧了。直接按切割点把 canvas 切成一段一段的每一段生成一个新的小 canvas然后依次写入 PDF 页面。function canvasToSegmentDataUrl(canvas, startY, height, type image/jpeg, quality 0.95) { const segment document.createElement(canvas); segment.width canvas.width; segment.height Math.ceil(height); const ctx segment.getContext(2d); ctx.drawImage(canvas, 0, startY, canvas.width, Math.ceil(height), 0, 0, canvas.width, Math.ceil(height)); return segment.toDataURL(type, quality); }注意这里的startY和height是 canvas 的物理像素坐标不是 CSS 坐标。如果 html2canvas 设置了scale: 2那么 canvas 的坐标和 CSS 坐标差两倍切片时一定要把 CSS 切割点乘以 scale。我的做法是用canvas.width / dom.clientWidth算出实际的缩放比例再逐个乘回去这样即使 scale 不是整数倍也不会出错。4.4 在 Vue3 里的完整封装这段代码我直接封装成了工具函数项目里其他地方也能复用// utils/exportPdf.ts import html2canvas from html2canvas; import { jsPDF } from jspdf; interface ExportPdfOptions { scale?: number; fileName?: string; pageWidthMM?: number; pageHeightMM?: number; } function getElementTop(el: HTMLElement, container: HTMLElement): number { let top 0; let node: HTMLElement | null el; while (node node ! container) { top node.offsetTop; node node.offsetParent as HTMLElement | null; } return top; } function getSplitPoints(container: HTMLElement, pageHeightPxCss: number): number[] { const elements Array.from(container.querySelectorAllHTMLElement(*)).filter((el) { if (el.offsetHeight 1) return false; const style window.getComputedStyle(el); return style.display ! none style.visibility ! hidden; }); const maxHeight container.offsetHeight; const points [0]; let guard 0; while (points[points.length - 1] pageHeightPxCss maxHeight) { if (guard 2000) break; const current points[points.length - 1]; const rawNext current pageHeightPxCss; let bestTop -1; for (const el of elements) { if (el.offsetHeight pageHeightPxCss) continue; const top getElementTop(el, container); const bottom top el.offsetHeight; if (top rawNext bottom rawNext top current - 1) { bestTop Math.max(bestTop, top); } } const next bestTop current ? bestTop : rawNext; points.push(next); } return points; } export async function exportElementToPdf( dom: HTMLElement, options: ExportPdfOptions {} ) { const { scale 2, fileName export.pdf, pageWidthMM 210, pageHeightMM 297, } options; const canvas await html2canvas(dom, { scale, useCORS: true, backgroundColor: #ffffff, logging: false, }); const pdfScale canvas.width / dom.clientWidth; const pageHeightPxCss (canvas.width * pageHeightMM) / pageWidthMM / pdfScale; const points getSplitPoints(dom, pageHeightPxCss); const pdf new jsPDF({ orientation: p, unit: mm, format: a4 }); points.forEach((startCss, index) { const start Math.floor(startCss * pdfScale); const endCss index points.length - 1 ? dom.offsetHeight : points[index 1]; const end Math.ceil(endCss * pdfScale); const height end - start; if (height 1) return; const segment document.createElement(canvas); segment.width canvas.width; segment.height height; const ctx segment.getContext(2d); if (!ctx) return; ctx.drawImage(canvas, 0, start, canvas.width, height, 0, 0, canvas.width, height); const imgHeightMM (height * pageWidthMM) / canvas.width; if (index 0) pdf.addPage(); pdf.addImage(segment.toDataURL(image/jpeg, 0.95), JPEG, 0, 0, pageWidthMM, imgHeightMM); }); pdf.save(fileName); }组件里的调用方式很简单关键是拿到导出容器的 refscript setup langts import { ref, nextTick } from vue; import { exportElementToPdf } from /utils/exportPdf; const pageRef refHTMLElement(); const exporting ref(false); async function handleExport() { if (!pageRef.value) return; exporting.value true; try { await nextTick(); await exportElementToPdf(pageRef.value, { fileName: 工单详情.pdf, }); } finally { exporting.value false; } } /script template div div refpageRef classexport-page !-- 需要导出的页面内容 -- /div button :disabledexporting clickhandleExport导出 PDF/button /div /template这里的nextTick()很关键。如果表格数据是接口返回后渲染的必须等 DOM 更新完再截图否则截到的是空表格。切成多段 JPEG 后PDF 整体体积会比整张长图加负偏移小不少因为每段只保留需要的像素toDataURL 编码时也更高效。在长页面场景下体感差距挺明显。5. 上生产环境之后踩到的其他坑5.1 跨域图片导致 Tainted Canvas这是最经典的坑。只要页面里有一张图片的域名和当前页面不一致而且服务器没返回 CORS 头html2canvas 绘制的 canvas 就会被标记为 Tainted调用toDataURL()时直接抛 SecurityError。排查方法看一眼控制台报错就能定位。解决思路分三步图片服务器返回Access-Control-Allow-Origin: *或允许当前域名前端给img加crossoriginanonymoushtml2canvas 配置加上useCORS: true如果是动态生成的图片也要记得设置crossOrigin属性const img new Image(); img.crossOrigin anonymous; img.src url;如果图片在第三方域名且没法改响应头那前端就无能为力了只能走后端代理下载再转 base64。5.2 字体和图片没加载完就截图图表页面最容易中招。ECharts 渲染是异步的图表动画还没结束就截图导出内容就是半渲染状态。还有自定义字体font-face没加载完时截图导出 PDF 里文字会变成默认字体排版全乱。我的处理方式是先等一下await document.fonts.ready; await Promise.all( Array.from(document.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 nextTick();再加上一小段setTimeout给浏览器的重绘留一点时间基本就不会出现半渲染状态了。5.3 scale 参数和图片编码格式的取舍scale决定 canvas 的分辨率。用 1 导出的 PDF 文字边缘发虚尤其在 Retina 屏上非常明显。用 2 效果就清晰很多文件体积也还能接受。如果页面特别长scale 太大会直接吃满内存这时候建议控制在 1.5 到 2 之间。格式方面PNG 适合有透明背景的场景但文件体积大JPEG 体积小但必须保证背景有颜色不然透明区域会偏黑。我在导出工具里默认用 JPEG 0.95同时强制backgroundColor: #ffffff兼顾清晰度和体积。5.4 固定定位、弹窗和懒加载这些截图刺客导出容器里如果出现position: fixed的元素被一起画进 PDF 里是常见问题。更常见的是导出时 loading 遮罩盖在内容上面结果遮罩被截图进去。我的方案是给这些元素统一加一个>