☰
Node.js + Puppeteer 网页转 PDF 完整指南:从选型到避坑
2026/10/11 14:25:11 网站建设 项目流程

前几天临时接了个“小活”:把某个运营后台里的报表页面,一键导出成 PDF 发给合作方。页面是 Vue 写的,内容有表格、折线图,还有几个 tab 切换出来的统计区块。一开始我以为这事简单,毕竟浏览器本身就有“打印 -> 另存为 PDF”,后来才发现,要的是在 Node.js 服务端跑一个接口,用户点一下按钮,后端把线上页面抓下来、渲染完、生成 PDF 传回来。整个链路走下来踩了不少坑,也沉淀了一套相对顺手的做法,这里完整记录一下。

这篇文章会围绕“使用 Node.js 代码将网页一键导出成本地 PDF 文件”这个主题来展开,适合正在做报表导出、合同生成、订单凭证、邮件附件这类功能的后端开发者,也适合前端同学想了解服务端无头浏览器的玩法。我会把方案选型、代码实现、参数调优、常见坑都讲透。

1. 为什么我最终选了 Puppeteer 这条路

1.1 先说说市面上常见的几种方案

网页转 PDF,听起来高大上,但本质上就是“用浏览器打开页面,然后调用打印引擎输出 PDF”。围绕这个核心,Node.js 生态里有几条常见路线。

  • 纯 Node 库解析 HTML 生成 PDF:比如旧的 html-pdf 这类库,本质是把 HTML 丢给一个内置的渲染内核去排版。优点是轻量、不依赖系统里的浏览器,缺点是它对现代 CSS 的支持非常弱,flex、grid、CSS 变量、Canvas 图表基本全废,稍微复杂点的页面就排版错乱。
  • 调用系统浏览器远程调试协议生成 PDF:Puppeteer 和 Playwright 都属于这一类。它们通过 CDP(Chrome DevTools Protocol)驱动本机的 Chrome/Chromium,用浏览器真正的排版引擎去渲染页面,再调用 Chrome 的 PDF 打印能力输出文件。兼容性最好,效果和你在浏览器里按 Ctrl+P 一模一样。
  • 命令行工具路线:比如 wkhtmltopdf,老牌工具,性能不错,但它的内核版本普遍偏老,对 flexbox、grid 的支持跟不上。新项目我不太推荐再导入这种包袱。

我在实际对比之后,把赌注压在了 Puppeteer 上。它的底层是 Chromium,渲染结果就是“用户实际看到的那个页面”,不用怀疑样式会不会丢。而且 Puppeteer 的 API 抽象得比较舒服,能控制浏览器的几乎所有行为。

1.2 Puppeteer 方案的核心优势

很多人问:如果页面是服务端渲染的静态 HTML,直接拿字符串拼接一个 PDF 不就行了,为什么要动用一个浏览器?

答案很简单。你面对的绝大多数网页都不是静态的。你要导出的报表页可能需要等待接口返回数据,可能包含 ECharts 之类的 Canvas 图表,可能有懒加载的图片,可能按钮事件会影响某些区块的显示。这些东西纯 HTML 解析器根本搞不定。Puppeteer 的价值在于:它不是把 HTML 翻译成 PDF,而是把“渲染完成后的页面”拍成 PDF。

你可以把它理解成一个遥控机器人。它帮你打开 Chrome,输入网址,等页面加载完,等图片出来,等接口数据填充进去,等图表画完,然后才按下打印按钮。整个过程你写的是 JavaScript 代码,却能控制一个完完整整的浏览器。

另外,Puppeteer 在无头模式下资源消耗可控,一个页面实例几十到一百多 MB 内存,服务端完全扛得住。而且它还能复用一个浏览器实例并发处理多个导出任务,后面我会详细讲。

2. 环境准备与最小可用实现

2.1 安装与初始化项目

先创建一个普通的 Node.js 项目,然后安装依赖:

mkdir pdf-export-demo cd pdf-export-demo npm init -y npm install puppeteer

如果网络环境不理想,可以指定使用国内的镜像源安装 Chromium,或者使用puppeteer-core配合本机已有的 Chrome。我平时在开发机上直接用默认的puppeteer就好,它会在安装时自动下载一个配套的 Chromium 版本,省去兼容性问题。

注意:生产环境如果是精简的 Linux 服务器,光装 puppeteer 还不够,Chromium 依赖的系统库一个都不能少。我后面会专门讲这块的坑。

2.2 第一版可以直接跑的导出代码

