做前端这些年,"导出 PDF"这四个字,基本是后台管理系统里的钉子户需求。无论是财务报表、订单凭证还是审批单,产品经理都会在某一天突然提出:加个导出按钮吧。第一次我天真地以为调用浏览器打印就够了,结果分页乱、样式塌、字体跑偏,挨了一顿吐槽之后才老老实实把 jsPDF 与 html2canvas 这套组合学透。这篇文章先不整虚的,直接从原理到实战把导出 PDF 这条链路拆成"上篇",重点覆盖技术选型、核心原理、单页与多页导出、避坑经验,下一篇再聊高级定制与复杂表格场景。适合刚接手"导出"需求想快速落地、或正在被分页和清晰度折磨的前端开发者参考。
1. 需求背景与技术选型:为什么导出 PDF 让人头疼
1.1 前端导出 PDF 的典型场景与隐藏痛点
后台系统里最常见的三类导出需求:第一类是数据型报表,表格动辄几百行,导出的 PDF 既希望内容完整,又希望视觉上接近页面;第二类是单据型页面,比如订单确认单、审批回执,通常有固定的排版、印章、签名区域;第三类是富样式文档,比如活动海报、结业证书,对还原度要求极高。这三种需求有一个共同点:前端手头只有一份已经渲染好的 DOM,没有人会专门为导出再搭一套后端模板。
表面上看,导出 PDF 只是"截图 + 生成文件"两步,但隐藏痛点一个都不少。页面样式是由浏览器实时计算的,换台机器、换个浏览器,渲染结果可能不同;内容一旦超出单页范围,怎么切分、怎么避免把一个表格从中间劈开;导出文件的清晰度能不能打印;中文、特殊字体能不能完整展示;还有图片跨域、CSS 兼容性这类"灰色地带"。这些问题不提前搞清楚,光一个导出按钮就能吃掉两三天工期。
我个人的经验是,接到这类需求先别急着写代码,花半小时把方案选型聊明白,后面能省下大量返工时间。这也是很多半路接手的老项目最容翻车的地方——上一任直接用打印命令凑合,结果分页全靠浏览器心情,后来整块推倒重来。
1.2 主流方案横向对比:我为什么最终选了 jspdf + html2canvas
市面上的前端 PDF 导出方案,我按"要不要服务器参与"和"最终 PDF 是图片还是文本"两条线整理了一下,大致有这么几类:
| 方案 | 实现思路 | 优势 | 劣势 | 合适场景 |
|---|---|---|---|---|
| 浏览器打印 / 另存为 PDF | 调起浏览器原生打印 | 零依赖、文本可选择 | 样式还原不稳定、分页不可控 | 简单文档、用户自己打印 |
| jspdf + html2canvas | DOM 转 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 默认单位为 mm,A4 纵向纸是 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 绘制,最终数据量也会随之增加。
举个例子:假设目标元素宽度是 900px,scale = 2 时 canvas 宽度是 1800px。如果 PDF 左右各留 10mm 边距,可用宽度是 190mm,那么 1mm 对应的像素数是 1800 ÷ 190 ≈ 9.47px。若 canvas 高度是 2400px,换算成 PDF 高度就是 2400 ÷ 9.47 ≈ 253mm,一页放得下;若目标高度翻倍到 6000px,换算后约 634mm,就需要三页来装(2×297=594,剩下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 单页导出代码逐行拆解
假设页面里有一个id="pdf-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 重绘成 canvas,scale: 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-Origin,canvas 的像素读取就会被浏览器拦截,最终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标签上临时加crossorigin="anonymous"就以为万事大吉,那只会让请求模式改变,服务端不配合照样失败。
5.2 字体未加载完导致文字错位
页面里用了网络字体,导出时偶尔出现整行文字错位、字体变细、甚至变成默认字体的情况。原因很好解释:html2canvas 执行时,字体文件还在加载中,它读取到的计算样式虽然声明了font-family,但实际没有可用的字形数据。
解决办法是在导出前等字体就绪:
await document.fonts.ready;如果项目里用了动态插入的字体,最好再等一个document.fonts.load('16px "字体名"')。实测下来,凡是导出前加了这一步,文字渲染的稳定性明显提升。
5.3 滚动容器与 fixed 定位的渲染陷阱
页面里如果目标元素本身带滚动条,或者它的父级设置了overflow: auto,html2canvas 常常只截取当前可视区域,滚动下方的内容不在画布上。类似的问题也出现在弹窗、抽屉这类组件里。解决办法是把容器的可视高度临时撑开,再配合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 质量、控制 scale |
6. 性能调优与阶段总结
6.1 从"能导出"到"优雅导出"的调优清单
跑通第一版之后,我一般会按一个顺序把基础功能打磨到能交付的程度。先是加载体验:导出是异步操作,长页面可能要好几秒,按钮要进入 loading 状态并防止重复点击。其次是边界处理:目标元素不存在、图片全部加载失败、内容高度为 0,这些都要给出明确提示,而不是让用户看到报错。最后是参数抽离:把 scale、边距、JPEG 质量、文件名提到配置对象里,方便不同页面复用。
我自己的做法是把这套逻辑封装成一个通用导出函数,页面里只要维护一份"导出配置"就能调用。页面结构是动态的也没关系,导出函数内部统一做document.fonts.ready等待、DOM 备份、临时样式修改、canvas 生成、分页、恢复 DOM 这一整套流程,调用方根本不需要关心细节。
6.2 后续路线:什么情况应该放弃这套方案
文章到这里,"上篇"要讲的基础链路已经完整覆盖:从选型逻辑、原理边界,到单页导出、多页分页、避坑调优。下一篇文章我计划重点拆解几个更进阶的场景——复杂表格的自动裁切、PDF 文件合并与压缩、动态模板设计,以及在 React/Vue 里封装成可复用组件的具体写法。
这里也给出一个诚实的判断标准:如果你的页面需要被搜索引擎收录、需要全文检索,或者需要导出后二次编辑,那 jspdf + html2canvas 就不该出现在候选名单里,改用 pdfmake 这类文本排版方案,或者无头浏览器服务端渲染才是正确投入。技术选型没有绝对的优劣,只有合不合适。
最后分享一个小经验:导出前先截一张预览图给用户确认,比直接下载文件体验好很多,也能减少用户反复下载浪费的时间和带宽。把上篇这套基础打扎实之后,再踩到新坑时你不会慌,因为原理清楚,排查方向就不会跑偏。