在做前端的时候,你一定遇到过这种需求:页面上有一个挺好看的报表、订单详情、统计图表,用户想一键保存成 PDF 发给别人或者存档。这功能真做起来没有看上去那么轻巧,因为浏览器本身没有“把当前页面变成 PDF”的原生能力,虽然 window.print() 被很多开发者当作后备方案,但打印出来要调样式、要改布局,效果往往一言难尽。我自己在项目里踩过一圈坑之后,现在最顺手的组合还是 html2canvas + jsPDF——html2canvas 先把 DOM 截成 canvas 图片,jsPDF 再把图片按 PDF 的页面尺寸排进去导出。这套方案能帮助我快速实现页面导出成 PDF 的需求,本文就把整个思路、代码、参数调优和常见坑全部梳理一遍,给要接这个功能的同学一份可以直接抄作业的指南。
1. 方案选型:为什么大家都在用 html2canvas + jsPDF
1.1 这两个库各管什么
要理解这套方案,先得把两个库的分工弄清楚。html2canvas 做的事情是“截图”,它会把指定的 DOM 节点重绘到一张 canvas 画布上,这张画布本质上就是一张位图,像素点里存的是页面渲染出来的视觉结果。而 jsPDF 做的事情是“排版”,它负责创建 PDF 文档、按页添加内容、控制每一页的尺寸和坐标,最终输出一个 .pdf 文件。
整个流程就是:DOM → html2canvas 截成 canvas → canvas 导出成图片数据(base64 或 dataURL)→ jsPDF 以 addImage 的方式把图片写进 PDF 的每一页 → 保存文件。
用生活化类比来说,html2canvas 像是一台照相机,把眼前页面拍下来;jsPDF 像是排版打印机,把照片按 A4 纸的尺寸裁好、排好、打印出来。因为最终落到 PDF 里的是一张位图,所以这套方案对样式的还原度取决于 html2canvas 对 CSS 的解析能力,而不是浏览器的打印引擎。
1.2 与 html2pdf 这类封装库的取舍
你可能也见过 html2pdf.js,它其实就是把 html2canvas + jsPDF 又包了一层,对外暴露一个更简洁的 API,核心逻辑和两个独立库没什么区别。我会选择直接用底层两个库,而不是用封装完成的 html2pdf,原因有三个:
- 分页逻辑需要自己控制,用封装库反而多一层黑盒,出了问题要扒进去看源码,排查成本更高。
- 版本迭代节奏不同,html2pdf 对底层库的依赖版本可能滞后,遇到新浏览器的兼容问题时,直接升级 html2canvas 和 jsPDF 更灵活。
- 我需要中途插入页眉页脚、指定某个区域不导出、动态调整图片质量,这部分在底层库里可以直接操作,封装后反而要绕过它的 API 去 hack。
不过如果你只是做一次性的简单导出,不想关心实现细节,html2pdf 也能快速解决问题,只是后续维护要注意它的依赖更新情况。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与最小可用实现
2.1 安装引入的两种方式
先解决依赖。npm 项目里常规安装:
bash复制npm install html2canvas jspdf
然后在你的模块文件里引入:
javascript复制import html2canvas from 'html2canvas';
import { jsPDF } from 'jspdf';
注意 jspdf 从 2.x 开始官方推荐按命名方式导入 jsPDF。如果是老项目用的 1.x 版本,写法是 const { jsPDF } = require('jspdf'),这个版本差异不影响整体原理,但要留意代码仓库里的包版本,避免复制网上的示例后报 undefined。
如果你不用打包工具,也可以直接在 HTML 里用 CDN 方式引入:
html复制<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js"></script>
<script>
// 全局会挂载 html2canvas 和 jspdf 的全局对象
</script>
这种方式适合写 Demo 或者在公司内部简单功能页里快速验证。我建议不管哪种方式,都先把版本固定,我记得后面踩过一个坑,html2canvas 的 1.4.x 和 1.3.x 在某些 CSS 属性的处理上不太一样,升级以后要重新回归一遍。
2.2 核心代码:把第一屏导出为 PDF
写一个最精简的导出函数,功能就是把指定的 DOM 节点截图并转成 PDF。下面这段代码可以直接放进你的项目里跑通:
javascript复制async function exportPdf() {
const element = document.getElementById('report-container');
// 第一参数是目标节点,第二参数是配置项
const canvas = await html2canvas(element, {
scale: 2, // 导出图片的分辨率倍数,2 倍在清晰度上比较均衡
useCORS: true, // 允许跨域图片加载
backgroundColor: '#ffffff',
});
const imgData = canvas.toDataURL('image/jpeg', 0.95);
// 建立 pdf,单位使用 pt,A4 纵向尺寸是 595 x 842
const pdf = new jsPDF('p', 'pt', 'a4');
// 计算出图片在 A4 纸上的显示宽度和高度
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = pdf.internal.pageSize.getHeight();
const imgWidth = pdfWidth;
const imgHeight = (canvas.height * imgWidth) / canvas.width;
pdf.addImage(imgData, 'JPEG', 0, 0, imgWidth, imgHeight);
pdf.save('report.pdf');
}
第一次跑通这个函数,你可能会发现导出的 PDF 有一些问题,比如内容被截断到一页里、图片不够清晰、跨域图片显示不出来。别慌,这些是这套方案必踩的坑,接下来的几个小节都会逐个处理。
2.3 导出响应式页面的隐藏规律
很多人实现成功能后,会把页面切到手机模拟器再试一次,结果发现导出的内容和 PC 端完全不同,甚至样式错乱。原因是 html2canvas 是“实时截图”,它读取的是当前视口下 DOM 的渲染结果。如果页面是响应式的,在不同宽度设备上看到的布局不同,截图自然也不同。
所以这里有一个隐藏逻辑:如果你希望导出的一直是设计稿里的 PC 布局,最好不要依赖用户当前浏览器窗口的布局结果。常见做法是把导出的目标容器固定宽度渲染,比如给目标区域套一层固定 width: 1200px 的容器,截图时临时设置成绝对定位挂到 body 上,截完再移除。这样做能保证导出的视觉结构稳定,不随用户窗口变化。
另一个隐藏规律是 html2canvas 对 vh、vw 这类单位支持并不可靠。因为在内部重绘时它需要重新计算每个节点的绝对位置,视口单位在某些情况下会换算成 0,导致元素堆叠。如果你的页面里用了大量 vh/vw,导出前建议先给目标容器和子元素设置明确的像素尺寸,否则会出现定位错乱。
3. 多页导出:一篇文章不裁切的完整方案
3.1 分页原理
页面内容总是比一页 A4 纸要长的。如果直接把整张图片塞进 PDF 的第一页,两件事会发生:一是图片会按比例压缩到 A4 宽度以内,导致整个页面缩得很小;二是超出第一页高度的部分直接丢失。
分页的思路其实很简单:先把整块 DOM 截图得到一张长图,然后把长图沿着高度方向切成若干段,每一段对应 PDF 的一页。切分高度就取 A4 换算成图片像素后的高度。代码上需要知道两个数值:PDF 页面宽度对应的图片宽度,以及 PDF 页面高度对应的图片高度。
A4 在 jsPDF 默认单位 pt 下是 595.28 x 841.89。如果 canvas 的宽度是 canvasWidth,那么每一页的高度对应的 canvas 像素是:
code复制pageCanvasHeight = canvasWidth * 841.89 / 595.28
这个数值会参与分页循环,每一页从长图上截取同样高度的片段。
3.2 分页代码实现
我直接给出一个通用分页导出函数,里面包含了比较完整的分页处理逻辑:
javascript复制async function exportMultiPagePdf(element) {
const canvas = await html2canvas(element, {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
logging: false,
windowWidth: element.scrollWidth,
windowHeight: element.scrollHeight,
});
const imgData = canvas.toDataURL('image/jpeg', 0.95);
const pdf = new jsPDF('p', 'pt', 'a4');
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = pdf.internal.pageSize.getHeight();
// 图片在 PDF 中的显示尺寸
const imgWidth = pdfWidth;
const imgHeight = (canvas.height * imgWidth) / canvas.width;
// 计算需要分页的数量
const pageCount = Math.ceil(imgHeight / pdfHeight);
// 当前裁剪位置(基于 PDF 尺寸坐标系,单位是 pt)
let currentHeight = 0;
for (let i = 0; i < pageCount; i++) {
if (i > 0) {
pdf.addPage();
}
// 这个高度差表示当前页要显示的原始图片上下位移量
const remaining = imgHeight - currentHeight;
const displayHeight = Math.min(remaining, pdfHeight);
// 将整张图片“从上方裁掉”已输出的部分
pdf.addImage(
imgData,
'JPEG',
0,
-currentHeight, // 负值让图片向上偏移
imgWidth,
imgHeight,
undefined,
'FAST'
);
currentHeight += displayHeight;
}
pdf.save('multi-page.pdf');
}
这段代码的关键在于 addImage 的 y 坐标传入负的 currentHeight。比如第一页的图片从 y=0 开始显示,由于图片高度大于页面高度,超过页面的部分被自动裁剪;第二页新增一页后,把 y 坐标设置为 -841.89,图片位置整体上移一页的高度,于是第二页展示的正好是上一页截断位置之后的图片内容。这种“整图滚动式”插入比逐段截 canvas 更简单,也不用担心 canvas 片段之间出现缝隙。不过它有个前提是你必须用 image/jpeg 格式,因为 JPEG 在 PDF 中压缩表现更好,如果使用 PNG,导出文件体积会明显增大。
3.3 设置单页宽度避免内容挤压
分页之后还可能出现一种情况:每页内容显示出来了,但宽度被拉伸或者挤压,整体变形。这通常是图片比例和 PDF 页面比例不匹配导致的。
A4 宽高比是 595.28 : 841.89,大约是 1 : 1.414。如果你的 DOM 容器宽高比远大于这个比例,比如一个很宽的数据表格,按 imgWidth = pdfWidth 计算后,图片高度会非常小,内容被压缩成一行行看不清;如果容器本身特别高,图片会吐到很多页里。
解决方案是在导出前控制 DOM 容器的宽高比。大多数业务报表容器的比例其实都适合纵向输出,遇到横向大宽度的场景,可以考虑两种处理方式:
- 把 PDF 切换成横向:
new jsPDF({ orientation: 'l', unit: 'pt', format: 'a4' }),让图片按横向页面的宽高比进行适配。 - 限制导出容器的宽度,比如把目标区域设为固定
width: 800px,内部内容自适应排列后,再截图导出,这样在 A4 纵向页面上展示效果最自然。
我习惯在截图前把容器宽度固定成 794px 左右,约等于 A4 宽度减掉两侧边距后的像素值。这样的比例最接近 A4 页面,导出的效果最像打印出来的文档。
4. 清晰度、跨域与样式还原:实战调优
4.1 scale 怎么调才清晰又不卡
清晰度是整个方案里最能拉开体验差距的点。默认情况下 html2canvas 的 scale 是 1,意思是用 CSS 像素直接生成图片,在普通屏幕上看起来还行,但在高分屏或 PDF 放大后会出现明显的锯齿和模糊感。
我通常会把 scale 调到 2 左右。这是内存、耗时和清晰度的平衡点。scale=2 时,canvas 的像素数量是原来的 4 倍,内存消耗呈平方级别增长。如果页面特别长,甚至会出现浏览器崩溃或截图空白的情况。
一个比较保守的调法是这样的:先判断 DOM 容器的实际宽度和高度,如果尺寸超过一定阈值(比如宽度超过 1500px,高度超过 5000px),就在 scale 上做削减,避免一次生成超大 canvas。
javascript复制const maxCanvasArea = 15000 * 15000; // 经验阈值
const area = element.scrollWidth * element.scrollHeight;
const scale = area > maxCanvasArea ? 1 : Math.min(2, Math.max(1, area / 10000000));
scale 还影响到 html2canvas 内部对 css 像素和物理像素的换算,一味的低 scale 会导致文字边缘发虚,业务报表导出后放大看会非常明显。所以如果是给客户看的正式文件,基本都要保证 scale 至少为 2。
4.2 跨域图片、字体、背景色的处理
跨域图片是 html2canvas 最经典的坑。因为 canvas 的像素安全机制,如果页面中包含未开启 CORS 的跨域图片,html2canvas 截出来的 canvas 会被标记为“受污染”,调用 toDataURL() 会直接抛 SecurityError。
处理方式分两步。第一步,在图片标签上设置 crossorigin="anonymous",告诉浏览器允许跨域拉取图片并写入 canvas:
html复制<img src="https://cdn.example.com/a.png" crossorigin="anonymous" />
第二步,后端需要响应 Access-Control-Allow-Origin 头。这一步如果后端没法配,前端再折腾也没用。有时同样一张图,开发环境可以导出、生产环境不行,多半就是生产环境 CDN 忘了配响应头。
字体方面,html2canvas 不会主动等待 WebFont 加载完成。如果你在截图时字体文件还没加载完,导出的 PDF 会退回默认字体,排版会错位。常见做法是截图前用 document.fonts.ready 等待字体就绪:
javascript复制await document.fonts.ready;
背景色方面,html2canvas 默认使用 transparent,也就是透明背景。如果导出 PDF 后内容贴在白色页面里没什么问题,但如果后续要放到深色背景的文档里,透明区域会变成黑色。建议在配置里显式写上 backgroundColor: '#ffffff',保证底色是预期值。
4.3 一些容易被忽略的样式细节
html2canvas 并不是完整的浏览器渲染引擎,对部分 CSS 的支持存在空缺。我实际项目里遇到过的几个高频问题,这里列一下:
box-shadow在部分版本中会丢失,或者在某些元素上表现为外框黑边。尽量少依赖阴影做视觉区分,或者截图前给关键区域加实线边框。linear-gradient支持还算正常,但conic-gradient(锥形渐变)和一些复杂的background-blend-mode不支持或表现异常。border-radius基本没问题,但旧版本中如果同时设置overflow: hidden和border-radius,可能会出现内容溢出的圆角没被正确裁剪。- 使用
position: fixed的元素,html2canvas 会把它绘制在页面顶部而不是视口位置,所以弹窗、固定底部按钮这类的元素经常出现在导出图里。导出前建议给这些元素加一个display: none,或者设置一个data-html2canvas-ignore属性,html2canvas 会自动忽略标记了该属性的元素。
我自己的习惯是:在需要导出的业务页面里,专门维护一套 .export-ignore 样式类,凡是导出的目标文案、按钮、弹层都打上这个类,避免重复处理。
5. 常见问题排查:从空白页到乱码
5.1 问题一:导出 PDF 全是空白
空白 PDF 是我被问得最多的问题。这类问题根源基本在 canvas 生成失败或导出时 canvas 被污染,分成两种情况排查:
- 打开浏览器控制台看有没有报错。如果报
SecurityError,大概率是跨域图片没处理干净,设置useCORS: true并检查图片响应头。 - 如果控制台没有报错,但 PDF 里只有空白,可以先把生成的 canvas 打印出来检查,或者在
pdf.addImage前用document.body.appendChild(canvas)临时把它挂到页面上看看。如果 canvas 本身正常,就是 PDF 插入环节出了问题,检查一下imgData是否为空字符串。
还有一个容易忽略的点:html2canvas 的 onclone 回调里克隆的 DOM 如果引用了外部的 @media print 样式,可能会变更渲染结果。空白页往往就是写入 PDF 的图片是透明的,因为页面里元素全是白色文字或透明背景。
5.2 问题二:canvas 高度超出最大限制
浏览器对 canvas 的尺寸是有限制的,iOS Safari 上 canvas 最大面积约为 16777216 像素(4096 x 4096),现代桌面浏览器一般允许更大的画布,但超过一定高度(比如 Chrome 的 32767px 或 65535px)之后,canvas 内容会直接不渲染,导出的自然是空图。
解决思路有两个。
第一个是降低 scale。逻辑很简单,如果页面高度是 10000px,scale=2 时 canvas 高度就是 20000px,如果再乘上宽度 1600px,面积已经非常大了。把 scale 降到 1,一般页面都可以绕开限制。
第二个是分段截图。把目标 DOM 按高度拆成若干段,每一段分别用 html2canvas 截图,再分批插入 PDF。这样每一张 canvas 的高度都不会超过限制,但要注意分段时元素不能跨段,否则中间的内容会被切掉一半。这个方法比较费事,适合确实需要高清晰度又不愿意牺牲 scale 的场景,我一般只在做长报表时用到。
5.3 问题三:多页 PDF 每页之间内容被截断
“上一页底部的内容被截了一半,这一页又没有它的下一半”,这是整图滚动式分页最容易遇到的问题。原因在于 html2canvas 生成的长图是完整连续的,PDF 的页面边界是人为切割的,切割点落在某个人眼可见的元素中层内,截断在所难免。
处理办法主要有两种思路:
- 给目标 DOM 的子元素加
break-inside: avoid无效,因为 html2canvas 根本不走 CSS 分页引擎。只能换个实现思路。 - 按 DOM 节点分页:先找到页面里的自然分块(比如每个表格行、每个卡片),计算每个块在长图中的 Y 坐标范围,然后让分页切点尽量落在块的边界上。实现上通过遍历目标容器下子元素的
offsetTop和offsetHeight来排出最接近 A4 高度的切点。
如果业务要求不那么严格,我建议直接接受截断,毕竟位图导出本来就做不到像 PDF 排版引擎那样精准分页。真要精准分页,说明你的场景更适合用 pdfmake 这类基于数据流生成 PDF 的方案,而不是截图方案。
5.4 问题速查表
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| PDF 空白 | canvas 被污染、图片数据为空 | 检查跨域图片、检查 canvas 生成结果 |
| 导出模糊 | scale 太低 | 调高 scale,或控制容器尺寸后再截图 |
| 图片无法显示 | 图片没有 CORS 响应头 | 图片标签加 crossorigin,后端配置 Allow-Origin |
| 元素缺失 | 使用了 fixed 定位或 canvas 不支持的 CSS | 给元素加 data-html2canvas-ignore,或提前改样式 |
| 导出卡死/内存溢出 | 页面高度过高、scale 过大 | 降低 scale、分段截图 |
| 文字被切成两半 | 分页线落在行内 | 换成按逻辑块切分方式,或接受截断 |
6. 性能优化与替代方案思考
6.1 大页面卡顿的处理
当页面内容很多时,html2canvas 的截图过程对主线程的阻塞很严重,用户会看到页面卡住几秒甚至十几秒。优化手段可以从几个方向入手:
- 截图前给页面加一个 loading 遮罩,提示用户正在生成,避免用户重复点击导致多个截图任务并发。
- 将截图过程用
requestAnimationFrame或者把任务拆到 idle 回调中执行,虽然实际耗时不会缩短,但能减少页面交互的冻结感。 - 把不需要导出的图片、图表等先替换成降级占位,比如只导出文字,图表区域变成简单的色块。这种场景多用于服务端只想要图片轮廓的情况。
前端导出 PDF 本质上是浏览器端的大批量像素操作,性能优化天花板有限。如果页面特别重,还是建议考虑后端方案:把制定区域的 HTML 发给后端,用无头浏览器截图服务生成 PDF,前端的压力会小很多。
6.2 替代方案对比:html-to-image、modern-screenshot、纯文本方案
html2canvas 不是唯一的选择,近年来也有不少替代库。我给一个横向对比,方便你按需选型:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| html2canvas + jsPDF | 方案成熟、资料多、上手快 | 位图导出会模糊;部分 CSS 不支持 | 通用页面/报表导出 |
| html-to-image | 内部使用 SVG foreignObject,样式还原度更高,支持 WebP 导出 | 对浏览器 CSP 要求高,跨域限制同样存在 | 需要高质量图片的场景 |
| modern-screenshot | API 简单,支持多种格式,性能不错 | 社区相对较新,国内讨论少 | 导出截图的备选方案 |
| pdfmake / react-pdf | 直接生成文本型 PDF,清晰可选中文字 | 需要将 DOM 结构转换成数据定义,工作量大 | 需要可复制文本的正式文档 |
| puppeteer 服务端 | 使用真实浏览器内核,渲染最准确 | 需要额外部署 Node 服务,资源占用高 | 复杂页面的服务端导出 |
如果你的核心诉求是“页面截图”,从体验角度我反而建议先试试 html-to-image。它的原理是先把 DOM 序列化成 SVG 的 <foreignObject>,再绘制到 canvas。由于走的不是自定义渲染逻辑,CSS 支持度比 html2canvas 高不少,导出图片也更接近浏览器真实显示效果。不过它同样受跨域图片限制,而且对 CSP 中的 img-src 和 media-src 有要求,在强安全策略的内网系统里可能会遇到阻挠。
我做过的另一个项目里,客户要求 PDF 里的文字可以复制、搜索,这种需求截图型方案无论怎么优化都满足不了。后来改用纯数据流方案,前端把页面结构映射成 JSON,传给 pdfmake 生成文本型 PDF。代价是开发量上了一个台阶,需要重写模板、维护样式定义文件。所以选择方案前,先弄清需求里最核心的一环是“像照片一样还原页面”还是“内容可编辑可用”。
关于替代方案,还有一个坑值得提:很多替代库会在 Canvas 2D 的 toDataURL 导出 PNG 时,因为图片格式差异导致文件大小暴增。导出为 JPEG 并设置压缩质量 0.92 左右,通常比 PNG 体积小一个数量级,视觉差异也不明显,推荐优先选择。
综合来看,html2canvas + jsPDF 依然是我脑海中处理“前端页面导出 PDF”需求的第一选择。它不需要后端配合,发布部署零成本,对业务方来说看到的导出的确就是页面的样子,认知一致性最高。对于大多数企业内部报表、数据看板、工单详情这类中后台场景,这套方案完全够用。
最后再分享一个小技巧:如果你的导出按钮经常被反复点击,可以在 pdf.save() 之前用 await new Promise(resolve => setTimeout(resolve, 0)) 强制让出一次微任务队列,让浏览器先完成画面渲染,避免生成过程中 canvas 绘制不完整。这个细节我在不少项目里实测有效,导出文件的稳定性会提高不少。希望这篇文章能帮你把 html2canvas 和 jsPDF 的坑绕过去,少加班,早下班。
