做Web开发这些年,被问得最多的需求里,“页面上加个下载PDF的按钮”绝对排得上前三。产品经理甩一句话就跑了,听着简单,可真正动手做的时候,你首先要面对一个灵魂拷问:这个PDF到底应该怎么生成?是把当前页面内容原样保存下来,还是按固定的报表模板重新排一版?是要文字能复制、能搜索,还是只需看个大概?方向没定清,后面每一步都可能白干。
这篇我把这些年踩过的坑和用过的方案统一梳理一遍,从浏览器原生打印、纯前端合成图片,到服务端无头浏览器渲染,都会给出适用范围、核心代码和避坑要点。如果你是前端、全栈,或者刚接手一个要做文档导出的项目,这篇应该能帮你少走不少弯路。
1. 先想清楚:你的“页面下载PDF”到底该走哪条路
很多团队在这个需求上栽跟头,不是因为技术不行,而是因为一开始就没分清自己属于哪一类场景。我见过有人在纯前端项目里硬上Puppeteer,也见过有人在需要文字可搜索的合同场景里用截图生成PDF,最后都返工了。
1.1 场景决定方案,先对号入座
我习惯把“页面下载PDF”拆成三类场景:
- 用户浏览的是一个展示型页面,希望点击按钮把当前看到的内容保存成PDF。比如订单详情、个人简历、数据报表。这类页面往往样式复杂,甚至包含图表、统计图。
- 页面上本来就有一个PDF文件的链接或预览iframe,用户只是想“把这个文件下载下来”。这其实是文件下载,不是生成PDF,方案完全不同。
- 后端要根据数据生成一份全新的PDF文件,比如合同、发票、成绩单。前端页面只是触发入口,真正的重活在后端模板渲染。
前两类是前端主战场,第三类虽然也会有前端参与,但核心是后端模板。本文前四章主要围绕第一类,第五章会把第二类场景单独拉出来讲。
1.2 四类主方案横评
我把实际项目里用过的方案做了一张对比表,你可以直接保存下来作为选型参考。
| 方案 | 实现位置 | 保真度 | 文字可选 | 分页控制 | 适用场景 |
|---|---|---|---|---|---|
| 浏览器打印(@media print + window.print) | 浏览器端 | 高,取决于浏览器打印引擎 | 是,矢量文字 | CSS控制,中等能力 | 页面即详情页、表单页,用户手动另存为PDF |
| 前端截图合成(html2canvas + jsPDF) | 浏览器端 | 中,图片化易模糊 | 否,导出的全是图片 | 按图像切页,易截断 | 数据大屏、海报、简单分享卡片 |
| 无头浏览器(Puppeteer / Playwright) | 服务端 | 很高,所见即所得 | 是 | CSS控制,能力强 | 报表、合同、订单详情等需要高保真场景 |
| 后端模板重绘(iText / WeasyPrint / ReportLab) | 服务端 | 取决于模板设计 | 是 | 代码控制,精细 | 数据驱动、固定版式、批量生成 |
看完这张表,你可以记住一个粗选口诀:能打印就别截图,要保真就上无头浏览器,要批量就走后端模板重绘,能直接下载文件就直接下载,别绕弯生成。
1.3 两个容易被忽略的事实
第一,浏览器打印方案和无头浏览器方案本质上是一样的。它们都是“浏览器把内容渲染到打印介质上,再输出PDF”,差别只是一个有界面、一个无界面。所以后面章节讲的CSS打印分页技巧,在这两种方案里是通用的。
第二,前端截图合成方案虽然实现简单,但它生成的是“图片型PDF”。文字不可选中、不可搜索,文件体积也偏大。如果业务方要求PDF里的内容能被搜索引擎收录、能被读者复制粘贴,那这个方案从一开始就不该出现在候选列表里。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 浏览器原生打印导出:被低估的第一选择
我先说一个可能让很多人意外的结论:对于“页面内容导出PDF”这个需求,chrome的 window.print() 加打印样式,是性价比最高的方案。它不需要引入任何第三方库,不需要后端服务,生成的文件是矢量文字,放大不模糊,体积还特别小。
2.1 核心三步:打印样式、触发打印、让用户存PDF
第一步,给要打印的内容包一个容器,比如 #printArea。
第二步,写打印专用CSS。这是整个方案的关键,我直接给你一份能用的模板:
css复制@media print {
/* 隐藏不需要打印的元素 */
.no-print {
display: none !important;
}
/* 如果只打印页面某个区块,用 visibility 方案 */
body * {
visibility: hidden;
}
#printArea,
#printArea * {
visibility: visible;
}
#printArea {
position: absolute;
left: 0;
top: 0;
width: 100%;
}
/* 页面级控制:纸张、边距 */
@page {
size: A4;
margin: 12mm;
}
/* 防止卡片、表格行被拦腰切断 */
.card,
tr {
break-inside: avoid;
page-break-inside: avoid;
}
/* 强制分页 */
.page-break {
break-before: page;
page-break-before: always;
}
/* 长表格:表头在每页重复 */
thead {
display: table-header-group;
}
}
第三步,触发打印:
html复制<button id="downloadBtn">下载PDF</button>
<div id="printArea">
<!-- 这里是业务内容 -->
</div>
<script>
document.getElementById('downloadBtn').addEventListener('click', function () {
window.print();
});
</script>
点击按钮后,浏览器会弹出打印预览对话框,用户把“目标打印机”选择成“另存为PDF”,点保存就完成了。这个交互路径用户其实很熟悉,尤其是办公场景下用了一辈子Adobe PDF打印机的人。
2.2 为什么用 visibility 而不是 display: none
我见过很多新手写打印样式时,喜欢把“不需要的元素”全部 display: none,把“需要的元素”正常显示。这样做的隐患是:一旦你隐藏了某个占据宽度的侧边栏,页面剩余内容的宽度就会重新计算,打印出来的布局可能和屏幕上看到的差很多。
visibility: hidden 方案不会改变元素的占位,元素还在那里占着位置,只是视觉上不可见。再加上把 #printArea 设为绝对定位、铺满视口宽度,能最大程度保证打印布局和屏幕布局一致。这个方案特别适合“只打印页面中间一块内容”的情况。
当然,如果整个页面都是要打印的内容,只是隐藏几个导航、按钮,那直接用 display: none 隐藏那几个元素就够了,不必搞绝对定位。
2.3 打印预览不必每次都开
调试打印样式最烦的就是每次都要点“打印预览”再关掉。Chrome DevTools自带一个隐藏功能:按 Ctrl+Shift+P 打开命令菜单,输入 Rendering,打开“渲染”面板,在“模拟CSS媒体类型”里选择 print。这样你在页面上做的任何样式调整,都会实时按打印CSS渲染,所见即所得。
我在开发阶段几乎都是开着这个模拟环境调分页的,调好了再去真实打印预览里抽查一两次就够。
2.4 打印方案常见的坑
背景色和背景图默认不打印。 表格的斑马纹、区块的底色、带背景色的标题,到了PDF里全变白。解决办法是在 @media print 里给元素加上:
css复制* {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
这里有个细节:Chrome打印对话框里有个“背景图形”勾选项,就算CSS没写 print-color-adjust,用户手动勾选也能打印背景色。但你不能指望用户去勾,所以代码里必须写清楚。
页眉页脚很难彻底关掉。 Chrome打印预览默认会在PDF顶部输出页面标题,底部输出日期和URL。CSS没法百分百取消这些东西,因为这是浏览器层面的行为。但通过设置 @page { margin: 12mm; },可以压缩页眉页脚的位置。实测下来,设置过边距后,用户只要在打印预览里取消勾选“页眉和页脚”,导出的PDF就很干净。
fixed定位元素会在每一页重复出现。 页面上如果有悬浮客服按钮、固定在顶部的操作条,打印时会出现在每一页的同一个位置,非常碍事。在打印CSS里务必把这些元素 display: none。
2.5 iframe里的页面怎么打印
页面里嵌了iframe预览PDF,外层有个“打印/下载”按钮,这是热搜词里反复出现的组合。如果iframe与父页面同源,直接触发iframe内的打印:
js复制const iframe = document.getElementById('pdfFrame');
iframe.contentWindow.focus();
iframe.contentWindow.print();
如果你用的是jQuery,需要确保iframe加载完成之后再调用:
js复制$('#pdfFrame').on('load', function () {
const win = $(this)[0].contentWindow;
win.focus();
win.print();
});
这里有个必须提醒的点:如果iframe里的内容是一个真实的 .pdf 文件,而不是一个HTML页面,Chrome对PDF有自己的预览插件,contentWindow.print() 不一定能弹出打印对话框。更可靠的做法是直接下载这个PDF文件,具体方案在第五章会讲到。
3. 纯前端合成图片式PDF:html2canvas + jsPDF 方案
如果说打印方案是“第一选择”,那前端截图合成方案就是“应用最广泛但也最容易被骂”的方案。它上线快,效果直观,唯一的缺点是——它不是真正的排版PDF,而是把页面截图后塞进PDF的图片型文档。
3.1 基本原理
html2canvas 会把指定的DOM节点重新绘制成一个canvas位图,jsPDF 负责创建一个PDF文件,并把这张位图按A4页面尺寸切分成若干页,逐页写入。听起来很直接,但问题恰恰出在“重新绘制”这四个字上。
为什么不是直接把元素渲染到PDF里?因为jsPDF不认HTML,它只认图形指令。所以只能先把HTML变成图片,再把图片写进PDF。这也是为什么最终导出的PDF文字不可选中、文件体积大、放大后边缘会发虚。
3.2 一个可以直接抄的分页实现
先说安装依赖:
bash复制npm install html2canvas jspdf
下面这份代码我经过多次优化,处理了多页截断、高清缩放、白色背景填充,你可以直接复制到项目里用:
js复制import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
async function domToPdf(targetId, fileName = 'download.pdf') {
const target = document.getElementById(targetId);
// 等待图片和字体加载完成,避免截图里出现空白
const imgs = Array.from(target.querySelectorAll('img'));
await Promise.all(imgs.map(img => img.complete ? Promise.resolve() : new Promise(resolve => {
img.onload = () => resolve();
img.onerror = () => resolve();
})));
await document.fonts.ready;
const canvas = await html2canvas(target, {
scale: Math.max(window.devicePixelRatio, 2),
useCORS: true,
backgroundColor: '#ffffff',
});
const pdf = new jsPDF('p', 'pt', 'a4');
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
const margin = 12;
const contentWidth = pageWidth - margin * 2;
const contentHeight = pageHeight - margin * 2;
const cw = canvas.width;
const ch = canvas.height;
const scaleFactor = contentWidth / cw;
const drawHeight = ch * scaleFactor;
let offsetY = 0; // 已切到第几张图的偏移量
let remaining = ch; // 剩余画布高度
while (remaining > 0) {
// 当前页能放下多少画布像素
const sliceH = Math.min(remaining, contentHeight / scaleFactor);
// 创建当前页的切片画布
const sliceCanvas = document.createElement('canvas');
sliceCanvas.width = cw;
sliceCanvas.height = Math.round(sliceH);
const ctx = sliceCanvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, cw, sliceCanvas.height);
ctx.drawImage(canvas, 0, offsetY, cw, sliceCanvas.height, 0, 0, cw, sliceCanvas.height);
// 把切片写入PDF当前页
const drawW = contentWidth;
const drawH = sliceCanvas.height * scaleFactor;
pdf.addImage(sliceCanvas.toDataURL('image/jpeg', 0.95), 'JPEG', margin, margin, drawW, drawH);
offsetY += sliceCanvas.height;
remaining -= sliceCanvas.height;
// 还有剩余内容则新增一页
if (remaining > 0) {
pdf.addPage();
}
}
pdf.save(fileName);
}
调用方式很简单:
js复制document.getElementById('downloadBtn').addEventListener('click', function () {
domToPdf('printArea', '我的报表.pdf');
});
这里几个细节值得说明。scale 取 Math.max(window.devicePixelRatio, 2) 是为了保证清晰度,普通屏幕上1倍缩放导出的PDF会发虚。backgroundColor: '#ffffff' 是为了防止页面某些区域透明,导致PDF输出黑色背景。切片时每页都先填充白色,再画图,避免JPEG压缩时出现黑边。
3.3 如果你要的是快速实现,html2pdf.js更省事
不想手写分页逻辑,可以直接用 html2pdf.js,它把 html2canvas 和 jsPDF 封装成了一个API:
js复制import html2pdf from 'html2pdf.js';
html2pdf()
.set({
margin: 12,
filename: 'demo.pdf',
image: { type: 'jpeg', quality: 0.95 },
html2canvas: { scale: 2, useCORS: true },
jsPDF: { unit: 'pt', format: 'a4', orientation: 'portrait' },
pagebreak: { mode: ['css', 'legacy'] }
})
.from(document.getElementById('printArea'))
.save();
pagebreak 配置会让它在CSS规定的分页处切页,也能尽量避免元素被切成两半。但它解决不了本质问题:内容一旦超过一页,总会在图片切分处出现不自然的断裂。
3.4 这个方案真正的软肋
我总结下这个方案在真实项目里翻车的几个场景,你提前避开:
跨域图片污染canvas。 如果页面里有来自CDN或OSS的图片,html2canvas 默认无法读取跨域图片的像素数据,截图区域会空白或直接报错。解决办法是 useCORS: true,同时图片服务器必须返回 Access-Control-Allow-Origin 响应头。不少云存储控制台默认不开启这个头,需要单独配置跨域规则。
长页面内存暴涨。 一个几万像素高的页面,生成的canvas是非常吃内存的,老一点的手机会直接白屏或崩溃。我的经验是,超过10屏的内容就不建议用这个方案,要么分段截取,要么换服务端方案。
截断位置不可控。 无论你怎么优化切片逻辑,都可能出现某一行文字被从中间切开的情况。业务方如果对排版细节敏感,这个方案很容易被投诉。
4. 服务端无头浏览器渲染:高保真导出的终极解法
当需求变成“必须和页面一模一样”“打印出来要正式”“要能给客户看”的时候,我基本会直接上无头浏览器。Puppeteer和Playwright的核心原理相同:启动一个没有界面的浏览器,让它打开页面,然后调用浏览器的PDF引擎把页面输出成PDF。因此页面看到什么样,PDF就是什么样。
4.1 Puppeteer实现一个最简单的PDF服务
安装依赖:
bash复制npm install puppeteer
注意,这样安装会同时下载一个Chromium浏览器,体积大约一两百MB。如果你服务器上已经有Chrome或Chromium,可以改用 puppeteer-core 并通过 executablePath 指定浏览器路径。
核心代码:
js复制const puppeteer = require('puppeteer');
async function pageToPdf(urlOrHtml, outputPath) {
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
try {
const page = await browser.newPage();
// 强制使用打印样式,这一步很关键
await page.emulateMediaType('print');
if (urlOrHtml.startsWith('http')) {
await page.goto(urlOrHtml, {
waitUntil: 'networkidle0',
timeout: 30000
});
} else {
await page.setContent(urlOrHtml, {
waitUntil: 'networkidle0'
});
}
await page.pdf({
path: outputPath,
format: 'A4',
printBackground: true,
preferCSSPageSize: true,
margin: {
top: '12mm',
bottom: '12mm',
left: '12mm',
right: '12mm'
}
});
} finally {
await browser.close();
}
}
调用方式:
js复制await pageToPdf('https://example.com/report', './report.pdf');
// 或者
await pageToPdf('<html><body><h1>hello</h1></body></html>', './hello.pdf');
preferCSSPageSize: true 的意思是,如果页面CSS里写了 @page { size: A4 },就优先按CSS定义的纸张来,否则用代码里的 format: 'A4'。这个参数强烈建议开启,因为很多页面已经写好了打印样式,你不希望两套配置打架。
4.2 服务端接入的两种模式
实际项目里,前端怎么把“当前页面”交给服务端,是很多人卡住的地方。我接触过的项目主要有两种模式。
模式一:前端传HTML字符串。前端点击下载按钮时,把要打印的区域提取出来:
js复制const html = document.getElementById('printArea').outerHTML;
fetch('/api/export-pdf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ html })
}).then(res => res.blob()).then(blob => {
// 触发浏览器下载
});
后端收到HTML后,用 page.setContent(html) 渲染并导出PDF。这种模式的优点是前端不需要把页面部署到公网,后端也拿不到登录态。缺点是:如果页面里用了很多动态脚本、图表库,单纯 outerHTML 拿到的只是DOM快照,脚本不会重新执行,canvas绘制的图表也不会重新渲染。
我踩过这个坑之后,总结了一个变通方案:对echarts等canvas图表,前端先 chart.getDataURL() 拿到图片base64,替换到HTML字符串里的占位 <img> 标签,再发给后端。这样虽然图表退化成了图片,但至少能正常导出。
模式二:前端传URL。前提是页面可以公网访问,或者后端在内网能访问到页面地址。对无头浏览器来说,直接 goto(url) 是最自然的,所有前端逻辑都会重新执行一遍。但要注意鉴权问题:页面如果依赖Cookie登录,无头浏览器打开时会跳到登录页。常见的解决办法是前端把登录Cookie传到后端,后端在打开页面之前用 page.setCookie 设置进去。
4.3 中文字体的坑,必踩
无头浏览器跑在服务器上,服务器操作系统如果没有安装中文字体,导出的PDF里中文会全部变成方块或乱码。这不是代码问题,是字体缺失。
我处理过几回这个情况,方向是固定的:安装中文字体,或者把字体打包进项目。
Ubuntu/Debian服务器可以执行:
bash复制apt-get install -y fonts-noto-cjk
如果你的导出服务是用Docker部署的,一定要在Dockerfile里加上这一行。装完之后,可以用 fc-list | grep -i cjk 确认字体是否可用。
还有一种情况:页面本身使用了自定义web字体,比如思源黑体、阿里巴巴普惠体。无头浏览器加载字体需要时间,如果 waitUntil: 'networkidle0' 没等到字体加载完成就执行 page.pdf,导出的PDF里文字就是系统字体渲染的,观感可能差很远。解决方法是加一行等待:
js复制await page.evaluate(() => document.fonts.ready);
4.4 性能优化与生产部署
每次导出都 launch() 一个新的浏览器实例,再 close(),性能和资源消耗都很糟糕。我实测过,单个浏览器实例的启动就需要好几秒,内存占用通常在两三百MB。如果导出接口被频繁调用,服务器会很难受。
我的做法是:在服务端启动时创建一个常驻的浏览器实例,后续每次导出只新建页面,导出后关闭页面,不关浏览器。这样浏览器只启动一次,后续请求的响应时间能缩短一半以上。
另一个必须考虑的是并发。无头浏览器对并发任务的支持并不好,多个页面同时执行导出任务可能相互抢占资源。我建议把导出接口做成队列任务,或者用一些现成的库做并发池管理。实在不行,至少要限制同一时间只执行一个导出任务,其他的排队等待。
4.5 无头浏览器与打印CSS的配合
无头浏览器走的就是浏览器渲染管线,所以第二章里讲的 @media print 打印样式、@page 规则、page-break-inside 分页控制,在这里完全通用。尤其是分页控制,无头浏览器导出的PDF同样可能出现表格行被切断、区块跨页的问题,解决方案和打印方案一模一样。
我通常会先在DevTools里用print模拟把打印CSS调好,再交给Puppeteer导出。这样视觉调试和最终输出的一致性很高,省去大量在服务端反复测试的时间。
5. 文件名、iframe、已有文件下载:最容易忽略的落地细节
真到了上线阶段,你会发现“把页面变成PDF”只是前半场,后半场全是按钮交互、文件下载、移动端兼容这些看着不起眼、做起来处处坑的细节。
5.1 下载已有PDF文件,不是所有项目都能用a标签
当需求是“页面上有个PDF链接,用户点一下就能下载”,很多人的第一反应是:
html复制<a href="/files/report.pdf" download>下载</a>
download 属性在同源情况下很好用,点击后浏览器会直接下载而不是打开预览。但一旦文件链接是跨域的,比如放在了OSS或CDN上,download 属性会失效,浏览器依然会跳到预览页面。
跨域场景下稳妥的做法是走fetch拿字节流,再转成Blob下载:
js复制async function downloadFile(url, fileName) {
const response = await fetch(url, {
headers: { 'Authorization': 'Bearer ' + getToken() }
});
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = fileName;
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(blobUrl);
document.body.removeChild(a);
}
这个方案的另一个好处是:接口需要带Token或者自定义Header时,a 标签做不到,但fetch可以。
5.2 中文文件名乱码
前端用jsPDF的 pdf.save('我的报表.pdf') 一般没问题。但如果是后端返回的下载流,响应头里要正确设置中文文件名,否则浏览器下载下来的文件名是乱码。
后端设置响应头时,推荐同时给 filename 和 filename*:
http复制Content-Disposition: attachment;
filename="report.pdf";
filename*=UTF-8''%E6%88%91%E7%9A%84%E6%8A%A5%E8%A1%A8.pdf
filename 是旧版浏览器的回退方案,filename* 按RFC 5987标准编码中文,现代浏览器都会优先读它。
5.3 iframe预览PDF,想从父页面下载怎么做
这个场景特别常见:右侧内容是 <iframe src="xxx.pdf">,顶部有“下载”按钮。如果你能拿到iframe的src,最直接的办法是调上面的 downloadFile 接口直接下载。它不关心iframe里显示的是什么,它只知道源地址。
如果文件源是接口返回的字节流,而不是一个可访问的URL,那就要看接口的鉴权方式。如果
