资讯动态

前端导出PDF实战:jsPDF+html2canvas原理、分页与避坑指南

发布时间:2026/10/18 3:35:22 来源:尧图企业网站定制
做前端这些年导出 PDF这四个字基本是后台管理系统里的钉子户需求。无论是财务报表、订单凭证还是审批单产品经理都会在某一天突然提出加个导出按钮吧。第一次我天真地以为调用浏览器打印就够了结果分页乱、样式塌、字体跑偏挨了一顿吐槽之后才老老实实把 jsPDF 与 html2canvas 这套组合学透。这篇文章先不整虚的直接从原理到实战把导出 PDF 这条链路拆成上篇重点覆盖技术选型、核心原理、单页与多页导出、避坑经验下一篇再聊高级定制与复杂表格场景。适合刚接手导出需求想快速落地、或正在被分页和清晰度折磨的前端开发者参考。1. 需求背景与技术选型为什么导出 PDF 让人头疼1.1 前端导出 PDF 的典型场景与隐藏痛点后台系统里最常见的三类导出需求第一类是数据型报表表格动辄几百行导出的 PDF 既希望内容完整又希望视觉上接近页面第二类是单据型页面比如订单确认单、审批回执通常有固定的排版、印章、签名区域第三类是富样式文档比如活动海报、结业证书对还原度要求极高。这三种需求有一个共同点前端手头只有一份已经渲染好的 DOM没有人会专门为导出再搭一套后端模板。表面上看导出 PDF 只是截图 生成文件两步但隐藏痛点一个都不少。页面样式是由浏览器实时计算的换台机器、换个浏览器渲染结果可能不同内容一旦超出单页范围怎么切分、怎么避免把一个表格从中间劈开导出文件的清晰度能不能打印中文、特殊字体能不能完整展示还有图片跨域、CSS 兼容性这类灰色地带。这些问题不提前搞清楚光一个导出按钮就能吃掉两三天工期。我个人的经验是接到这类需求先别急着写代码花半小时把方案选型聊明白后面能省下大量返工时间。这也是很多半路接手的老项目最容翻车的地方——上一任直接用打印命令凑合结果分页全靠浏览器心情后来整块推倒重来。1.2 主流方案横向对比我为什么最终选了 jspdf html2canvas市面上的前端 PDF 导出方案我按要不要服务器参与和最终 PDF 是图片还是文本两条线整理了一下大致有这么几类方案实现思路优势劣势合适场景浏览器打印 / 另存为 PDF调起浏览器原生打印零依赖、文本可选择样式还原不稳定、分页不可控简单文档、用户自己打印jspdf html2canvasDOM 转 canvas 再贴进 PDF所见即所得、还原度高文件体积偏大、文字不可搜索报表截图、单据、证书pdfmake 等纯文本方案用 JS 描述结构再排版文本可选、体积小需要重构布局、复杂样式难还原数据表格、合同文本无头浏览器服务端导出服务端用浏览器渲染后出 PDF还原度最高、功能最全部署成本高、并发压力大大型报表、批量导出我最终选 jspdf html2canvas 的核心理由有两个。一是大部分后台页面的样式本来就在浏览器里调好了再让前端用 pdfmake 重新描述一遍布局等于写两套模板维护成本翻倍二是不想为了一个导出功能动用服务器资源纯前端就能闭环的方案最适合中后台项目。它的代价——文字变成图片、文件偏大——在后两类需求里是可以接受的尤其是单据和证书这类本身就以视觉展示为主的场景。提示如果需求是我需要用户拿到 PDF 后能搜索、能复制里面的文字那别犹豫直接看 pdfmake 或无头浏览器方案。jspdf html2canvas 生成的 PDF 本质上是图片文字不可选择这一点在做技术选型时就要明确告诉产品经理。2. 核心原理拆解html2canvas 与 jsPDF 到底在做什么2.1 html2canvas它不是截图而是重绘很多初学者以为 html2canvas 是在截屏其实它做的事情要复杂得多。它会把目标 DOM 的树结构遍历一遍读取每个节点的计算样式computed style包括颜色、字体、边框、背景、定位、阴影然后在一个内部 canvas 上按自己的渲染逻辑画出来。换句话说它模仿的是浏览器渲染引擎但又不完全是浏览器渲染引擎所以会有一堆边界情况需要手动处理。这个机制解释了三个常见现象为什么不是所有 CSS 都支持比如某些高级滤镜和混合模式为什么图片没加载完时会导出白图它读取的是图片加载完成后的像素数据为什么跨域图片会让 canvas 被污染读取像素时浏览器安全策略介入。理解了重绘而非截图排错时思路会清晰很多。有一个我反复跟团队强调的点canvas 的污染不是 html2canvas 的 bug而是浏览器安全机制。只要 canvas 中出现了未通过跨域授权加载的图片toDataURL就会抛 SecurityError。这个认识到位之后遇到白屏导出基本能秒定位到图片跨域问题。2.2 canvas 到 PDF 的尺寸换算逻辑生成 PDF 时最核心的一步是把 canvas 的像素尺寸转换成 PDF 的物理尺寸毫米。jsPDF 默认单位为 mmA4 纵向纸是 210 × 297mm。屏幕 CSS 的 1px 按 96 DPI 计算1 英寸 25.4mm所以 96 DPI 下 1px ≈ 0.2646mm一页 A4 宽度换算下来大概相当于 794px。如果直接把 1x 的 canvas 贴进 PDF文字在打印时会明显发虚。一般会把 canvas 的 scale 设为 2相当于把导出分辨率提到 192 DPI这个密度已经能满足大多数打印需求。注意这里不是简单地把元素 CSS 放大而是 html2canvas 内部用一个更大的 canvas 绘制最终数据量也会随之增加。举个例子假设目标元素宽度是 900pxscale 2 时 canvas 宽度是 1800px。如果 PDF 左右各留 10mm 边距可用宽度是 190mm那么 1mm 对应的像素数是 1800 ÷ 190 ≈ 9.47px。若 canvas 高度是 2400px换算成 PDF 高度就是 2400 ÷ 9.47 ≈ 253mm一页放得下若目标高度翻倍到 6000px换算后约 634mm就需要三页来装2×297594剩下40mm再开一页。2.3 这种组合的天然短板必须心里有数在动手写代码之前先把这套方案的短板列出来能避免做到一半才后悔。第一PDF 里的文字不可选择搜索、复制都做不到对合同条款类文档不太友好第二canvas 是位图放大到一定倍数会糊但普通阅读和 A4 打印没问题第三长页面导出时会占用大量内存手机端尤其明显第四CSS 兼容性是持续维护的话题html2canvas 对部分属性支持不好需要人工规避。这套方案真正合适的定位是视觉还原优先的导出。你只要在选型时把这条边界讲清楚后面就不会被各种为什么字不能复制的追问搞到崩溃。下一节我直接进入代码把最小可用版本跑通让心里先有个实体概念。3. 最小可用实现30 行代码跑通导出3.1 依赖安装与版本注意事项先装两个包这里以 npm 为例npm install jspdf html2canvas我实际用的是 jspdf 2.5.x 和 html2canvas 1.4.x。jspdf 从 2.x 开始 API 变成了 ES Module 风格老的new jsPDF()全局用法只在通过 CDN 引 UMD 包时才能直接用工程化项目里要写成import { jsPDF } from jspdf; import html2canvas from html2canvas;还有一个值得注意的点新版 jspdf 其实内置了doc.html()方法它内部也依赖 html2canvas传一个 DOM 元素进去就能直接生成 PDF。但因为它把中间过程都封装了遇到分页和样式问题时很难插手干预。我更建议手动走一遍 canvas → addImage 流程控制力强出了问题也知道去哪找。3.2 单页导出代码逐行拆解假设页面里有一个idpdf-content的容器我们先把最简单的单页导出写出来async function exportSinglePage() { const target document.getElementById(pdf-content); const canvas await html2canvas(target, { scale: 2, useCORS: true, backgroundColor: #ffffff, }); const imgData canvas.toDataURL(image/jpeg, 0.92); 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 imgHeight (canvas.height * contentWidth) / canvas.width; pdf.addImage(imgData, JPEG, margin, margin, contentWidth, imgHeight); pdf.save(单页导出.pdf); }逐段解释一下。第一步html2canvas把目标 DOM 重绘成 canvasscale: 2是清晰度保障useCORS: true允许加载许可跨域的图片资源第二步canvas.toDataURL把画布转成 base64 的 JPEG 数据质量 0.92 是一个在体积和画质之间比较平衡的值第三步创建 jsPDF 实例p表示纵向mm是单位a4是纸张规格第四步做尺寸换算先拿到 A4 宽高减去边距得到内容区宽度再按 canvas 宽高比算出图片应占的高度最后addImage把图片贴到 PDF 页面save触发下载。注意pdf.internal.pageSize这种写法看起来像私有 API但用的人非常多jspdf 一直保留着它实际项目中可以直接用。3.3 几个决定成败的关键参数scale建议至少 2最大不必超过 3。再高只会增加内存压力肉眼基本看不出差异。手机端页面如果特别长scale 2 也可能卡顿可以考虑降到 1.5 再配合压缩。backgroundColor默认是白色但如果目标元素本身有透明背景导出后可能得到黑底或透明底显式指定#ffffff最稳妥。useCORS只对服务器允许跨域的图片有效遇到严格限制的资源仍会失败。后面第 5 节会讲具体怎么处理。JPEG 质量0.9 以上保留细节0.8 以下适合超长页面压缩体积。注意 JPEG 不支持透明如果需要保留透明就得用 PNG但体积会明显变大。4. 多页长文导出分页切割与跨页截断4.1 分页算法的两种实现思路对比单页版本一旦内容超过 A4 高度多出来的部分会被直接裁掉所以长内容必须做分页。主流做法有两种。第一种是负坐标法整个 canvas 保持一张大图每一页都用负的 Y 坐标把图片往上挪让对应区域恰好落在当前页的可视范围内。代码短但每一页都引用同一张完整大图页面很多时 PDF 体积会迅速膨胀。第二种是切片法先在 canvas 上按页高等比裁出若干小图逐页 addImage文件更小逻辑也更直观。我推荐第二种尤其适合几十页长文档。切片法的核心思路是先算出总图片高度换算成 PDF 高度后需要几页然后循环每次从大 canvas 上抠出当前页对应区域的一小块转成新 canvas再 addImage。代码顺序可以这样写async function exportMultiPage(elementId, fileName) { const target document.getElementById(elementId); const canvas await html2canvas(target, { scale: 2, useCORS: true, backgroundColor: #ffffff, }); const imgDataUrl canvas.toDataURL(image/jpeg, 0.92); 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 pxPerMm canvas.width / contentWidth; const contentHeightMm canvas.height / pxPerMm; const pageCount Math.ceil(contentHeightMm / pageHeight); for (let i 0; i pageCount; i) { if (i 0) pdf.addPage(); const yPx Math.floor(i * pageHeight * pxPerMm); const sliceHeightPx Math.min(canvas.height - yPx, Math.floor(pageHeight * pxPerMm)); const sliceCanvas document.createElement(canvas); sliceCanvas.width canvas.width; sliceCanvas.height sliceHeightPx; const ctx sliceCanvas.getContext(2d); ctx.drawImage(canvas, 0, yPx, canvas.width, sliceHeightPx, 0, 0, canvas.width, sliceHeightPx); const sliceData sliceCanvas.toDataURL(image/jpeg, 0.92); const sliceHeightMm sliceHeightPx / pxPerMm; pdf.addImage(sliceData, JPEG, margin, margin, contentWidth, sliceHeightMm); } pdf.save(fileName); }关键就两行yPx表示当前页在大 canvas 上纵向的起点drawImage的第五、第六个参数决定抠图的宽高第七到第十个参数把抠出的区域平铺到新的 canvas 上。每页的图片宽度保持一致高度按实际截取多少来定不会出现变形。4.2 防止元素被腰斩的 CSS 处理分页之后紧接着的第二个问题表格行、标题块、卡片可能会刚好卡在页缝位置。在纯 html2canvas 流程里CSS 分页样式只能算一种软建议1.4.x 版本对page-break-inside的支持非常有限所以我实际项目里采用的是预分段思路导出前先把目标 DOM 的克隆体按预估高度切成若干区块容器再逐块导出拼接。这样做虽然多写一点代码但分页位置完全可控。对于简单的卡片、行级元素提前加上break-inside: avoid也能减少被切概率但别把分页安全寄托在它身上行不行。真正要保证表格行不被切开更可靠的方式是在生成表格数据时按行分组把每一组看作一个独立的导出单元。这样需求方说这一行别分开你就知道改哪段代码。注意在 html2canvas 的体系里CSS 分页更多是建议性的。完全精确的按段分页需要根据内容高度做大量计算。如果业务上对分页位置要求苛刻宁可先在 DOM 层面把内容拆好再分段导出。4.3 页眉页脚与页码的添加分页循环里加页码非常方便因为每一页都有独立的 PDF 页面对象。比如在每页底部中间写页码pdf.setFontSize(9); pdf.setTextColor(150); pdf.text(第 ${i 1} 页 / 共 ${pageCount} 页, pageWidth / 2, pageHeight - 5, { align: center, });页眉可以在顶部写一句话或者一个小标题注意别和正文的边距打架。以 10mm 边距为例页眉文字的基线放在margin - 2左右页脚放在pageHeight - 5左右比较安全。这里有一个小技巧jsPDF 的text()坐标中的 Y 是文字基线不是文字顶部所以别把 Y 直接设成 2否则文字会冒出页面。如果导出的文档包含章节标题还可以在页眉里动态写入当前页所属章节名。做法是在循环里维护一个当前章节索引根据yPx判断这一页落在哪个章节区间再决定页眉渲染什么文字。这个小功能对几十页的培训手册特别加分。5. 避坑实录我在真实项目里踩过的坑5.1 图片跨域导致的白图与 canvas 污染有次做一个带商品头图的订单导出本地开发一切正常一上测试环境就导出白图。排查到最后发现是图片走了另一套 CDN返回来没有带跨域响应头。useCORS: true只能保证 html2canvas 尝试以跨域方式加载但服务端不下发Access-Control-Allow-Origincanvas 的像素读取就会被浏览器拦截最终toDataURL直接抛错。稳妥的做法是把图片先转成同源资源。前端常见的方案是fetch图片 → 转 blob → 用FileReader转成 DataURL再塞进页面。如果图片量不大也可以在后端接口里直接返回 base64。代码大概是async function imgToDataUrl(src) { const res await fetch(src); const blob await res.blob(); return new Promise((resolve) { const reader new FileReader(); reader.onload () resolve(reader.result); reader.readAsDataURL(blob); }); }切记不要在img标签上临时加crossoriginanonymous就以为万事大吉那只会让请求模式改变服务端不配合照样失败。5.2 字体未加载完导致文字错位页面里用了网络字体导出时偶尔出现整行文字错位、字体变细、甚至变成默认字体的情况。原因很好解释html2canvas 执行时字体文件还在加载中它读取到的计算样式虽然声明了font-family但实际没有可用的字形数据。解决办法是在导出前等字体就绪await document.fonts.ready;如果项目里用了动态插入的字体最好再等一个document.fonts.load(16px 字体名)。实测下来凡是导出前加了这一步文字渲染的稳定性明显提升。5.3 滚动容器与 fixed 定位的渲染陷阱页面里如果目标元素本身带滚动条或者它的父级设置了overflow: autohtml2canvas 常常只截取当前可视区域滚动下方的内容不在画布上。类似的问题也出现在弹窗、抽屉这类组件里。解决办法是把容器的可视高度临时撑开再配合windowHeight参数const canvas await html2canvas(target, { scale: 2, useCORS: true, windowWidth: target.scrollWidth, windowHeight: target.scrollHeight, });还有一类坑是position: fixed的元素。它相对的是浏览器视口html2canvas 重绘时往往把它复制到页面中一个奇怪的位置导致多页导出的某一页出现莫名其妙的悬浮层。遇到这种我一般会在导出前把相关元素的定位临时改成absolute导出后再恢复。5.4 导出大文件的性能与体积权衡表格类报表导出到五六页以上内存占用和文件体积都会变得可观。canvas 数据在内存里是 RGBA 裸像素横向 1800px、纵向 5000px 时一个 canvas 就有 1800×5000×4 36MB 的原始数据这还只是单页面的量。手机上几张图叠加浏览器直接卡死也不奇怪。我的调优习惯是三步走第一步导出前临时隐藏页面上的无关模块用ignoreElements排除图表动画、loading 浮层这些干扰第二步把 JPEG 质量从 0.95 降到 0.85 左右长文档肉眼几乎无法察觉差异第三步如果文档实在太大考虑把目标 DOM 按章节拆分成多个容器先后导出再合并 PDF避免一次生成超大型 canvas。这个方法在下一篇里会详细展开。5.5 常见现象速查表现象最大概率原因解决方案导出白屏或报错跨域图片污染 canvas图片转 DataURL 或同源托管文字模糊发虚scale 不足至少 scale: 2只能导出第一页少了分页循环按高度计算页数并 addPage内容被横向截断容器宽度超出可视窗口设置 windowWidth 为 scrollWidth字体错位或丢失网络字体未加载完导出前 await document.fonts.ready出现悬浮乱层fixed 定位元素导出前临时改为 absolute文件过大未压缩或 scale 过高降 JPEG 质量、控制 scale6. 性能调优与阶段总结6.1 从能导出到优雅导出的调优清单跑通第一版之后我一般会按一个顺序把基础功能打磨到能交付的程度。先是加载体验导出是异步操作长页面可能要好几秒按钮要进入 loading 状态并防止重复点击。其次是边界处理目标元素不存在、图片全部加载失败、内容高度为 0这些都要给出明确提示而不是让用户看到报错。最后是参数抽离把 scale、边距、JPEG 质量、文件名提到配置对象里方便不同页面复用。我自己的做法是把这套逻辑封装成一个通用导出函数页面里只要维护一份导出配置就能调用。页面结构是动态的也没关系导出函数内部统一做document.fonts.ready等待、DOM 备份、临时样式修改、canvas 生成、分页、恢复 DOM 这一整套流程调用方根本不需要关心细节。6.2 后续路线什么情况应该放弃这套方案文章到这里上篇要讲的基础链路已经完整覆盖从选型逻辑、原理边界到单页导出、多页分页、避坑调优。下一篇文章我计划重点拆解几个更进阶的场景——复杂表格的自动裁切、PDF 文件合并与压缩、动态模板设计以及在 React/Vue 里封装成可复用组件的具体写法。这里也给出一个诚实的判断标准如果你的页面需要被搜索引擎收录、需要全文检索或者需要导出后二次编辑那 jspdf html2canvas 就不该出现在候选名单里改用 pdfmake 这类文本排版方案或者无头浏览器服务端渲染才是正确投入。技术选型没有绝对的优劣只有合不合适。最后分享一个小经验导出前先截一张预览图给用户确认比直接下载文件体验好很多也能减少用户反复下载浪费的时间和带宽。把上篇这套基础打扎实之后再踩到新坑时你不会慌因为原理清楚排查方向就不会跑偏。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