如果你曾经尝试过以编程方式将现代网页转换为 PDF,你可能已经遇到过这个难题:文件最终要么是空白的,要么内容不完整,要么卡在加载动画上。页面在浏览器里看起来完美无缺,问题出在哪里?
答案在于时机。大多数 PDF 工具都是在 JavaScript 渲染内容之前 就抓取了 HTML。对于服务端渲染的页面来说这没问题——标记本身已经存在。但对于 React/Vue/Angular 应用,服务端发送的是一个近乎空的壳,浏览器随后才构建 DOM。过早抓取就只会保存这个空壳。
下面介绍正确的做法。
为什么朴素的方法会失败
wkhtmltopdf 是经典的 Google 答案。它很快,而且存在已久,但使用的是古老的 WebKit 构建,实际上不支持现代 JavaScript。对于静态页面没问题,但对于任何客户端渲染的内容,它捕获的都是空状态。
浏览器的 window.print() / Ctrl+P 之所以有效,是因为它 确实 是一个真正的浏览器——但它是手动的、单页的,而且不可能在规模化场景下干净地自动化。
用 HTTP 客户端直接请求原始 HTML(axios/fetch 然后传给 PDF 库)有着和 wkhtmltopdf 相同的致命缺陷:没有 JS 执行,就没有渲染后的内容。
你真正需要的是一个能运行页面 JavaScript、等待其稳定下来,然后 再打印的真实浏览器引擎。那就是 Puppeteer。
Puppeteer 方法
Puppeteer 驱动无头 Chromium。它执行页面的方式与普通 Chrome 标签页完全一致,因此屏幕上渲染的内容就是它会捕获的内容。
const puppeteer = require('puppeteer');
async function pageToPdf(url, outPath) {
const browser = await puppeteer.launch({
args: ['--no-sandbox', '--disable-setuid-sandbox'], // needed in most containers
});
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle0', timeout: 60000 });
await page.pdf({
path: outPath,
format: 'A4',
printBackground: true, // otherwise CSS backgrounds/colors are dropped
margin: { top: '20px', bottom: '20px', left: '16px', right: '16px' },
});
await browser.close();
}
pageToPdf('https://example.com', 'out.pdf');
Enter fullscreen mode Exit fullscreen mode
这里有两个选项比看起来更重要:
-
waitUntil: 'networkidle0'告诉 Puppeteer,只有在 500ms 内没有网络连接时才认为导航完成。对于大多数 SPA 来说,这是数据已加载、DOM 已构建的信号。(networkidle2允许最多 2 个连接——适用于带有长轮询或分析信标且永远不会完全安静的页面。) -
printBackground: true——如果忘记这个,每一个背景颜色、渐变和图像都会消失,因为 Chrome 的打印路径默认会剥离它们。
仍然会困扰你的陷阱
生成非空白 PDF 是第一步。生成一个 正确 的 PDF 才是真正暴露问题的地方。
懒加载内容
在滚动时加载的图片和区块不会出现在 PDF 中,因为 Puppeteer 从未滚动。可以在打印前通过自动滚动强制加载它们:
await page.evaluate(async () => {
await new Promise((resolve) => {
let y = 0;
const step = 300;
const timer = setInterval(() => {
window.scrollBy(0, step);
y += step;
if (y >= document.body.scrollHeight) {
clearInterval(timer);
window.scrollTo(0, 0);
resolve();
}
}, 100);
});
});
Enter fullscreen mode Exit fullscreen mode
Web 字体尚未加载
文本以备用字体渲染,因为 Web 字体在打印时尚未完成加载。显式等待它:
await page.evaluateHandle('document.fonts.ready');
Enter fullscreen mode Exit fullscreen mode
Print CSS 覆盖了你的布局
如果网站提供了 @media print 样式表,Chrome 会应用它。有时这是你想要的;有时它会破坏布局。用以下方式强制使用屏幕渲染:
await page.emulateMediaType('screen');
Enter fullscreen mode Exit fullscreen mode
固定页眉重复或遮挡内容
粘性/固定元素可能在 PDF 的每一页上重复出现,或与正文重叠。可靠的修复方法是在打印前通过 page.addStyleTag() 将它们的位置设置为 static(或隐藏它们)。
何时自托管 Puppeteer 不值得
当你需要在自己控制的管道中生成 PDF 时,Puppeteer 是正确的工具。但它带来了真正的运维负担:无头 Chromium 很重(200MB+),需要容器中正确的系统库,如果不仔细管理浏览器实例就会泄漏内存,在并发情况下如果不池化页面并限制并行度就会崩溃。对于一个边角功能——“让用户导出此页面”——需要维护的基础设施太多了。
如果你只需要输出而不需要底层设施,托管服务可以在底层完成相同的无头渲染和捕获,而无需运维负担。我运营 site2pdf.online 正是为此——它在真实的 Chrome 中渲染每个页面(因此 JS 内容能正确呈现),还能跟随网站的内部链接一次性捕获多页,导出为 PDF、PNG 或 ZIP。当替代方案是搭建和维护自己的 Chromium 集群时,这很方便。
经验法则:如果 PDF 生成是产品的核心,请自托管 Puppeteer 并掌控它。如果只是边缘的便利功能,请外包出去。
总结
空白 PDF 问题几乎总是一个时机问题:捕获发生在 JavaScript 渲染之前。使用真实的浏览器引擎,等待网络(和字体)稳定,滚动触发懒加载内容,并启用 printBackground。做到这些,动态页面就能干净地转换。
这个完整、可复制粘贴的版本——加上固定页眉和多页处理——值得保存在某个代码片段里。你会比预想的更频繁地用到它。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.