Converting a JavaScript-Rendered Web Page to PDF 的封面图片

PetrDev

如果你曾经尝试过以编程方式将现代网页转换为 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。做到这些,动态页面就能干净地转换。

这个完整、可复制粘贴的版本——加上固定页眉和多页处理——值得保存在某个代码片段里。你会比预想的更频繁地用到它。