
前端导出 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 idexportContent h2项目验收报告/h2 p这是一段需要原样导出到 PDF 的内容。/p div classchart-box这里是图表区域/div /div button idexportBtn导出 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 生成 canvasjsPDF 创建空白 PDFaddImage 把 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 选择跑通再去研究避让算法。下篇会在这个基础上继续尤其是“元素级避让”“逐区块拼接”这两个方向能够显著提升导出的精致程度。到时候见。