Vue3 页面导出 PDF 分页截断问题解决方案
2026/9/18 8:46:19 网站建设 项目流程

先说下背景:接了个需求,要把系统里的工单详情页导出成 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 区域单独导出成图片,然后用缩略图模式一张张滑过去。很快发现问题集中在每页底部和下一页顶部的交界处。

第三步,我在代码里加了几行调试日志,打印当前切割线的像素位置,然后遍历页面里跨这条线的元素,把它们的tagNameoffsetTopoffsetHeight打出来。结果非常直观:

切割线位置: 1123px 跨越切割线的元素: <tr> top=1100 height=50 <p> top=1150 height=24

切割线 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 遍历元素找跨线节点,把切割点吸附到元素顶部

核心算法分三步:

  1. 从当前切割点开始,算出原始的下一切割点rawNext
  2. 遍历导出容器内所有可见元素,找出所有满足top < rawNextbottom > rawNext的元素
  3. 如果存在这样的元素,就取其中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); }

注意这里的startYheight是 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.querySelectorAll<HTMLElement>('*')).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); }

组件里的调用方式很简单,关键是拿到导出容器的 ref:

<script setup lang="ts"> import { ref, nextTick } from 'vue'; import { exportElementToPdf } from '@/utils/exportPdf'; const pageRef = ref<HTMLElement>(); 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 ref="pageRef" class="export-page"> <!-- 需要导出的页面内容 --> </div> <button :disabled="exporting" @click="handleExport">导出 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>crossorigin="anonymous"
  • html2canvas 配置加上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 遮罩盖在内容上面,结果遮罩被截图进去。

我的方案是给这些元素统一加一个>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询