先写一个最小可用版本。假设我们有个本地的 HTML 文件,需要转成 PDF:

const puppeteer = require('puppeteer'); async function exportPDF() { const browser = await puppeteer.launch({ headless: 'new', // 使用新版无头模式 args: ['--no-sandbox', '--disable-setuid-sandbox'] }); const page = await browser.newPage(); // 加载本地 HTML 文件 await page.goto('file:///path/to/report.html', { waitUntil: 'networkidle0' }); // 输出 PDF await page.pdf({ path: 'report.pdf', format: 'A4', printBackground: true, margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' } }); await browser.close(); console.log('PDF 生成完成'); } exportPDF();

这段代码干了几件事:拉起无头浏览器 -> 打开本地 HTML -> 等待网络空闲 -> 按 A4 纸型导出 PDF。跑通之后,你就有了一个最基础的“网页转 PDF”能力。

关键点是waitUntil: 'networkidle0'。它的意思是:等待页面 500ms 内没有新的网络请求发生,才认为加载完成。对于单页应用或者依赖接口数据的页面,这个参数能避免“页面还没渲染完就把 PDF 导出来了”的尴尬。

2.3 关于 headless 模式的版本选择

这里有个容易被忽略的细节。Puppeteer 的老版本只有一种无头模式,新版本推出了所谓的“新无头模式”(headless: 'new')。新版无头模式更接近真实浏览器行为,对 WebGL、Canvas 等特性的支持更完整。

我实际测下来的感受是:如果页面里有 ECharts、AntV 这类 Canvas 图表,老无头模式偶尔会出现图表空白的情况,切换成headless: 'new'后基本解决。如果你的 puppeteer 版本比较新,其实默认就是新无头模式,不写这个参数也行,但我习惯显式写出来,避免团队里其他人升级依赖后行为发生变化。

提示:page.pdf()这个 API 依赖无头模式运行。如果你设置了headless: false让浏览器窗口显示出来再导出 PDF,会直接报错。调试页面样式时可以开有头模式,真正导出时必须切回无头。

3. 从“能跑”到“好用”:关键参数与细节打磨

3.1 PDF 格式与页面尺寸参数详解

page.pdf()的参数远比看起来复杂,很多效果好不好,全在这些参数里。

  • format:预设纸张规格,A4、A3、Letter都可以。如果不传,默认是 A4。
  • width/height:自定义页面尺寸,传字符串,比如'1200px'。设置后优先级高于format。
  • printBackground:是否打印背景色和背景图。默认值是false,这导致很多人第一次导出后发现深色表格、高亮标签全没了,页面白茫茫一片。做报表导出必须设为true。
  • preferCSSPageSize:是否优先使用 CSS 里@page制定的尺寸。如果你的页面针对打印写过专门的 CSS 规则,这个参数可以保证它们生效。
  • scale:缩放比例,默认 1。页面太宽时,填0.8可以把内容等比缩小塞进纸张。
  • margin:页边距。注意这里的单位用字符串,比如'20mm'、'1in'、'1cm'。
  • displayHeaderFooter:是否显示页眉页脚,配合headerTemplate和footerTemplate使用。
  • landscape:纸张方向,报表横向列很多的时候可以设为true。

我一般会在代码里把这几个参数单独抽出来,做成一个可配置对象,方便不同业务按需覆盖。

const pdfOptions = { format: 'A4', printBackground: true, preferCSSPageSize: true, scale: 1, margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' } };

3.2 等待渲染:动态页面的最大坑

如果导出的页面是纯静态的,networkidle0就够了。但是遇到动态渲染的页面,这里就是事故高发区。

最常见的场景是:页面加载时发起一个异步请求,请求完成后把表格数据渲染到 DOM 上。Chrome 认为自己加载完成了——因为 HTML、JS、CSS 都拿到了,后续那个数据接口虽然是异步的,但可能等networkidle0触发时还没返回。结果就是导出的 PDF 里只有表头没有数据。

对付这种情况,我的思路是“等待关键元素出现”,而不是单纯等网络:

// 等待接口数据渲染到表格中 await page.waitForSelector('#report-table tbody tr', { timeout: 10000 });

如果图表用了 Canvas 绘制,光等 DOM 还不够,还得额外等一下,让 Canvas 真正画完。可以用page.evaluate去读取某个 Canvas 的大小或者像素数据,确认渲染完成。很多图表组件在容器不可见时可能出现 0×0 的画布,这时候可能需要横向循环重试:

await page.waitForFunction(() => { const canvas = document.querySelector('#chart-container canvas'); return canvas && canvas.width > 0 && canvas.height > 0; }, { timeout: 15000 });

这里我踩过一个大坑:某个报表页面里的图表组件只有在它所在的 tab 被激活时才渲染。如果用户打开页面默认停留在第一个 tab,第二个 tab 的 Canvas 就是空白的,导出后当然也是空白。解决方案有二,要么在导出前用page.evaluate把要导出的 tab 先激活,要么改变思路,传一个参数给页面,让页面直接以“全部展开”模式渲染。后者更适合做导出功能,不会影响线上用户的交互体验。

3.3 自定义页眉页脚与页码

做正式的对外文档,页脚里基本都要带页码。Puppeteer 提供了内置的模板类,使用方式比较隐蔽,网上资料少,我直接给结论。

await page.pdf({ format: 'A4', displayHeaderFooter: true, headerTemplate: `<div style="font-size:10px; color:#999; width:100%; text-align:center;">内部资料 · 请勿外传</div>`, footerTemplate: ` <div style="font-size:10px; color:#999; width:100%; text-align:center;"> <span class="pageNumber"></span> / <span class="totalPages"></span> </div> `, margin: { top: '25mm', bottom: '25mm', left: '15mm', right: '15mm' } });

这里有几个要点:

  • headerTemplate和footerTemplate里不能用外部样式表的 class,因为它们在独立的渲染上下文里,只能写内联样式。
  • 默认字体大小是 0,所以模板里必须显式指定font-size,否则啥也看不见。
  • 内置的pageNumber、totalPages、date、title、url这几个 span class 是 Chrome 预留的,可以直接引用。
  • 模板区域显示在页边距里,所以必须给margin留出足够空间,不然页眉页脚和正文会重叠。

我自己习惯把页眉页脚做成两个公共模板,抽到一个工具文件里,所有导出任务复用,这样页码格式统一,改起来也方便。

3.4 分页控制与 CSS 适配

长表格跨页时,默认行为是行被硬生生截断,上一页一半、下一页一半,非常难看。解决办法在页面 CSS 里加规则:

@media print { tr { break-inside: avoid; } .section-block { break-inside: avoid; page-break-inside: avoid; } }

break-inside: avoid表示尽量不把元素拆散到两页。为了兼容旧内核,我通常把page-break-inside也一并写上。除此之外,还可以用page-break-before: always强制某一页从头开始,这很适合给独立的章节开头做分页。

在使用远程页面时,你没法直接改人家的 CSS 文件,但可以在导航完成后注入一段样式:

await page.addStyleTag({ content: ` @media print { tr, .card, .chart-container { break-inside: avoid; page-break-inside: avoid; } h2 { page-break-before: auto; } } ` });

addStyleTag是个非常好用的 API,相当于在页面 head 里临时塞了一个 style 标签,不影响线上页面,只影响当前这次导出的渲染结果。

4. 实战:一键导出带图表和表格的长文档

4.1 场景设定与业务需求

讲完基础,我们串一个完整案例。假设有个模拟项目 X 的运营后台,页面上有三个模块:顶部是概览指标卡,中间是折线趋势图(Canvas),底部是明细数据表格,表格大概有几百行。需求是后端提供一个导出接口,用户访问/api/export?id=123,服务端拉取线上报表页,渲染完成后生成 PDF 返回给前端下载。

页面 URL 是https://report.example.local/project/detail?id=123。这个页面为了适配普通屏幕,指标卡和图表是横向排布的,直接导出 A4 竖版会发现右侧被裁掉。这个问题有两种解法:一种是导出时注入 CSS,把布局改成单列;另一种是直接横版导出。我暂时选竖版并注入 CSS 改造布局,这样文档更通用。

4.2 核心流程实现

导出服务的核心代码长这样:

const puppeteer = require('puppeteer'); const path = require('path'); async function generateReportPDF(reportId, outputPath) { const browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox', '--disable-setuid-sandbox', '--font-render-hinting=none'] }); try { const page = await browser.newPage(); await page.setViewport({ width: 1280, height: 800 }); const url = `https://report.example.local/project/detail?id=${reportId}`; await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 }); // 等待表格数据和图表渲染完成 await page.waitForSelector('#detail-table tbody tr', { timeout: 10000 }); await page.waitForFunction(() => { const canvas = document.querySelector('#trend-chart canvas'); return canvas && canvas.width > 0 && canvas.height > 0; }, { timeout: 15000 }); // 注入打印样式:调整布局、控制分页 await page.addStyleTag({ content: ` @media print { body { background: #fff; } .metric-card, .chart-wrapper, #detail-table { break-inside: avoid; page-break-inside: avoid; } .flex-row { display: block !important; } .metric-card { width: 100% !important; margin-bottom: 12px; } #detail-table tr { break-inside: avoid; } } ` }); // 切换到 print 媒体类型,确保打印样式生效 await page.emulateMediaType('print'); // 导出 PDF await page.pdf({ path: outputPath, format: 'A4', printBackground: true, displayHeaderFooter: true, headerTemplate: `<div style="font-size:10px; color:#999; width:100%; text-align:center;">模拟项目 X 数据报表</div>`, footerTemplate: ` <div style="font-size:10px; color:#999; width:100%; text-align:center;"> <span class="pageNumber"></span> / <span class="totalPages"></span> </div> `, margin: { top: '25mm', bottom: '25mm', left: '15mm', right: '15mm' } }); console.log(`PDF 已生成:${outputPath}`); return outputPath; } finally { await browser.close(); } } // 用法 generateReportPDF('123', path.join(__dirname, 'output', 'report.pdf')) .then(() => process.exit(0)) .catch((err) => { console.error('导出失败:', err); process.exit(1); });

这段代码有一个细节值得展开:emulateMediaType('print')。在没调用之前,页面使用的还是屏幕媒体类型的样式,@media print里的规则不生效。调用之后,Chrome 会按打印媒体类型去渲染页面。如果发现打印样式没有按预期生效,优先检查这一行有没有写。

还有setViewport。PDF 的排版与视口宽度有关联,视口设置得太窄,响应式布局可能变成移动端样式,导出效果就不是桌面报表的样子。我一般设为 1280 或 1366,具体看目标页面适配的是哪个档位。

4.3 样式检查和视觉验证

导出的 PDF 因为不可交互,样式问题比网页更隐蔽。我整理了一套快速验证清单:

  • 字体是否正常加载,是否出现缺字、乱码、回退字体。
  • 背景色是否保留,特别是表头、高亮标签。
  • 图表是否渲染完整,缩放是否失真。
  • 分页是否切割了表格行或图表,页边距是否整齐。
  • 页眉页脚是否显示,页码是否从正确数字开始。
  • 超宽的列有没有被截断,内容是否溢出到纸张外。

这套清单听起来简单,但每次我都会逐项过一遍。尤其字体问题,服务端可能和本地开发环境完全不一样,我在下一节展开细讲。

5. 常见问题与排查技巧实录

5.1 中文乱码或字体缺失

这个问题是我在 Linux 服务器上踩得最狠的坑。本地 Windows 或 macOS 自带中文字体,Chromium 渲染中文没问题。一旦部署到精简的 CentOS 或者容器环境,系统里可能一个中文字体都没有,导出 PDF 全是方块。

解决方式是给服务器安装中文字体。以常见的 Debian/Ubuntu 系为例:

apt-get install -y fonts-noto-cjk

如果系统里没有这个包,也可以从别处拷贝一份.ttf字体文件到/usr/share/fonts/目录,然后fc-cache -fv刷新字体缓存。装完之后,可以用fc-list :lang=zh确认中文字体是否被系统识别。

如果不想动服务器环境,还有一个旁路思路:在导出前给页面注入一个自定义字体,通过page.addStyleTag把字体文件以 base64 形式嵌入。缺点是字体文件往往几 MB,注入耗时且 PDF 文件会变大。我的建议是:能用系统字体就用系统字体,实在不行再用内嵌方案。

5.2 页面元素没加载出来

表现形形色色:表格有外壳没数据、图表空白、图片裂开。定位思路其实一致:先确认页面在浏览器里是不是真的渲染完成了,再谈 PDF 问题。

  • 如果是接口数据没返回,把waitUntil从默认的load换成networkidle0,或者干脆直接waitForSelector等待某个关键 DOM 出现。
  • 如果是 Canvas 图表,用waitForFunction轮询画布尺寸,直到非零。
  • 如果是懒加载图片,可以模拟滚动页面,让图片进入视口触发加载。Puppeteer 里可以用page.evaluate滚动,滚动完成后再导出。
  • 如果加载逻辑依赖定时器或者动画,可以额外waitForTimeout(注意新版里用new Promise(r => setTimeout(r, ms))),但不要在生产代码里到处加死等,能等元素就等元素。

我见过一种很恶心的现象:页面在本地网络能快速渲染完,服务器上因为出口带宽受限,接口响应特别慢,networkidle0等不到。遇到这种情况,可以把等待策略改成“等待核心元素出现 + 短暂兜底延时”,比单一策略稳得多。

5.3 页码不连续 / 页边距异常

页码从第 2 页开始、最后一页没有页码、页码显示在错误位置,这些大多和页边距设置有关。

displayHeaderFooter: true时,页眉页脚是画在页面的 margin 区域里的。如果margin设置得太小——比如top: '10mm'——页眉文字就可能和正文贴在一起,甚至被裁掉。我的经验是:有页眉时顶部留 25mm,有页脚时底部留 25mm,只留页脚的话底部 20mm 也行。

还有一个容易忽略的问题:preferCSSPageSize一旦开启,页面里如果写了@page { size: A5 },纸张会变成 A5,页边距计算也和 A4 不一样。排查时先关掉这个参数,看看是不是 CSS 里的@page规则导致的。

5.4 内存占用过高与批量任务

每launch()一次浏览器,都会启动一个完整的 Chromium 进程,内存大概在 200MB 上下。如果导出任务是一个一个独立触发的,频繁启动销毁会导致机器内存飙升,接口响应也慢。

更合理的做法是复用浏览器实例。启动一次浏览器,每个导出任务只新建一个page,完成任务后关闭这个 page,保留浏览器进程继续服务下一个任务:

class PDFService { constructor() { this.browserPromise = puppeteer.launch({ headless: 'new', args: ['--no-sandbox', '--disable-setuid-sandbox'] }); } async export(html, outputPath) { const browser = await this.browserPromise; const page = await browser.newPage(); try { await page.setContent(html, { waitUntil: 'networkidle0' }); await page.pdf({ path: outputPath, format: 'A4', printBackground: true }); } finally { await page.close(); } } }

用page.setContent还有一个好处:如果导出内容是自己拼的 HTML,不用起一个临时 HTTP 服务,直接传给浏览器渲染。很多内部工具类导出都是用这种方式做的,简单直接。

批量导出时注意并发控制。我建议同时最多跑几个页面实例,可以用异步队列限制并发。Chrome 对太多并发标签页会吃不消,内存就是最大的瓶颈。实测中我一般把并发数控制在 3 到 5 个,导出任务超过这个量就排队。

5.5 服务器缺少系统库导致浏览器启动失败

这个坑通常在部署阶段出现。puppeteer.launch()一执行就报Error: Failed to launch the browser process,并且日志里带一堆.so文件找不到的提示。

解决办法是在服务器上安装 Chromium 的依赖库。Debian/Ubuntu 系的安装命令 Pppeteer 官方文档里有一整套,核心就是下面这几个包:

apt-get install -y gconf-service libasound2 libatk1.0-0 libc6 libcairo2 \ libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 \ libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 \ libnss3 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 \ libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 \ libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 fonts-noto-cjk

另外,如果在 root 用户下运行,还需要加--no-sandbox参数,否则 Chromium 会拒绝启动。很多云服务器直接用 root 跑服务,这个参数几乎是必加的。

实操心得:这套方案还能怎么扩展

最后分享一点我的个人体会。网页转 PDF 这个功能,表面上是调一个page.pdf(),实际上是一个“以浏览器为渲染引擎的打印服务”。一旦理解了这一点,扩展方向就打开了。

比如你可以用富文本编辑器拼一份 HTML 合同模板,套上公司 Logo 和页脚,然后通过这套流程导出成带页码的正式合同 PDF。你可以在用户点了“生成报告”之后,后台异步跑浏览器渲染,生成完再通过邮件或站内信推送给用户。你还可以把多个页面片段合并到一个 HTML 里,一次导出生成汇总文档。核心思路都是一样的:把页面交给 Chromium,等它渲染完,再按下虚拟打印按钮。

我在实际使用中最深的一个感悟是:不要迷信“等网络空闲”这个参数,也不要迷信单一的 CSS 方案。每接入一个新的业务页面,都要花时间确认它的渲染链路,搞清楚数据从哪里来、图表什么时候画完、布局在打印媒体类型下表现如何。把这几个点摸透了,后续的导出服务会异常稳定,几乎不用再改。

如果这篇文章帮你少踩了几个坑,那我的记录就有价值了。遇到更特殊的需求,比如分页特别复杂的合同模板、需要动态生成目录的长文档,后续我还有不少细节可以展开聊。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询