前端导出 PDF 这件事,绕不开两个名字:jsPDF 和 html2canvas。我前阵子做项目验收报告导出,用户要求页面长什么样,PDF 就长什么样,思来想去还是回到了这套组合上。它并不完美,但如果目标是“所见即所得”,它比浏览器打印和纯服务端渲染都更快落地。写这篇上篇,是把我在实际项目中踩过的分页、模糊、跨域、超长页面问题整理出来。适合前端开发、全栈,以及所有需要把网页内容转成 PDF 交付的人参考。这篇文章不是只贴一个能跑的按钮就完事,我会把“为什么这么写”也讲清楚。比如为什么要用 html2canvas 截图而不是直接调 jsPDF 写文本,为什么分页要自己算像素高度,为什么导出图片总是发虚,为什么长页面导出时浏览器动不动崩溃。这些问题单看官方文档很难串起来,但它们是实际业务里最消耗时间的部分。上篇先讲原理、最小可运行示例、分页切割、清晰度控制和常见坑,下篇再往深处讲长文档的逐区块拼接、元素避让切断、水印页码和 PDF 压缩。1. 为什么方案是 jsPDF + html2canvas,而不是浏览器打印1.1 浏览器打印到底输在哪很多人第一反应是 window.print(),再加上几个 @media print 样式,页面就能打印成 PDF。这个方案对“简单内容”没问题,比如一篇文章、一个表单。但真正做导出功能时,浏览器打印的弱点会一个接一个冒出来:不同操作系统的打印默认页边距不一样,用户浏览器设置的缩放比例会直接改变输出结果,Chrome 和 Safari 在“另存为 PDF”时对背景色的处理也不同。最难受的是分页控制,你很难精确告诉浏览器“这块内容必须放在一页里”,更别提在每一页底部自动加页码、页眉、水印。如果你尝试过用打印方案做带复杂图表的报告,很快就会遇到一个局面:页面预览里一切正常,导出的 PDF 里某一行字被切掉一半,某几个色块消失了,页脚的日期还可能重叠。原因很简单,浏览器打印走的是另一个渲染通道,它和屏幕渲染天生不是完全等价。为了彻底摆脱这种“打印预览不可控”,我们才会走向截图方案。1.2 两个库的分工:截图后贴图jsPDF 和 html2canvas 的组合,本质是“截图后贴图”。html2canvas 负责把指定的 DOM 节点克隆一份、解析样式、最终绘制成一张 canvas 位图;jsPDF 负责创建 PDF 文档,把这张位图以页面为画布贴进去,然后保存。也就是说,你最终拿到的 PDF 是一页一页的图片,里面的文字不能被选中,文件体积也会比纯文本 PDF 大不少。这一点非常重要,它决定了后续几乎所有的坑。比如分页时不能直接用 jsPDF 的 text 方法换行,因为整页都是位图;又比如文字清晰度取决于 canvas 的分辨率,而不是 PDF 自身的矢量精度。理解了这一点,再看后面的代码就不会觉得奇怪。如果你需要的是“页面视觉完全一致”的合同、报告、截图快照,位图方案是性价比最高的;如果你需要的是“可搜索的小体积 PDF”,那就该换 jsPDF.text 加手动排版,而不是套截图。1.3 这个组合的适用边界用这套方案前,先明确目标。我个人的判断标准是:适合:视觉型文档,例如运营活动页面截图、数据看板快照、带复杂样式模板的合同、验收报告、电子签章页面。适合:页面元素不能被轻易重新排版,后端生成成本高,或者需要在前端即时导出的场景。不适合:几百页的电子书、大量纯文本内容、需要选中复制和检索的文件。不适合:超大型表格跨页展示,因为截图分页没法做到表头自动重复、行不被切断这样的复杂逻辑。在业务里,需求方通常只关心“样式别乱、字别糊、页数对”。这时候 jsPDF 加 html2canvas 是唯一能在最短时间内满足需求的路线。先把边界定好,后面遇到问题才不会硬扛。2. 最小可运行示例:从一个导出按钮到一张 PDF2.1 安装和引包如果你用 npm 或现代构建工具,安装非常简单:npm install jspdf html2canvas注意包名是jspdf,不是jsPDF。引入时一般这样写:import { jsPDF } from 'jspdf'; import html2canvas from 'html2canvas';如果你不想用打包工具,也可以直接引 dist 目录下的 UMD 文件,脚本加载后全局变量分别是window.jspdf.jsPDF和window.html2canvas。因为 html2canvas 在 npm 上同时提供 commonjs 和 es module 两种格式,平时容易踩到引入错误,建议项目里统一用按需导入的方式。版本方面我一般会锁定一个稳定版本,不盲目追新。早几年 html2canvas 0.x 和 1.x 的行为差别很大,升级后经常出现样式渲染不一致,所以在生产项目里我会把版本锁死,后续只做安全补丁升级。2.2 第一步导出单页 PDF我们先写一个最简单、能跑的版本。假设页面里有这样一段内容:<div id="exportContent"> <h2>项目验收报告</h2> <p>这是一段需要原样导出到 PDF 的内容。</p> <div class="chart-box">这里是图表区域</div> </div> <button id="exportBtn">导出 PDF</button>对应的导出函数如下:async function exportSinglePagePdf() { const element = document.getElementById('exportContent'); const canvas = await html2canvas(element, { scale: 2, useCORS: true, backgroundColor: '#ffffff' }); const pdf = new jsPDF('p', 'mm', 'a4'); const pageWidth = pdf.internal.pageSize.getWidth(); // 210 const pageHeight = pdf.internal.pageSize.getHeight(); // 297 const margin = 10; const contentWidth = pageWidth - margin * 2; const imgHeight = canvas.height * contentWidth / canvas.width; pdf.addImage( canvas.toDataURL('image/jpeg', 0.95), 'JPEG', margin, margin, contentWidth, imgHeight ); pdf.save('report.pdf'); }这段代码虽然只有十几行,但它已经包含了这套组合最核心的三步:html2canvas 生成 canvas,jsPDF 创建空白 PDF,addImage 把 canvas 放进 PDF。第一次跑通后,你会发现导出的 PDF 内容可能超出页面底部,没关系,这是单页版,下一篇开始就处理分页。2.3 为什么必须换算毫米单位这里有个很容易被忽略的单位问题。jsPDF 默认使用毫米作为单位,而 html2canvas 给出的 canvas.width 和 canvas.height 是像素,二者不能直接混用。如果你把 canvas.width 直接传给 addImage,图片在 PDF 里会按 1 像素 = 1 毫米绘制,结果就是图片大到只剩一角。正确做法是先确定一个显示宽度,然后按 canvas 的宽高比算出高度。上面代码里的canvas.height * contentWidth / canvas.width就是这一层换算。拿到毫米高度后,再决定要不要分页、需要几张 A4 纸。很多初学者导出的 PDF 是空白页或半页,十有八九是这里没换算对。3. 多页 PDF 的核心:长页面截图后的分页切割逻辑3.1 为什么不能直接把完整 canvas 塞进 jsPDF业务里的 PDF 通常不止一页,但 html2canvas 只能产生一张完整的长图。如果你把一整张超高 canvas 直接 addImage 到 PDF,结果只有两种:要么塞进一页导致内容压缩到看不清,要么写到页面边界外,最终只显示一部分。jsPDF 本身不会帮你自动分页。它的 addPage 只是增加一张空白画布,没有“检测内容溢出然后自动换页”的能力。所以分页这事必须在前端手动完成:先确定每一页 PDF 能显示多少内容,再按这个高度把 canvas 切成若干段,每一段通过 addImage 放到单独的一页里。3.2 按高度切片:一份可复制的分页代码我直接给出实际项目里验证过的分页切割函数。这个函数不是最复杂的,但足够稳定,能够处理大多数长页面:async function exportContentToPdf(elementId) { const element = document.getElementById(elementId); const canvas = await html2canvas(element, { scale: 2, useCORS: true, backgroundColor: '#ffffff' }); const pdf = new jsPDF('p', 'mm', 'a4'); const pageWidth = pdf.internal.pageSize.getWidth(); const pageHeight = pdf.internal.pageSize.getHeight(); const margin = 10; const contentWidth = pageWidth - margin * 2; const contentHeight = pageHeight - margin * 2; const canvasWidth = canvas.width; const canvasHeight = canvas.height; const pxPerMm = canvasWidth / contentWidth; const pageHeightPx = contentHeight * pxPerMm; let renderedHeightPx = 0; let pageIndex = 0; while (renderedHeightPx < canvasHeight) { const sliceHeightPx = Math.min(pageHeightPx, canvasHeight - renderedHeightPx); const sliceCanvas = document.createElement('canvas'); sliceCanvas.width = canvasWidth; sliceCanvas.height = sliceHeightPx; const ctx = sliceCanvas.getContext('2d'); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, canvasWidth, sliceHeightPx); ctx.drawImage(canvas, 0, renderedHeightPx, canvasWidth, sliceHeightPx, 0, 0, canvasWidth, sliceHeightPx); if (pageIndex > 0) { pdf.addPage(); } const imgData = sliceCanvas.toDataURL('image/jpeg', 0.92); const imgWidthMm = contentWidth; const imgHeightMm = sliceHeightPx / pxPerMm; pdf.addImage(imgData, 'JPEG', margin, margin, imgWidthMm, imgHeightMm); renderedHeightPx += sliceHeightPx; pageIndex++; } pdf.save('multipage.pdf'); }这段代码的核心思路是:先算出 A4 可打印区域对应的像素高度pageHeightPx,然后从 canvas 顶部开始,每次截取一小段 canvas 作为独立的切片,再换算成毫米高度放入 PDF。切片之间不会重叠,也不会遗漏,最终页数就是Math.ceil(canvasHeight / pageHeightPx)。对于内容刚好卡在边界的情况,最后一页高度不足一页是正常的。3.3 避免文字被拦腰切断:元素边界对齐的思路按固定高度切片有一个绕不开的问题:如果页面内容有标题、段落、卡片,切片点可能会落在一行文字中间,输出后看起来就像一刀切在了文字上。要彻底解决,需要做“元素边界对齐”。思路是这样的:不要在sliceHeightPx处直接切,而是往前找一个“安全切点”。一个常见做法是先收集目标区域内所有可能被截断的元素,例如设置了page-break-inside: avoid样式的卡片、带边框的盒子、表格行,然后获取它们的offsetTop和offsetHeight。当计算出的当前切割点落进某个元素中间时,就把切割点调整到这个元素底部或顶部。因为 html2canvas 的 canvas 是基于 DOM 克隆渲染,canvas 像素坐标和页面元素坐标存在一个 scale 倍数的对应关系,调整时记得乘以scale。下面是一个简化版的伪代码,演示怎么求出最接近目标高度但不会切断元素的分页点:function findSafeCutY(els, startY, endY, scale) { let safeY = endY; for (const el of els) { const top = el.offsetTop * scale; const bottom = (el.offsetTop + el.offsetHeight) * scale; if (bottom > startY && bottom < endY) { // 当前切割点在元素内部,让出元素底部 safeY = Math.min(Math.max(safeY, bottom), endY); } } return safeY; }这个函数不完美,但对于块状卡片较多的页面,已经能把切断率从“经常发生”降到“偶尔发生”。真正要求高的场景,我会在下篇讲按区块逐段截图,也就是每个内容块单独截图排页,而不是整页 canvas 一刀切,那样永远不会切断块内部。4. 清晰度、跨域图片和文件体积:三个绕不开的细节4.1 scale 参数不是越大越好很多教程会告诉你用scale: 2,但很少有人解释为什么。html2canvas 默认的 scale 是 1,也就是说生成的 canvas 像素数等于元素 CSS 像素数。在普通屏幕下,这看起来没问题,但 PDF 里的图片是按物理尺寸呈现的,同样的内容如果分辨率不足,放大后边缘发虚、文字发糊。使用scale: 2相当于把 canvas 的宽和高都放大一倍,图片信息量变成原来的 4 倍。用scale: window.devicePixelRatio则能匹配 Retina 屏幕的物理像素密度,导出后和你在屏幕上看到的清晰度最接近。但 scale 不是越大越好,因为 canvas 的内存占用按面积增长,执行时间也明显增加。我实际测试过的体感如下:scale清晰度导出时间内存压力适用场景1一般快低长页面快速预览,不追求打印2清晰中等中等大多数业务报表、合同3很清慢高精细设计稿、印刷需求4及以上边际收益低很慢可能崩溃不建议生产环境使用经验上,先固定 scale 2,如果导出后小字仍然发虚,再加到 3。多数屏幕尺寸下,2 到 3 已经是清晰度与性能最好的平衡点。4.2 JPEG 与 PNG 的选择canvas 转图片时,常用toDataURL('image/jpeg', quality)或toDataURL('image/png')。JPEG 体积小,但对文字边缘有压缩失真,而且 JPEG 不支持透明背景;PNG 清晰无损,但文件体积经常是 JPEG 的几倍甚至十几倍。所以我习惯这样做:页面底色是白色或浅色时,用 JPEG 加 0.92 质量;页面有透明底色或大量小字号文字时,先用 PNG 导出一版,确认体积可接受再上线。还有一个细节:在切片 canvas 上绘制前,我会先填充白色背景再做 drawImage,这样即使页面某处原本透明,导出 JPEG 后也不会变成黑色区块。4.3 跨域图片与字体加载html2canvas 本质上是在浏览器里重新绘制 DOM,它读取图片数据时会受到 canvas 跨域限制。如果你的页面图片来自另一个域名,并且图片服务没有返回Access-Control-Allow-Origin头,canvas 会被污染,轻则图片区域空白,重则整张 canvas 无法导出。处理方式分两步。第一步,给图片加上跨域属性并确保服务端允许跨域:const img = new Image(); img.crossOrigin = 'anonymous'; img.src = 'https://example.com/image.jpg'; await img.decode();第二步,在 html2canvas 里开启useCORS: true,同时设置allowTaint: false。如果图片服务端无法改响应头,那就只能走图片转 base64、同域代理这类方案。还有一个经常被忽略的点:导出前要等字体加载完成,否则 canvas 里可能出现后备字体,和页面显示不一致。建议在调用 html2canvas 前执行:await document.fonts.ready;这能规避相当一部分“导出后字体变了”的投诉。5. 实测高频坑:从白屏、截断到内存暴涨5.1 html2canvas 不支持的 CSS 特性html2canvas 不是浏览器本身,它支持的 CSS 特性是有边界的。我踩得最多的三个坑:backdrop-filter完全不支持,元素带毛玻璃效果时导出往往只保留半透明底色;复杂box-shadow虽然能渲染但开销很大,多了会明显拖慢导出;部分现代 CSS 颜色函数和混合模式在 1.4.1 版本下也可能出问题。遇到这种场景,我的原则是导出页尽量用简单可靠的颜色和布局,或者准备一套专门用于导出的“降级样式”,利用 html2canvas 的onclone回调在克隆文档里临时替换 class。onclone是 html2canvas 提供的一个很有用的钩子,它会在克隆文档准备完成后、截图渲染前执行。你可以在这里隐藏不需要导出的元素、给节点加替代类名,甚至提前把远程图片替换成本地图片,都不会影响页面本身。5.2 导出结果白屏的常见原因白屏不是“bug”,而是某个前置条件没满足。最常见的几个原因:目标元素是display: none或者宽度为 0;元素在 iframe、webgl 里,html2canvas 抓不到;图片跨域导致 canvas 被污染;导出前元素尚未渲染完成。排错顺序我一般按照“可见性 -> 尺寸 -> 资源加载 -> 跨域”来走。如果你碰到的是“预览时一切正常,导出后白屏”,先打开控制台看是否有Tainted canvases或Unable to load image的报错。没有报错的话,检查一下目标元素是否被 CSS 隐藏了,或者是否用了 canvas 绘制但来源是跨域图片。html2canvas 的logging: true选项也能帮你看到它具体卡在哪一步。5.3 超长页面的内存与 canvas 尺寸上限这个坑最隐蔽,也最容易在生产环境炸。浏览器对 canvas 有一个最大面积限制,不同浏览器阈值不同,但共同点是:当你的页面高度超过几千像素,再加上 scale 2 的放大,canvas 总像素数很快会冲破安全线,导致 html2canvas 报错、浏览器标签页崩溃,或者生成的白屏 PDF。我习惯把canvas.height > 6000作为警戒线。一旦超过,就不再对整个页面做一次 html2canvas,而是把页面拆成几个区块,对每个区块分别导出,再按区块拼接成分页。或者干脆把 scale 降到 1,牺牲一部分清晰度来保稳定。具体判断可以在导出函数开头加一段:const canvas = await html2canvas(element, { scale: 2 }); if (canvas.height > 8000 || canvas.width * canvas.height > 12000000) { console.warn('canvas 尺寸接近浏览器上限,建议拆分导出'); }除了 canvas 上限,切片时也要注意内存。上面分页代码里,每次循环创建的 sliceCanvas 会在循环结束后释放,但如果一次性把几十个切片都放进数组,内存峰值会很高。我这里直接把它写在导出函数内部,用完即丢,效果更好。5.4 版本锁定和效果差异html2canvas 1.4.1 和 1.4.0 之间已经有一些渲染差异,更不要说 0.5 版本和 1.x 的巨大差别。我在不同版本之间切换测试过,同样的页面,旧版本可能边框宽度更粗,新版本对 CSS 变量的支持更好。生产项目中如果导出效果已经稳定,尽量不要随意升级。jsPDF 也一样,早期 1.x 和 2.x 在pdf.internal.pageSize和 addImage 行为上都有变化,代码里写死版本号,升级前单独回归测试导出功能,会省掉很多线上问题。6. 上篇的边界与下篇的方向6.1 这套方案的适用边界:真实体感写到这里,我再分享一点个人体感。jsPDF 加 html2canvas 的组合,放到生产环境里完全可用,但它更像“重剑无锋”,谈不上精巧。你付出的是文件体积和执行时间,换来的是开发速度和样式还原度。如果你的需求是把几千字纯文本变成一份可搜索的 PDF,我会建议你别用截图,直接 jsPDF.text 一行行排版,生成的文件又小又干净。但如果是合同、验收报告、运营模板,用户不会关心 PDF 里能不能选词,他只关心“看起来和页面一样”,那么截图方案就是最优解。实际项目里最让我印象深刻的教训是:先确认页面设计是否适合截图导出,再动手写代码。如果页面本身用了大量backdrop-filter、动态背景、外部 iframe,那无论你用多新的版本都要踩坑。提前和设计师、产品确认导出页的视觉边界,比事后修补 CSS 高效得多。6.2 下篇继续深入的内容这篇上篇覆盖的是基础方案和最常见的坑。真正复杂的项目里,还会有几个更深入的话题没有展开:长文档如何按内容区块逐段截图,做到完全不会切断标题;如何在每一页 PDF 上统一叠加页码、水印、页眉;前端生成多份 PDF 时如何压缩体积和内存占用;移动端 Safari 下的兼容性处理。这些我打算放到下篇里逐一讲。如果你现在刚好在用这套组合做导出,建议先把上篇的分页切割和 scale 选择跑通,再去研究避让算法。下篇会在这个基础上继续,尤其是“元素级避让”“逐区块拼接”这两个方向,能够显著提升导出的精致程度。到时候见。
前端导出 PDF 这件事,绕不开两个名字:jsPDF 和 html2canvas。我前阵子做项目验收报告导出,用户要求页面长什么样,PDF 就长什么样,思来想去还是回到了这套组合上。它并不完美,但如果目标是“所见即所得”,它比浏览器打印和纯服务端渲染都更快落地。写这篇上篇,是把我在实际项目中踩过的分页、模糊、跨域、超长页面问题整理出来。适合前端开发、全栈,以及所有需要把网页内容转成 PDF 交付的人参考。这篇文章不是只贴一个能跑的按钮就完事,我会把“为什么这么写”也讲清楚。比如为什么要用 html2canvas 截图而不是直接调 jsPDF 写文本,为什么分页要自己算像素高度,为什么导出图片总是发虚,为什么长页面导出时浏览器动不动崩溃。这些问题单看官方文档很难串起来,但它们是实际业务里最消耗时间的部分。上篇先讲原理、最小可运行示例、分页切割、清晰度控制和常见坑,下篇再往深处讲长文档的逐区块拼接、元素避让切断、水印页码和 PDF 压缩。1. 为什么方案是 jsPDF + html2canvas,而不是浏览器打印1.1 浏览器打印到底输在哪很多人第一反应是 window.print(),再加上几个 @media print 样式,页面就能打印成 PDF。这个方案对“简单内容”没问题,比如一篇文章、一个表单。但真正做导出功能时,浏览器打印的弱点会一个接一个冒出来:不同操作系统的打印默认页边距不一样,用户浏览器设置的缩放比例会直接改变输出结果,Chrome 和 Safari 在“另存为 PDF”时对背景色的处理也不同。最难受的是分页控制,你很难精确告诉浏览器“这块内容必须放在一页里”,更别提在每一页底部自动加页码、页眉、水印。如果你尝试过用打印方案做带复杂图表的报告,很快就会遇到一个局面:页面预览里一切正常,导出的 PDF 里某一行字被切掉一半,某几个色块消失了,页脚的日期还可能重叠。原因很简单,浏览器打印走的是另一个渲染通道,它和屏幕渲染天生不是完全等价。为了彻底摆脱这种“打印预览不可控”,我们才会走向截图方案。1.2 两个库的分工:截图后贴图jsPDF 和 html2canvas 的组合,本质是“截图后贴图”。html2canvas 负责把指定的 DOM 节点克隆一份、解析样式、最终绘制成一张 canvas 位图;jsPDF 负责创建 PDF 文档,把这张位图以页面为画布贴进去,然后保存。也就是说,你最终拿到的 PDF 是一页一页的图片,里面的文字不能被选中,文件体积也会比纯文本 PDF 大不少。这一点非常重要,它决定了后续几乎所有的坑。比如分页时不能直接用 jsPDF 的 text 方法换行,因为整页都是位图;又比如文字清晰度取决于 canvas 的分辨率,而不是 PDF 自身的矢量精度。理解了这一点,再看后面的代码就不会觉得奇怪。如果你需要的是“页面视觉完全一致”的合同、报告、截图快照,位图方案是性价比最高的;如果你需要的是“可搜索的小体积 PDF”,那就该换 jsPDF.text 加手动排版,而不是套截图。1.3 这个组合的适用边界用这套方案前,先明确目标。我个人的判断标准是:适合:视觉型文档,例如运营活动页面截图、数据看板快照、带复杂样式模板的合同、验收报告、电子签章页面。适合:页面元素不能被轻易重新排版,后端生成成本高,或者需要在前端即时导出的场景。不适合:几百页的电子书、大量纯文本内容、需要选中复制和检索的文件。不适合:超大型表格跨页展示,因为截图分页没法做到表头自动重复、行不被切断这样的复杂逻辑。在业务里,需求方通常只关心“样式别乱、字别糊、页数对”。这时候 jsPDF 加 html2canvas 是唯一能在最短时间内满足需求的路线。先把边界定好,后面遇到问题才不会硬扛。2. 最小可运行示例:从一个导出按钮到一张 PDF2.1 安装和引包如果你用 npm 或现代构建工具,安装非常简单:npm install jspdf html2canvas注意包名是jspdf,不是jsPDF。引入时一般这样写:import { jsPDF } from 'jspdf'; import html2canvas from 'html2canvas';如果你不想用打包工具,也可以直接引 dist 目录下的 UMD 文件,脚本加载后全局变量分别是window.jspdf.jsPDF和window.html2canvas。因为 html2canvas 在 npm 上同时提供 commonjs 和 es module 两种格式,平时容易踩到引入错误,建议项目里统一用按需导入的方式。版本方面我一般会锁定一个稳定版本,不盲目追新。早几年 html2canvas 0.x 和 1.x 的行为差别很大,升级后经常出现样式渲染不一致,所以在生产项目里我会把版本锁死,后续只做安全补丁升级。2.2 第一步导出单页 PDF我们先写一个最简单、能跑的版本。假设页面里有这样一段内容:<div id="exportContent"> <h2>项目验收报告</h2> <p>这是一段需要原样导出到 PDF 的内容。</p> <div class="chart-box">这里是图表区域</div> </div> <button id="exportBtn">导出 PDF</button>对应的导出函数如下:async function exportSinglePagePdf() { const element = document.getElementById('exportContent'); const canvas = await html2canvas(element, { scale: 2, useCORS: true, backgroundColor: '#ffffff' }); const pdf = new jsPDF('p', 'mm', 'a4'); const pageWidth = pdf.internal.pageSize.getWidth(); // 210 const pageHeight = pdf.internal.pageSize.getHeight(); // 297 const margin = 10; const contentWidth = pageWidth - margin * 2; const imgHeight = canvas.height * contentWidth / canvas.width; pdf.addImage( canvas.toDataURL('image/jpeg', 0.95), 'JPEG', margin, margin, contentWidth, imgHeight ); pdf.save('report.pdf'); }这段代码虽然只有十几行,但它已经包含了这套组合最核心的三步:html2canvas 生成 canvas,jsPDF 创建空白 PDF,addImage 把 canvas 放进 PDF。第一次跑通后,你会发现导出的 PDF 内容可能超出页面底部,没关系,这是单页版,下一篇开始就处理分页。2.3 为什么必须换算毫米单位这里有个很容易被忽略的单位问题。jsPDF 默认使用毫米作为单位,而 html2canvas 给出的 canvas.width 和 canvas.height 是像素,二者不能直接混用。如果你把 canvas.width 直接传给 addImage,图片在 PDF 里会按 1 像素 = 1 毫米绘制,结果就是图片大到只剩一角。正确做法是先确定一个显示宽度,然后按 canvas 的宽高比算出高度。上面代码里的canvas.height * contentWidth / canvas.width就是这一层换算。拿到毫米高度后,再决定要不要分页、需要几张 A4 纸。很多初学者导出的 PDF 是空白页或半页,十有八九是这里没换算对。3. 多页 PDF 的核心:长页面截图后的分页切割逻辑3.1 为什么不能直接把完整 canvas 塞进 jsPDF业务里的 PDF 通常不止一页,但 html2canvas 只能产生一张完整的长图。如果你把一整张超高 canvas 直接 addImage 到 PDF,结果只有两种:要么塞进一页导致内容压缩到看不清,要么写到页面边界外,最终只显示一部分。jsPDF 本身不会帮你自动分页。它的 addPage 只是增加一张空白画布,没有“检测内容溢出然后自动换页”的能力。所以分页这事必须在前端手动完成:先确定每一页 PDF 能显示多少内容,再按这个高度把 canvas 切成若干段,每一段通过 addImage 放到单独的一页里。3.2 按高度切片:一份可复制的分页代码我直接给出实际项目里验证过的分页切割函数。这个函数不是最复杂的,但足够稳定,能够处理大多数长页面:async function exportContentToPdf(elementId) { const element = document.getElementById(elementId); const canvas = await html2canvas(element, { scale: 2, useCORS: true, backgroundColor: '#ffffff' }); const pdf = new jsPDF('p', 'mm', 'a4'); const pageWidth = pdf.internal.pageSize.getWidth(); const pageHeight = pdf.internal.pageSize.getHeight(); const margin = 10; const contentWidth = pageWidth - margin * 2; const contentHeight = pageHeight - margin * 2; const canvasWidth = canvas.width; const canvasHeight = canvas.height; const pxPerMm = canvasWidth / contentWidth; const pageHeightPx = contentHeight * pxPerMm; let renderedHeightPx = 0; let pageIndex = 0; while (renderedHeightPx < canvasHeight) { const sliceHeightPx = Math.min(pageHeightPx, canvasHeight - renderedHeightPx); const sliceCanvas = document.createElement('canvas'); sliceCanvas.width = canvasWidth; sliceCanvas.height = sliceHeightPx; const ctx = sliceCanvas.getContext('2d'); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, canvasWidth, sliceHeightPx); ctx.drawImage(canvas, 0, renderedHeightPx, canvasWidth, sliceHeightPx, 0, 0, canvasWidth, sliceHeightPx); if (pageIndex > 0) { pdf.addPage(); } const imgData = sliceCanvas.toDataURL('image/jpeg', 0.92); const imgWidthMm = contentWidth; const imgHeightMm = sliceHeightPx / pxPerMm; pdf.addImage(imgData, 'JPEG', margin, margin, imgWidthMm, imgHeightMm); renderedHeightPx += sliceHeightPx; pageIndex++; } pdf.save('multipage.pdf'); }这段代码的核心思路是:先算出 A4 可打印区域对应的像素高度pageHeightPx,然后从 canvas 顶部开始,每次截取一小段 canvas 作为独立的切片,再换算成毫米高度放入 PDF。切片之间不会重叠,也不会遗漏,最终页数就是Math.ceil(canvasHeight / pageHeightPx)。对于内容刚好卡在边界的情况,最后一页高度不足一页是正常的。3.3 避免文字被拦腰切断:元素边界对齐的思路按固定高度切片有一个绕不开的问题:如果页面内容有标题、段落、卡片,切片点可能会落在一行文字中间,输出后看起来就像一刀切在了文字上。要彻底解决,需要做“元素边界对齐”。思路是这样的:不要在sliceHeightPx处直接切,而是往前找一个“安全切点”。一个常见做法是先收集目标区域内所有可能被截断的元素,例如设置了page-break-inside: avoid样式的卡片、带边框的盒子、表格行,然后获取它们的offsetTop和offsetHeight。当计算出的当前切割点落进某个元素中间时,就把切割点调整到这个元素底部或顶部。因为 html2canvas 的 canvas 是基于 DOM 克隆渲染,canvas 像素坐标和页面元素坐标存在一个 scale 倍数的对应关系,调整时记得乘以scale。下面是一个简化版的伪代码,演示怎么求出最接近目标高度但不会切断元素的分页点:function findSafeCutY(els, startY, endY, scale) { let safeY = endY; for (const el of els) { const top = el.offsetTop * scale; const bottom = (el.offsetTop + el.offsetHeight) * scale; if (bottom > startY && bottom < endY) { // 当前切割点在元素内部,让出元素底部 safeY = Math.min(Math.max(safeY, bottom), endY); } } return safeY; }这个函数不完美,但对于块状卡片较多的页面,已经能把切断率从“经常发生”降到“偶尔发生”。真正要求高的场景,我会在下篇讲按区块逐段截图,也就是每个内容块单独截图排页,而不是整页 canvas 一刀切,那样永远不会切断块内部。4. 清晰度、跨域图片和文件体积:三个绕不开的细节4.1 scale 参数不是越大越好很多教程会告诉你用scale: 2,但很少有人解释为什么。html2canvas 默认的 scale 是 1,也就是说生成的 canvas 像素数等于元素 CSS 像素数。在普通屏幕下,这看起来没问题,但 PDF 里的图片是按物理尺寸呈现的,同样的内容如果分辨率不足,放大后边缘发虚、文字发糊。使用scale: 2相当于把 canvas 的宽和高都放大一倍,图片信息量变成原来的 4 倍。用scale: window.devicePixelRatio则能匹配 Retina 屏幕的物理像素密度,导出后和你在屏幕上看到的清晰度最接近。但 scale 不是越大越好,因为 canvas 的内存占用按面积增长,执行时间也明显增加。我实际测试过的体感如下:scale清晰度导出时间内存压力适用场景1一般快低长页面快速预览,不追求打印2清晰中等中等大多数业务报表、合同3很清慢高精细设计稿、印刷需求4及以上边际收益低很慢可能崩溃不建议生产环境使用经验上,先固定 scale 2,如果导出后小字仍然发虚,再加到 3。多数屏幕尺寸下,2 到 3 已经是清晰度与性能最好的平衡点。4.2 JPEG 与 PNG 的选择canvas 转图片时,常用toDataURL('image/jpeg', quality)或toDataURL('image/png')。JPEG 体积小,但对文字边缘有压缩失真,而且 JPEG 不支持透明背景;PNG 清晰无损,但文件体积经常是 JPEG 的几倍甚至十几倍。所以我习惯这样做:页面底色是白色或浅色时,用 JPEG 加 0.92 质量;页面有透明底色或大量小字号文字时,先用 PNG 导出一版,确认体积可接受再上线。还有一个细节:在切片 canvas 上绘制前,我会先填充白色背景再做 drawImage,这样即使页面某处原本透明,导出 JPEG 后也不会变成黑色区块。4.3 跨域图片与字体加载html2canvas 本质上是在浏览器里重新绘制 DOM,它读取图片数据时会受到 canvas 跨域限制。如果你的页面图片来自另一个域名,并且图片服务没有返回Access-Control-Allow-Origin头,canvas 会被污染,轻则图片区域空白,重则整张 canvas 无法导出。处理方式分两步。第一步,给图片加上跨域属性并确保服务端允许跨域:const img = new Image(); img.crossOrigin = 'anonymous'; img.src = 'https://example.com/image.jpg'; await img.decode();第二步,在 html2canvas 里开启useCORS: true,同时设置allowTaint: false。如果图片服务端无法改响应头,那就只能走图片转 base64、同域代理这类方案。还有一个经常被忽略的点:导出前要等字体加载完成,否则 canvas 里可能出现后备字体,和页面显示不一致。建议在调用 html2canvas 前执行:await document.fonts.ready;这能规避相当一部分“导出后字体变了”的投诉。5. 实测高频坑:从白屏、截断到内存暴涨5.1 html2canvas 不支持的 CSS 特性html2canvas 不是浏览器本身,它支持的 CSS 特性是有边界的。我踩得最多的三个坑:backdrop-filter完全不支持,元素带毛玻璃效果时导出往往只保留半透明底色;复杂box-shadow虽然能渲染但开销很大,多了会明显拖慢导出;部分现代 CSS 颜色函数和混合模式在 1.4.1 版本下也可能出问题。遇到这种场景,我的原则是导出页尽量用简单可靠的颜色和布局,或者准备一套专门用于导出的“降级样式”,利用 html2canvas 的onclone回调在克隆文档里临时替换 class。onclone是 html2canvas 提供的一个很有用的钩子,它会在克隆文档准备完成后、截图渲染前执行。你可以在这里隐藏不需要导出的元素、给节点加替代类名,甚至提前把远程图片替换成本地图片,都不会影响页面本身。5.2 导出结果白屏的常见原因白屏不是“bug”,而是某个前置条件没满足。最常见的几个原因:目标元素是display: none或者宽度为 0;元素在 iframe、webgl 里,html2canvas 抓不到;图片跨域导致 canvas 被污染;导出前元素尚未渲染完成。排错顺序我一般按照“可见性 -> 尺寸 -> 资源加载 -> 跨域”来走。如果你碰到的是“预览时一切正常,导出后白屏”,先打开控制台看是否有Tainted canvases或Unable to load image的报错。没有报错的话,检查一下目标元素是否被 CSS 隐藏了,或者是否用了 canvas 绘制但来源是跨域图片。html2canvas 的logging: true选项也能帮你看到它具体卡在哪一步。5.3 超长页面的内存与 canvas 尺寸上限这个坑最隐蔽,也最容易在生产环境炸。浏览器对 canvas 有一个最大面积限制,不同浏览器阈值不同,但共同点是:当你的页面高度超过几千像素,再加上 scale 2 的放大,canvas 总像素数很快会冲破安全线,导致 html2canvas 报错、浏览器标签页崩溃,或者生成的白屏 PDF。我习惯把canvas.height > 6000作为警戒线。一旦超过,就不再对整个页面做一次 html2canvas,而是把页面拆成几个区块,对每个区块分别导出,再按区块拼接成分页。或者干脆把 scale 降到 1,牺牲一部分清晰度来保稳定。具体判断可以在导出函数开头加一段:const canvas = await html2canvas(element, { scale: 2 }); if (canvas.height > 8000 || canvas.width * canvas.height > 12000000) { console.warn('canvas 尺寸接近浏览器上限,建议拆分导出'); }除了 canvas 上限,切片时也要注意内存。上面分页代码里,每次循环创建的 sliceCanvas 会在循环结束后释放,但如果一次性把几十个切片都放进数组,内存峰值会很高。我这里直接把它写在导出函数内部,用完即丢,效果更好。5.4 版本锁定和效果差异html2canvas 1.4.1 和 1.4.0 之间已经有一些渲染差异,更不要说 0.5 版本和 1.x 的巨大差别。我在不同版本之间切换测试过,同样的页面,旧版本可能边框宽度更粗,新版本对 CSS 变量的支持更好。生产项目中如果导出效果已经稳定,尽量不要随意升级。jsPDF 也一样,早期 1.x 和 2.x 在pdf.internal.pageSize和 addImage 行为上都有变化,代码里写死版本号,升级前单独回归测试导出功能,会省掉很多线上问题。6. 上篇的边界与下篇的方向6.1 这套方案的适用边界:真实体感写到这里,我再分享一点个人体感。jsPDF 加 html2canvas 的组合,放到生产环境里完全可用,但它更像“重剑无锋”,谈不上精巧。你付出的是文件体积和执行时间,换来的是开发速度和样式还原度。如果你的需求是把几千字纯文本变成一份可搜索的 PDF,我会建议你别用截图,直接 jsPDF.text 一行行排版,生成的文件又小又干净。但如果是合同、验收报告、运营模板,用户不会关心 PDF 里能不能选词,他只关心“看起来和页面一样”,那么截图方案就是最优解。实际项目里最让我印象深刻的教训是:先确认页面设计是否适合截图导出,再动手写代码。如果页面本身用了大量backdrop-filter、动态背景、外部 iframe,那无论你用多新的版本都要踩坑。提前和设计师、产品确认导出页的视觉边界,比事后修补 CSS 高效得多。6.2 下篇继续深入的内容这篇上篇覆盖的是基础方案和最常见的坑。真正复杂的项目里,还会有几个更深入的话题没有展开:长文档如何按内容区块逐段截图,做到完全不会切断标题;如何在每一页 PDF 上统一叠加页码、水印、页眉;前端生成多份 PDF 时如何压缩体积和内存占用;移动端 Safari 下的兼容性处理。这些我打算放到下篇里逐一讲。如果你现在刚好在用这套组合做导出,建议先把上篇的分页切割和 scale 选择跑通,再去研究避让算法。下篇会在这个基础上继续,尤其是“元素级避让”“逐区块拼接”这两个方向,能够显著提升导出的精致程度。到时候见。