前一阵子我们内部系统改版,运营同事提了个需求:“能不能把订单明细页一键存成PDF发给客户?”后台页面上那一堆查询条件、表格、图表,总不能让人家手动Ctrl+P然后挑打印机。我第一反应就是用前端方案做,html2canvas加jspdf,这俩组合在前端导出PDF这个场景里几乎是标配。折腾了两天,踩了几个不大不小的坑,把思路和代码整理出来,给同样要做这个功能的朋友做个参考。
这套方案本质上是“先截图,再贴图”:用html2canvas把指定DOM区域渲染成一张canvas位图,再用jspdf新建一个pdf文档,把这张图按A4页面尺寸切分贴进去。它不需要后端参与,纯前端就能完成,适用于报表导出、工单保存、长图文存档这类场景。你可以直接拿去做订单、统计报表、文章内容、甚至简历预览的导出功能。如果你也在纠结怎么做“干净、清晰、不乱码”的前端导出PDF,这篇内容应该能帮你少走好几个弯路。
1. 项目背景:为什么需要把页面导出成PDF
1.1 前端导出PDF的主流方案对比
先说结论:前端导出PDF的方案挺多,但真正适合“把整个页面视觉还原出来”的,html2canvas加jspdf是最简单粗暴的一种。
我梳理一下常见的几条路:
- window.print() + 浏览器打印:这是最轻量的方案,直接把页面调成打印样式,让用户自己选“另存为PDF”。但它的缺点是样式不受控,不同浏览器打印出来的东西差别很大,而且用户必须手动操作,不适合“一键导出”这种动作。
- 纯jspdf写文本和表格:用jspdf的text、autoTable这些API逐行绘制内容。好处是输出的是矢量PDF,清晰度高、文件小,但代价是你得把页面里的DOM内容手动解析成坐标和行,复杂页面根本干不动,表格、图表、带样式的块状结构会写到怀疑人生。
- html2canvas + jspdf:把DOM先画成图片,再放进PDF。优点是不用管页面内部结构,最终看到什么就导出什么,还原度最高;缺点是输出的是位图,放大看会有轻微模糊,而且html2canvas对部分CSS属性支持不完整。
实际项目里,尤其是管理后台、报表系统,视觉还原度往往比“矢量放大不糊”更重要。我最终选了html2canvas + jspdf,理由很简单:快,稳,不用为每个页面单独写绘制逻辑。你要导出的内容只要在页面上能渲染出来,这套方案基本就能给你存下来。
1.2 这套组合的根本思路
把html2canvas和jspdf放在一起,核心思路就是“把页面当图画”。
html2canvas会把指定DOM节点“拍一张快照”,生成一个canvas元素。这个canvas就是一张位图,包含了节点内所有元素的视觉样式、布局、文字、图片、背景,基本上所见即所得。然后jsPDF负责创建PDF文件,提供一个叫addImage的方法,把canvas转成图片数据后贴到PDF页面上。
关键来了:PDF的页面尺寸是固定的(比如A4是210mm×297mm),但页面内容高度是任意的。所以你不能简单地把一整张长图直接塞进一页PDF里,否则内容会被缩放得小到看不清。正确做法是把长图按A4比例切成若干段,每段放一页,这叫“分页贴图”。具体怎么切,后面我会给完整代码。
这里必须提醒你一句:html2canvas不是浏览器原生截图,它是自己遍历DOM节点,逐个把样式绘制到canvas上。所以它注定不能100%还原CSS,尤其是filter、backdrop-filter、混合模式、部分flex/grid布局、较新的CSS特性,都可能在截图时丢失或错乱。提前做好这个心理预期,后面遇到样式问题就不会慌了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心原理:html2canvas和jspdf到底在做什么
2.1 html2canvas的截图机制
html2canvas的工作过程比很多同学想象得要复杂。它不是调用了什么浏览器底层截图API,而是从传入的DOM节点出发,递归遍历所有子节点,读取它们的样式信息(位置、大小、颜色、字体、边框、阴影等),再用canvas 2D API把这些信息逐项绘制出来。
这个机制决定了两个直接影响使用的结论:
第一,跨域图片默认画不上去。因为canvas绘制跨域图片会污染画布,html2canvas为了安全默认不允许加载跨域图片。解决办法是img标签上加上crossOrigin="anonymous",同时后端返回Access-Control-Allow-Origin头,或者直接转成base64。
第二,依赖样式计算结果的准确性。如果页面用rem、vw这类相对单位,或者依赖JS计算动态尺寸,截图时可能拿到的是未布局完成的状态。我还没见过哪个项目不需要针对导出场景做一点样式微调的。
html2canvas还提供了 onclone 回调。它会在内部克隆一份DOM用于截图,你可以在克隆文档里临时调整样式,比如隐藏筛选区、把过长内容展开、给目标容器加背景色。这些改动只影响截图,不影响真实页面,非常实用。
2.2 jsPDF的页面模型和addImage
jsPDF定义了一个文档对象,你可以理解成一张无限长的白纸,通过addPage新增页面,通过addImage把图片放上去。它的核心单位有pt、mm、px三套,默认是pt。A4纸在pt单位下是595.28 × 841.89,在mm单位下是210 × 297。
addImage的签名经常有人搞混,我重点说一下:
javascript复制pdf.addImage(imageData, format, x, y, width, height, alias, compression)
- imageData:可以是canvas对象、图片base64、或者图片的dataURL
- format:图片格式,传入canvas对象时填'PNG'即可
- x、y:图片在页面上的左上角坐标
- width、height:图片在页面上的宽高,单位跟随创建pdf时设定的unit
注意这两个width和height,它不是图片本身的像素宽高,而是你要把它“贴”在页面上的物理尺寸。如果原图很大,贴的时候width和height很小,图片会被压缩显示,等于在PDF里缩小了,但图片本身并没有变清晰。所以想要导出清晰,要从源头提升canvas分辨率。
2.3 清晰度与尺寸的换算逻辑
很多人导出PDF后觉得模糊,问题往往出在“canvas分辨率不够”或“贴图尺寸设得过大”。
这里有一个关键换算:页面在屏幕上通常是96dpi,一张在屏幕上宽900px的图,物理宽度约为 900/96 = 9.375英寸。如果直接以这个比例贴到PDF里,那么图片是1:1的。但PDF多数情况下会被打印或放大浏览,比屏幕更精细,经验上导出128dpi以上的效果才够看。你要做的,就是用scale参数把canvas放大绘制,再按目标dpi把尺寸贴进PDF。
换算成A4页面,A4宽210mm,1英寸=25.4mm,所以A4宽度是8.27英寸。若按150dpi导出,canvas宽度应该是 8.27 × 150 = 1240px。假如你的页面宽度是900px,html2canvas的scale应设为1240/900 ≈ 1.38。当然不是所有页面都要完全按A4宽度,但你必须理解这个逻辑:scale决定了截图的分辨率,addImage时的物理尺寸决定了图片在PDF里占多大。两者配合好,清晰度才稳。
3. 完整实现:一步步搭出可用的导出功能
3.1 安装依赖和基础引入
我用npm安装的是这两个包:
bash复制npm install html2canvas jspdf
推荐版本方面,html2canvas建议用1.4.1,jspdf用2.5.1。html2canvas在2.x之后基本没大更新,1.4.1比较稳定;jspdf 2.x系列API成熟,网上资料也多。
按需引入:
javascript复制import html2canvas from 'html2canvas';
import { jsPDF } from 'jspdf';
如果你是在普通页面用script标签引入,那就引这两个文件的cdn地址,全局变量分别是html2canvas和window.jspdf.jsPDF,用法基本一致。
3.2 基础版:单页导出
先看最小可用版本。假设页面上有一个id为pdfContent的容器,点击按钮导出:
javascript复制async function exportSinglePage() {
const element = document.getElementById('pdfContent');
// 生成canvas截图
const canvas = await html2canvas(element, {
scale: 2, // 提高分辨率
backgroundColor: '#fff', // 背景色,避免透明背景导出变黑
useCORS: true, // 尝试加载跨域图片
logging: false
});
// 新建A4 PDF,unit用pt
const pdf = new jsPDF({
unit: 'pt',
format: 'a4'
});
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = pdf.internal.pageSize.getHeight();
// 图片数据
const imgData = canvas.toDataURL('image/png');
// 按宽度等比缩放图片高度
const imgHeight = canvas.height * pdfWidth / canvas.width;
// 如果内容高度不超过一页,直接贴
pdf.addImage(imgData, 'PNG', 0, 0, pdfWidth, imgHeight);
pdf.save('export.pdf');
}
这个版本的问题很明显:内容一旦超过A4高度,图片就会被压缩到一页里,字会变得非常小。所以实际项目必须做分页。
3.3 多页分页导出:长图分段
分页的核心思路是:先得到整张canvas长图,然后从上往下按“A4页面能容纳的高度”裁切,一段生成一页。
关键点在于A4页面能容纳的“图片像素高度”怎么算。既然图片宽度要适配pdfWidth,而pdfWidth对应的物理宽度是固定的,那么每页能容纳的高度也要按同一比例换算:
javascript复制const pageHeight = pdfHeight; // PDF页面高度(pt)
const imgWidth = canvas.width; // canvas实际宽度(px)
const renderWidth = pdfWidth; // 图片要贴到PDF里的宽度(pt)
const ratio = renderWidth / imgWidth; // 像素到pt的换算比例
const renderHeight = canvas.height * ratio; // 整张图贴入PDF后的总高度(pt)
let position = 0; // 当前已贴到第几页
let remainingHeight = renderHeight;
while (remainingHeight > 0) {
// 每次贴一页的内容:从position开始,截取pageHeight的高度
pdf.addImage(imgData, 'PNG', 0, position, renderWidth, renderHeight);
remainingHeight -= pageHeight;
position -= pageHeight;
if (remainingHeight > 0) {
pdf.addPage();
}
}
这里解释一下addImage传的截图参数:x传0,y传position(负值),width传renderWidth,height传renderHeight。因为图片完整高度大于一页,y为负时,相当于把图片向上偏移,当前页显示的就是图片的下一段。这是最经典的长图分段方式,代码量小,效果也够用。
但直接按固定高度切有两个体验问题:
- 文字可能被拦腰截断。一段字如果恰好跨在两个页面边界,PDF里就会看到半行文字。
- 没有边距。图片从(0,0)开始,PDF打印时贴边,不太好看。
后一个问题很简单,把addImage的x、y改成页边距,图片宽度相应缩小即可。前一个问题,我放到第4部分“找断点”展开讲。
3.4 带边距、页脚的基础最终版
我整理一个综合了边距和页脚的基础版,也比较贴近真实业务:
javascript复制async function exportToPdf() {
const element = document.getElementById('pdfContent');
const canvas = await html2canvas(element, {
scale: 2,
backgroundColor: '#ffffff',
useCORS: true,
logging: false,
onclone: (clonedDoc) => {
// 在克隆文档里调整只用于导出的样式
const clonedElement = clonedDoc.getElementById('pdfContent');
clonedElement.style.width = '794px'; // 例如强制宽度,防止出现横向滚动条样式
}
});
const pdf = new jsPDF({
unit: 'pt',
format: 'a4',
orientation: 'portrait'
});
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
const margin = 30; // 左右边距
const contentWidth = pageWidth - margin * 2;
const imgData = canvas.toDataURL('image/png');
const imgWidth = contentWidth;
const imgHeight = canvas.height * contentWidth / canvas.width;
let y = margin;
let remaining = imgHeight;
while (remaining > 0) {
pdf.addImage(imgData, 'PNG', margin, y, imgWidth, imgHeight);
remaining -= (pageHeight - margin * 2);
y -= (pageHeight - margin * 2);
if (remaining > 0) {
pdf.addPage();
// 页脚:页码
const pageCount = pdf.getNumberOfPages();
pdf.setFontSize(10);
pdf.setTextColor(150);
pdf.text(`第 ${pageCount + 1} 页`, pageWidth / 2, pageHeight - margin / 2, { align: 'center' });
}
}
pdf.save('报表导出_' + Date.now() + '.pdf');
}
这里有几个细节值得说:
- margin不是越大越好。左边距30pt,加上右30pt,正文宽度约535pt,在150dpi下对应图片像素约1115px,已经足够大多数页面使用。
- 页码要在addPage之后添加,因为新页的上下文已经切换。页码放的位置要避开内容区,不然会和图片重叠。这套代码里内容区只占用到pageHeight - margin,底部margin区域专门留给页码。
- 如果整个导出只是单页,不需要加页码,所以我在addPage后面才加。
3.5 让导出内容更适配:onclone的妙用
很多页面本身有侧边栏、导航栏、按钮组,这些都不是想导出进PDF的内容。常见做法是先隐藏,导出完再恢复,但这会造成页面闪烁。onclone就是为了解决这个问题存在的。
html2canvas在真正截图前,会在内存中复制一份完整DOM,onclone里你能拿到这份克隆文档,改动不会影响真实页面。比如:
javascript复制onclone: (clonedDoc) => {
const root = clonedDoc.getElementById('pdfContent');
// 隐藏所有带有 no-print 类的元素
root.querySelectorAll('.no-print').forEach(el => el.style.display = 'none');
// 给表格加边框,防止某些浏览器样式下表格线消失
root.querySelectorAll('table').forEach(table => {
table.style.borderCollapse = 'collapse';
table.style.width = '100%';
});
}
这个技巧在处理复杂页面时非常实用。你现在可能觉得“导出内容删减”直接操作DOM也行,但一旦遇到表格样式、颜色主题切换这类场景,onclone能省很多事。
4. 常见问题排查与避坑实录
4.1 问题速查表
我在实际项目中把踩过的坑整理成了一张表,先贴出来,后面挑重点详细讲:
| 现象 | 常见原因 | 解决办法 |
|---|---|---|
| 导出图片模糊 | scale太小或图片被拉伸到过大尺寸 | scale设为2以上,并让addImage尺寸不超出实际像素比例 |
| 导出图片全黑 | 背景透明,PDF默认显示为黑色 | 设置backgroundColor: '#ffffff' |
| 图片跨域画不出来 | canvas被污染,跨域资源未加载 | img加crossOrigin,服务端配置CORS,或用base64转存 |
| 分页截断文字 | 长图按固定高度硬切 | 用元素断点算法,或对页面做分块截图 |
| 样式缺失或错乱 | html2canvas不支持的CSS特性 | 在onclone里做打印适配,替换或简化样式 |
| 截图时字体还没加载完 | 字体阻塞截图时机 | 先await document.fonts.ready再截图 |
| 内容太宽被压缩 | 页面有横向滚动条 | 在onclone里固定宽度,或设置html2canvas的width参数 |
| 导出大页面内存爆掉 | canvas过大导致浏览器崩溃 | scale不要贪大,必要时对内容分块导出 |
4.2 页面图片跨域导致的空白问题
这个是最常见的坑,尤其在页面包含第三方图片素材时。html2canvas默认不允许绘制跨域图片,即便useCORS设为true,服务器也必须返回CORS头,缺一个环节都会空白。
我的排查顺序是:
- 检查img标签是否加了crossOrigin="anonymous"。
- 打开控制台,看Network面板里这个图片的响应头有没有Access-Control-Allow-Origin。
- 如果图片在CDN上且不方便配头,干脆在页面加载后把图片转成base64,替换img的src。这样canvas绘制就完全没有跨域问题了。
这里给一个转base64的小工具函数:
javascript复制function imgToBase64(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
resolve(canvas.toDataURL('image/png'));
};
img.onerror = reject;
img.src = url;
});
}
但要注意,有些图片服务器完全不允许跨域,这种转base64也会失败,那就只能后端代理图片或者导出前提示用户。
4.3 多页导出时文字被截断的优化方案
固定高度切长图,效果就像拿剪刀把一条横幅剪成几段,遇到黑体大字体会被拦腰剪断。这个问题我自己项目里遇到过,当时用户导出报告,表格里一行数据刚好被切到两页,看起来非常不专业。
思路是“在接近页面底部的位置,找最近的元素边界作为断点”。做法分两步:
- 遍历页面内的DOM元素,记录每个元素的offsetTop和offsetHeight。
- 计算每页能显示的内容高度,当遍历到的元素会超出页面底部时,把断点定位到上一个元素底部,而不是硬切。
代码示意:
javascript复制function findPageBreakPositions(element, pageHeightPx) {
const elements = element.querySelectorAll('*');
const breaks = [];
let currentY = 0;
let pageStart = 0;
elements.forEach(el => {
const top = el.offsetTop;
const bottom = top + el.offsetHeight;
if (bottom > currentY + pageHeightPx) {
breaks.push(top);
currentY = top;
}
});
return breaks;
}
然后导出时,按断点位置切分canvas,而不是按平均高度切。这个方案对表格、卡片类布局效果很好。不过实现起来复杂度会上升,需要你针对自己的页面结构做调整。如果页面元素比较规整,比如文章、报告,强烈建议做这个优化。
4.4 导出前等待图片和字体加载完成
很多同学遇到“导出后顶部空白”、“截图时图片没显示出来”的情况,不是代码有问题,而是截图时机太早。
页面里的图片可能还在加载中,web字体可能还没生效,html2canvas并不等这些资源就绪。稳妥做法是:
javascript复制// 等待字体加载
await document.fonts.ready;
// 等待所有图片加载完成
const images = element.querySelectorAll('img');
await Promise.all(Array.from(images).map(img => {
if (img.complete) return Promise.resolve();
return new Promise(resolve => {
img.onload = resolve;
img.onerror = resolve; // 即使加载失败也继续,避免卡死
});
}));
把这两步放在html2canvas之前,导出的稳定性会明显提升。
4.5 为什么有时候导出后页面多出空白页
空白页通常有两个来源:
第一个是canvas高度计算包含了隐藏元素或padding。比如容器本身有margin-bottom,或者flex布局里子元素把高度撑开了,但视觉上看不出来。处理办法是在导出前把目标容器的高度固定,临时写成实际内容高度。
第二个是PDF分页循环里“多此一举”地调用了addPage。比如内容刚好占满一页,remaining计算后为0,但代码逻辑里在循环底部无条件addPage,结果多出一个空白页。我建议在addPage前先判断是否还有剩余内容,像上面代码那样加一个if (remaining > 0)。
5. 进阶玩法:让导出效果更专业
5.1 自定义页眉页脚和密级标识
很多企业内部报表要求在PDF顶部显示标题和密级,底部显示页码和导出时间。你可以用jsPDF的text方法逐页添加文字。
关键是定位:页眉一般放在顶部margin区域,页脚放在底部margin区域。别忘了给内容区多留出空间,否则页眉会和正文图片重叠。我通常的做法是把内容区的起始y从margin加大到margin + 40,这样顶部有空间放页眉。
javascript复制const headerTitle = '订单汇总报表';
const totalPages = pdf.getNumberOfPages();
for (let i = 1; i <= totalPages; i++) {
pdf.setPage(i);
pdf.setFontSize(12);
pdf.setTextColor(80);
pdf.text(headerTitle, margin, 20);
pdf.setFontSize(9);
pdf.setTextColor(150);
pdf.text(`共 ${totalPages} 页 第 ${i} 页`, pageWidth - margin, pageHeight - 10, { align: 'right' });
}
5.2 导出前对页面做临时“打印适配”
这里分享一个我后来一直在用的窍门:在onclone里,把页面调成“适合打印的样子”,而不是直接截原页面。
比如原页面表格有hover高亮,有响应式折叠列,导出时这些交互样式没有任何意义,反而让PDF看起来怪。可以在onclone里做:
- 隐藏筛选条件、操作按钮、分页控件
- 固定表格宽度,防止窄屏下换行
- 强制展开所有折叠区域(如果内容本身可展开)
- 把浅色文字调深,保证黑白打印可读
这个思路和CSS里的@media print很像,只是作用于canvas截图过程。
5.3 应对超大内容的导出
如果页面内容特别长,比如几万行的表格,直接用一张超大canvas截图,浏览器很容易崩溃。我建议分块导出:
把目标容器按视口高度分成多段,用html2canvas只截取每一段,依次贴入PDF。这样每一张canvas都不会太大,内存占用平稳。代价是实现复杂度提升,需要控制windowWidth和windowHeight参数,并且要处理滚动位置的还原。
我的经验是,内容超过20000px高度时,优先考虑分块;如果内容不超过10000px,直接用长图方案更省事。
javascript复制async function exportLargeContent(element, chunkHeight = 2000) {
const pdf = new jsPDF({ unit: 'pt', format: 'a4' });
const pdfWidth = pdf.internal.pageSize.getWidth();
const totalHeight = element.scrollHeight;
let y = 0;
while (y < totalHeight) {
const height = Math.min(chunkHeight, totalHeight - y);
const canvas = await html2canvas(element, {
y: y,
height: height,
width: element.scrollWidth,
windowHeight: height,
scale: 2
});
const imgData = canvas.toDataURL('image/png');
const imgHeight = canvas.height * pdfWidth / canvas.width;
if (y > 0) pdf.addPage();
pdf.addImage(imgData, 'PNG', 0, 0, pdfWidth, imgHeight);
y += chunkHeight;
}
pdf.save('large-export.pdf');
}
这个方案还有一个额外好处:每块截图之间,你可以插入一个“内容连续”的判断,如果某块内容特别短,说明这一段可能就是页面的自然分页点,可以手动调整y坐标,让切分更合理。
5.4 保存文件名和导出按钮交互
最后提两个提升体验的小细节:
- 文件名最好带上时间戳或业务单号,避免用户重复下载覆盖。
- 导出过程中加loading遮罩,因为截图和生成PDF是异步的,时间稍长用户会以为卡死了。
我习惯封装一个统一的导出工具函数,内部维护一个isExporting标志,防止用户重复点击。这些细节加在一起,功能才算真正“能用”。
其实做了几个项目之后,我最大的体会是:html2canvas加jspdf这套组合,写代码本身不难,难的是把样式兼容、分页策略、资源加载这些边角问题处理干净。你只要记住“先保证截图内容完整,再处理清晰度,最后优化分页细节”这个顺序,大概率不会翻车。
如果将来要做更高质量的需求,可以调研一下html2canvas-pro这类维护更积极的替代品,它修了很多样式兼容问题。但就现在的稳定性和社区资料量来说,html2canvas加jspdf依然是前端导出PDF最快的落地路径。希望这篇文章能帮你把导出功能一次写顺。
