我个人这几年在前端自动化和数据报告生成上折腾了不少方案,从最开始用PhantomJS,到后来切换到Puppeteer,再到如今PC端、移动端、PDF文档生成一把梭,这套工具链基本已经成了我日常工作的标配。今天就把我调研和实践Puppeteer用来生成页面截图和PDF的完整经验拿出来分享。无论是你想做定时任务抓取页面快照、给运营系统生成数据报告,还是给C端用户输出可打印的电子凭证,这篇内容都能提供一套可落地的思路和代码参考。
1. 工具选型与核心原理拆解
1.1 为什么是Puppeteer
市面上做浏览器自动化或页面截图的方案不少,我把它们分成几个流派:无头浏览器方案、现有浏览器驱动方案、以及纯HTTP请求模拟方案。纯HTTP请求模拟没法渲染JavaScript,遇到SPA页面基本残废,直接排除。现有浏览器驱动方案(比如Selenium)虽然生态成熟,但部署重、性能损耗大、API设计也更偏向测试语义,用来做批量截图和PDF生成总觉得杀鸡用牛刀。
纯HTTP方案解决不了的,恰恰是Puppeteer最擅长的:通过Chrome DevTools Protocol直接操控Chromium实例,把页面完整渲染出来,再走Chromium的合成器输出位图或调用底层打印引擎生成PDF。
这里有个核心概念得先理清楚——无头浏览器不等于没有浏览器。Puppeteer只是去掉了一个可见的浏览器窗口,但Chromium本身的渲染引擎、JS执行引擎、网络栈、布局引擎一样不少。这意味着你在普通浏览器里能看到的页面,Puppeteer也能完整渲染出来,而且因为少了一整层UI绘制开销,性能反而更好。
1.2 核心原理:从页面到像素再到PDF
Puppeteer生成截图和PDF,底层其实是两条相对独立的链路:
截图链路:Chromium通过CDP(Chrome DevTools Protocol)暴露一个截图接口,Puppeteer调用Page.captureScreenshot命令,Chromium的合成器会对当前视口进行一次离屏渲染,再编码成PNG或JPEG返回给Node层。如果把captureBeyondViewport和clip参数配合起来,还可以获取超过当前视口大小的高分辨率截图,这就是fullPage截图的原理。
PDF链路:这份PDF并不是从截图转出来的,而是Chromium内置的打印引擎(printToPDF命令)直接把页面排版成文档。这个过程会重新计算页面分页、页边距、页眉页脚,甚至保留页面里的链接和表单。这里注意一个关键点:打印引擎碰到display:none的元素不会打印,但碰到底层生成图片的元素会正常输出,所以同一个页面可以同时适配屏幕展示和纸质打印。
把这两条链路放在一张架构图里看,数据流是:URL/HTML内容 -> 浏览器实例 -> 页面渲染 -> 调用截图或PDF接口 -> 输出文件。这个流里任何一环出问题,都会导致输出质量下降,后面我在实践环节会逐一说到。
1.3 与其他工具对比概览
有些新人会问,直接用Playwright不行吗?我的看法是:Playwright和Puppeteer的底层一样是CDP,只是Playwright支持的浏览器更多、API设计更现代、内置了自动等待机制。但Puppeteer的生态更老、案例更多,遇到奇怪问题时更容易找到答案。下面这张对比表是我实际使用后的总结:
| 能力维度 | Puppeteer | Playwright | Selenium | PhantomJS(已停更) |
|---|---|---|---|---|
| 底层协议 | CDP | CDP | WebDriver | PhantomJS专属 |
| 支持浏览器 | Chromium系 | Chromium/Firefox/WebKit | 几乎全部 | 仅PhantomJS |
| 截图能力 | 强 | 强 | 较弱(截不全) | 弱 |
| PDF生成 | 原生支持 | 原生支持 | 不支持 | 弱 |
| 自动等待 | 需手动配置 | 内置 | 需WebDriverWait | 无 |
| 安装部署 | 中 | 中 | 较重 | 轻但已废弃 |
如果你只关心Chromium系页面的截图和PDF,Puppeteer是最省心的选择。项目起始时间早、用户量大、文档案例密集,遇到什么问题几乎都能搜到答案。
2. 环境准备与基础配置详解
2.1 安装Puppeteer时最常见的坑
Puppeteer的安装命令很简单:npm install puppeteer。但这个命令会在npm install阶段自动下载对应版本的Chromium,默认下载地址是国外CDN。在国内网络环境下经常卡住或失败,我遇到过的典型报错是download timed out或者ERROR: Failed to download Chromium。
有个官方建议是更换下载镜像,在项目根目录下配置.npmrc文件,写入如下内容:
# .npmrc puppeteer_download_host=https://npm.taobao.org/mirrors如果不想用淘宝源,也可以直接指定Chromium的镜像URL:
# 命令行形式 npm config set puppeteer_download_host=https://storage.googleapis.com # 默认 npm config set puppeteer_download_host=https://npm.taobao.org/mirrors # 国内加速但这里更加推荐的做法是:先单独下载好Chromium,然后通过环境变量指向本地浏览器路径,这样团队内部分发、CI构建时都很灵活:
# 下载好Chromium后,运行时指定可执行文件位置 PUPPETEER_EXECUTABLE_PATH=/path/to/chromium node your-script.js还有一个小坑是Linux服务器缺少运行库。如果直接运行报错error while loading shared libraries: libX11-xcb.so.1这类问题,说明缺了系统依赖。在CentOS系统上,一般需要安装这些包:
sudo yum install -y pango.x86_64 libXcomposite.x86_64 libXcursor.x86_64 libXdamage.x86_64 libXext.x86_64 libXi.x86_64 libXtst.x86_64 cups-libs.x86_64 libXScrnSaver.x86_64 libXrandr.x86_64 GConf2.x86_64 alsa-lib.x86_64 atk.x86_64 gtk3.x86_64 ipa-gothic-fonts xorg-x11-fonts-100dpi xorg-x11-fonts-75dpi xorg-x11-utils xorg-x11-fonts-cyrillic xorg-x11-fonts-Type1 xorg-x11-fonts-misc这个坑踩过以后,我直接写了一个一键检测脚本放在项目里,服务器新部署时先跑一遍,省得每次插拔。
2.2 浏览器实例的基础配置参数
启动浏览器实例时,很多人的习惯是直接puppeteer.launch()裸跑。但在生产环境里,我建议至少传入以下几组参数:
const browser = await puppeteer.launch({ headless: 'new', // 新无头模式,资源占用更低 executablePath: process.env.PUPPETEER_EXECUTABLE_PATH, // 指定Chromium路径 args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--no-first-run', '--no-default-browser-check', '--single-process', '--disable-extensions' ], defaultViewport: { width: 1440, height: 900 } });--no-sandbox在root用户下运行是必须的,否则Chromium会拒绝启动。--disable-dev-shm-usage用于避免小内存机器上/dev/shm区域溢出,这个在Docker容器里尤其常见。--disable-gpu不是必须的,但在服务器上如果没有GPU,加上它能避免一些莫名其妙的GL初始化错误。
关于headless这一项,老版本写的是headless: true,新版本Chromium提供了headless: 'new'这种独立的新无头模式,实测下来稳定性更好、内存释放更及时,建议直接使用新写法。
2.3 页面级别的装载选项
日常开发中,我们总会纠结是用page.goto还是page.setContent。两者的区别在于:goto是访问URL并等待整个加载过程;setContent是直接把HTML字符串塞进页面,不用发起网络请求。若你的输入是URL,用goto;若你的输入是模板字符串或数据库里的静态HTML,setContent更高效。
当时有一个典型业务场景:批量生成合同PDF,HTML模板放在文件服务器上,内容是数据库查出来的订单数据渲染后生成的。如果先生成文件再goto,每个合同就要额外发起一次内网HTTP请求,还得处理文件落盘和清理。后来改用setContent,把渲染好的HTML字符串直接set进页面再调PDF接口,速度至少提升了40%。
3. 核心实操:拍出高质量页面截图
3.1 截图基础姿势
先给一个最基础的截图实现。这里我加了不少"保险"操作,比如关闭干扰动画、稳定等待网络空闲,都来自真实业务踩坑:
async function captureScreenshot(url, outputPath, options = {}) { const browser = await puppeteer.launch({ headless: 'new' }); try { const page = await browser.newPage(); await page.setViewport({ width: options.width || 1440, height: options.height || 900 }); await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 }); // 等待页面内自定义事件,确保异步数据渲染完成 await page.evaluate(() => new Promise((resolve) => { if (document.readyState === 'complete') { resolve(); } else { window.addEventListener('load', resolve); } })); // 关闭会干扰render的过渡动画 await page.addStyleTag({ content: '* { transition: none !important; animation: none !important; }' }); await page.screenshot({ path: outputPath, type: 'png', fullPage: options.fullPage || false, clip: options.clip }); } finally { await browser.close(); } }上面这段代码里,最容易被忽略的是等待策略。很多人只写waitUntil: 'networkidle0',但实际页面里的接口可能是按需触发、延迟加载的,光等网络空闲不够。我通常会叠加一个自定义的"关键数据已渲染"的检查逻辑,比如页面里某个元素出现后再继续。等待时机不对,截出来的图往往是白屏或半成品,这是新手最容易踩的坑。
3.2 视觉差问题的根因
截图结果和本地浏览器打开的效果不一致,这个问题的根源一般有三个:屏幕尺寸、设备像素比和字体差异。
屏幕尺寸影响的是布局。同一个页面在1440宽的视口下正常,在375宽的视口下可能换行变形。若你是给移动端做长图分享,viewport就得设置成移动端的值,例如375x812,再配合fullPage截全面长图。
设备像素比DPR影响的是清晰度。默认DPR是1,截出来的图在Retina屏幕上发虚。正确做法是给viewport加deviceScaleFactor字段:
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 2 });deviceScaleFactor为2时,截图宽度就变成了2880px,能适配大多数高清屏幕展示场景。
字体差异影响的是文字渲染效果。本地Windows有微软雅黑,服务器Linux可能没有,Chromium会fallback到一个普通字体,截出来的图线上明显变丑。解决办法是在Docker镜像或服务器里安装需要用到的字体包,或者在你启动脚本时去检查系统中是否存在目标字体。
3.3 长页面截图的边界问题
fullPage模式看似一行代码的事,但背后有个限制:它只能截取"流式布局下连续滚动"的页面。像那些内部有横向滚动容器、或高度使用100vh固定而非文档撑开的内容,fullPage是截不全的。
实际业务中另一个高频问题:某些图表库的Canvas动画在滚动过程中才渲染,直接开fullPage可能出现下半屏图表空白。我的应对方案是:先滚动到底部再滚回顶部,强制所有懒加载和视口监听器执行一遍。这里用一个函数,通过CDP的强制滚动来触发:
async function forceScrollToBottomAndBack(page) { await page.evaluate(async () => { const distance = 300; let totalHeight = 0; while (totalHeight < document.body.scrollHeight) { window.scrollBy(0, distance); totalHeight += distance; await new Promise((resolve) => setTimeout(resolve, 50)); } window.scrollTo(0, 0); }); // 再等待一轮渲染 await new Promise((resolve) => setTimeout(resolve, 300)); }注意,这种方式在页面有无限滚动时可能会卡死,所以生产代码里要加一个最大滚动次数限制,例如200次就强制退出。
3.4 多页面批量截图的并发调度
批量截图第一个想法是开多个浏览器实例并行截图,但实测下来,开越多浏览器实例内存烧得越快,尤其是渲染综合后台报表这类重页面,三五个实例就能把8G内存吃光。更稳的方案是一个浏览器实例内开多个Page,每个Page只负责一个页面的截图任务,Page之间相互隔离,互不干扰:
const browser = await puppeteer.launch({ headless: 'new' }); const tasks = urls.map(async (url) => { const page = await browser.newPage(); try { await page.goto(url, { waitUntil: 'networkidle2' }); const filename = generateFilename(url); await page.screenshot({ path: filename }); } finally { await page.close(); } }); await Promise.all(tasks); await browser.close();这里要注意控制并发数量,我一般控制在3~5个Page,多了之后CPU调度都成了瓶颈,单个任务反而变慢。
如果机器资源非常紧张,还可以使用CDP层面的Target.createTarget去复用同一个渲染进程,但该方案入门门槛较高,普通场景不需要。
4. 核心实操:生成可打印的PDF文档
4.1 PDF基础生成与配置
PDF这块的API和截图有一个核心区别:截图是面对屏幕,PDF则面对纸张。纸张有页边距、尺寸、分页的概念,所以Puppeteer对PDF专门提供了page.pdf()方法。下面是我在实际项目中打磨过的一段配置:
const pdfConfig = { path: outputPath, format: 'A4', margin: { top: '20mm', right: '20mm', bottom: '20mm', left: '20mm' }, printBackground: true, preferCSSPageSize: false, displayHeaderFooter: true, headerTemplate: '<div style="font-size:10px;width:100%;text-align:center;color:#999;">财报生成系统</div>', footerTemplate: '<div style="font-size:10px;width:100%;text-align:center;color:#999;"><span class="pageNumber"></span></div>', scale: 0.8 }; await page.pdf(pdfConfig);这里有几个值得细聊的配置:
printBackground:默认是false,意味着所有背景色、背景渐变、背景图都会被丢掉。生成图表报告或凭证类PDF时,不开启背景输出会变成一片白底黑字,观感大打折扣。所以我强烈建议生产环境都打开。
preferCSSPageSize:这个参数控制是否优先使用CSS里设定的@page尺寸。如果页面上有自定义@page { size: A5 landscape; },而你希望保持A4纵向,就设置preferCSSPageSize: false。若希望页面自定义尺寸生效,设成true即可。
scale:缩放比例会影响整体排版密度。有些后台系统页面内容偏宽,硬塞进A4后,右侧总有一部分被裁掉。这时候调scale到0.75或0.8,能有效收窄内容宽度。当初做报告时发现图表太大,顶部三行挤占了大半页,加了scale后整体才协调。这个参数是等比例缩放,对内容高度也有效。
displayHeaderFooter:开启后可以在页面上方和下方加上统一的头脚信息。官方自带的模板支持页数占位符,例如<span class="pageNumber"></span>和第几页共几页的<span class="totalPages"></span>。
但注意,头脚模板里的样式只接受内联style,外部样式或class定义在模板里不生效。这是我踩过的一个比较隐蔽的坑。
4.2 页面分页控制技巧
PDF的页眉页脚好多人都知道,但"怎么让一块内容恰好独占一页"这个问题很少被系统讲过。用CSS控制分页时,重点在下面几个属性:
.avoid-break { break-inside: avoid; } .page-break-after { break-after: page; } section.chapter { break-before: page; page-break-before: always; /* 旧浏览器兼容 */ }break-inside: avoid用于防止表格、卡片、图表区域被截断成上下两截。break-after: page确保某个区块结束后新起一页。书本类PDF中,每个章节都有独立首页,用break-before: page最好使。
这里有个经验细节:Chrome的无头打印引擎对break-inside: avoid的支持要优于之前内核版本,如果你用的是旧版Chromium,这个CSS属性可能在特定元素上不生效,比较好的兜底方案是为这类元素单独设置固定高度,例如height: 120mm,不要让它能自然跨页。
4.3 Web字体引发的中文PDF乱码与错位
中文PDF问题排在第一位的是字体缺失。服务器环境往往只装了基本系统字体,甚至只装了文泉驿或Noto CJK,页面里如果在CSS中声明了"微软雅黑""苹方-简",头一次加载时又无法访问到这些本地字体,Chromium就会fallback到默认字体,导致:
- 文字渲染成方块(豆腐块)
- 字符间距离过大或过小
- 数字与汉字基线不对齐
解决中文PDF最彻底的办法是在页面HTML模板里直接嵌入字体文件,用Base64嵌入或把字体文件放在可达的URL上。我第一次做这一版方案时,是直接把一个8兆的中文字体文件放到静态资源服务器,在模板的CSS里写:
@font-face { font-family: 'ReportFont'; src: url('https://example.com/fonts/NotoSansSC-Regular.otf'); } body { font-family: 'ReportFont', sans-serif; }这样即使在服务器上,也能确保PDF里看到的字体与设计稿一致。缺点是字体文件大,每页渲染都要多下载一次(同一浏览器实例会缓存),总体收益远大于损失。
对于连网环境都不允许的任务,就需要把字体文件转为Base64直接写进CSS。这样做模板会很大,但能保证离线也正常工作。我的一个经验值:一个中文OTF文件Base64后大概6~10MB,可接受范围。若嫌大,可以用子集化工具裁剪只包含用到的字符。
4.4 表格不跨页的实用技巧
公司内部报表PDF十有八九离不开大表格。表格跨页的表现基本分为三种情况:
- 表头不重复。需要设置
thead { display: table-header-group; },这样打印时每一页都会在表格顶部重复打印表头行。 - 行被从中间截断。给
tr设置break-inside: avoid,但要注意对特别高的行,这个规则可能被打印引擎忽略。 - 表格太宽超出纸张。给
table设置table-layout: fixed,同时为关键列设置百分比宽度,防止内容过宽把列撑飞。
除了CSS,也可以在表格外层包一层div然后设置成display: block; overflow-x: auto;,但如果页面宽度本来就超出A4,这种处理会导致内容横截断。最稳妥还是从根上控制整个页面内容的排版宽度,PDF场景下我通常将主体内容区宽度限制在650~680px之间(A4去掉边距后的大致可用宽度)。
5. 性能优化与任务调度经验
5.1 减少页面加载时间的几板斧
截图和PDF生成的性能瓶颈,多数情况不在截图本身,而在页面加载阶段。网络请求越多、资源越重,等待时间越长。优化可以从三个方向入手:
阻塞无用的资源请求:很多后台系统页面里嵌入了埋点统计、客服弹窗、第三方SDK,它们既不影响主体渲染,又拖慢加载速度。我们可以用page.setRequestInterception屏蔽它们:
await page.setRequestInterception(true); page.on('request', (req) => { const resourceType = req.resourceType(); const url = req.url(); if (['image', 'media', 'font'].includes(resourceType) && !url.includes('required-domain.com')) { req.abort(); } else if (url.includes('/api/track') || url.includes('/analytics')) { req.abort(); } else { req.continue(); } });这里要小心,别把所有图片都abort了,否则截图里图表全空。正确做法是只屏蔽明显不需要的域名或非关键资源。
启用缓存复用:同一个浏览器实例内多次goto同样的静态资源,如果能用上HTTP缓存,重复加载会快很多。Puppeteer本身对同一实例内的请求会遵循Chromium的缓存逻辑,所以批量任务应尽量复用一个浏览器实例。
减少等待时间上限:waitUntil: 'networkidle2'已经比networkidle0宽松很多,但如果你不希望死等那些永远保持长连接的接口,可以自定义阈值。用Race方式在DOMContentLoaded之后等500ms就开跑,也比傻等网络空闲快:
await Promise.race([ page.goto(url, { waitUntil: 'domcontentloaded' }), new Promise((resolve) => setTimeout(resolve, 5000)) ]);实测下来,这个方法在业务系统中能把单页加载时间从平均7秒压到3秒左右。但要注意,这要求页面里的异步数据基本都能在5秒内加载完,否则截出来还是空的。
5.2 任务队列与失败重试
生产环境里跑批量任务,最怕的是某一个页面加载卡死或进程内存溢出,把整个批次任务带崩。我的队列策略如下:
- 所有待处理URL放入数组,按批次分批处理(每批5个)。
- 每个任务有独立超时控制,超时直接记为失败并关闭该Page。
- 失败任务插入队列尾部,重试最多2次。
- 连续失败超过3个任务,触发"降级模式":每个任务前后各增加2秒间隔,并清空浏览器实例重建。
一段简化版的失败重试逻辑:
async function runWithRetry(url, fn, retries = 2) { let attempt = 0; while (attempt <= retries) { try { return await fn(url); } catch (error) { attempt += 1; if (attempt > retries) throw error; await delay(1000 * attempt); } } }这里的delay函数就是简单的new Promise((resolve) => setTimeout(resolve, ms)),重试间隔递增是为了给目标站点喘息时间,避免因瞬时负载过高反复失败。
5.3 减少内存泄漏的实操细节
长时间跑批量任务时,内存占用会肉眼可见地往上涨。除了复用浏览器实例,还有几个细节:
- Page用完必须close。这里不是指browser.close,而是page.close。不关闭Page会导致它继续持有渲染进程和网络连接,累计几十个Page后内存直接爆掉。
- 关闭不需要的子frame。如果页面内部嵌入了多个iframe,每个都会占用独立渲染进程,导致浏览器整体内存剧增。可以考虑通过page.on('framenavigated')检测无关iframe并停止其加载。
- 不要长时间复用同一个Page对象。同一个Page对象反复goto不同URL,页面上下文状态(localStorage、cookie、JS堆)会越积越多,建议一个任务一个新Page。
内存监控方面,可以给脚本添加一个简单的手工观察点:
const mem = process.memoryUsage(); console.log(`Heap used: ${(mem.heapUsed / 1024 / 1024).toFixed(2)}MB`);配合系统级的free -m或容器监控,就能比较直观地发现泄漏节点。
5.4 Docker容器化部署的注意点
Puppeteer在Docker里部署已经是成熟路线。推荐直接用官方镜像作为基础层,或者在自己的Dockerfile中安装全套依赖,再设置无沙箱参数运行。一个精简的Dockerfile示例:
FROM node:20-slim RUN apt-get update && apt-get install -y \ chromium \ fonts-noto-cjk \ fonts-ipafont-gothic \ fonts-wqy-zenhei \ --no-install-recommends \ && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY package*.json ./ RUN npm install COPY . . ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium CMD ["node", "server.js"]重点在fonts-noto-cjk和fonts-wqy-zenhei这两个中文字体包,少了它们,中文PDF和截图的效果会非常拉胯。
另外,容器里不要使用--single-process参数,因为它会抑制多进程模型,反而增加崩溃概率。最主要是设置--no-sandbox和--disable-dev-shm-usage,这两个参数在Docker环境下属于必需品。
6. 动态数据渲染与登录态处理
6.1 等待异步数据完成的几种范式
许多后台页面要经过登录、权限校验、接口请求、图表渲染等多个阶段。截图工具经常截到半成品,就是没处理好这个异步过程。我总结了三种公认可行的等待范式:
范式一:等待指定元素出现
await page.waitForSelector('#report-container .chart-wrapper', { visible: true, timeout: 30000 });范式二:等待指定接口返回
用page.waitForResponse捕获一个特定的XHR请求,比如统计图表里的/api/summary接口:
const response = await page.waitForResponse( (res) => res.url().includes('/api/summary') && res.status() === 200, { timeout: 15000 } );范式三:等待页面局部文本变化
如果页面里有加载提示"加载中",等它变成特定文本时继续:
await page.waitForFunction( (selector) => document.querySelector(selector)?.textContent.trim() === '数据处理完成', { timeout: 30000 }, '#statusText' );这三种方式不是互斥的。比如报表页面可以先等接口返回,再等一个关键容器出现,最后强制等待500ms让图表动画落地,这样能最大概率拿到稳定画面。
6.2 登录态持久化的常用解法
自动化任务访问后台系统,基本都会遭遇登录拦截。如果每次都从登录流程开始跑,既慢又不稳定。推荐的做法是把登录态持久化到本地,用Puppeteer的cookie机制即可:
const fs = require('fs'); async function saveCookies(page, filePath) { const cookies = await page.cookies(); fs.writeFileSync(filePath, JSON.stringify(cookies)); } async function loadCookies(browser, filePath) { const cookies = JSON.parse(fs.readFileSync(filePath)); const page = await browser.newPage(); await page.setCookie(...cookies); return page; }注意,cookie的Domain必须能覆盖你要访问的站点。有时登录成功后cookie跨域或缺少Domain字段,setCookie会报错,所以在保存前可以对cookies做一次过滤和格式整理。如果系统使用localStorage做登录标识别,需要改用page.evaluate在目标origin下写入localStorage,复杂度更高一些。
另一个思路是用page.authenticate处理Basic Auth:
await page.authenticate({ username: 'admin', password: 'password123' });如果系统走的是SSO或验证码,建议绕开自动登录,直接让运维开一台"白名单IP直接放行"的专用测试账号,生产环境里最稳的就是固定账号+固定IP白名单。
6.3 针对SPA路由等待的细节
SPA(单页应用)路由切换不像传统页面那样触发整页load事件,Puppeteer里使用waitUntil: 'networkidle2'在大部分情况下有效,但也可能因为某个计数组件一直在轮询接口而永远不触发结束条件。
我的处理方式是:在SPA环境中,改用waitForSelector等待当前路由对应的根组件渲染完成,再加一个固定延迟作为兜底。也可以利用页面暴露的全局状态,比如等待window.__INITIAL_STATE__被填充:
await page.waitForFunction(() => !!window.__INITIAL_STATE__ && !window.__PENDING_REQUESTS__);这里的__PENDING_REQUESTS__是开发者在页面里主动暴露的一个标记,属于前端配合的手段,如果没有,就退回selector方案。
7. 常见问题与排查技巧实录
7.1 截图白屏
遇到白屏,我一般按下面顺序排查:
- URL是否可达。在Puppeteer里打开页面时,如果页面返回404但页面框架正常,截图可能是错误页而非白屏;如果网络被网关拦截,可能直接超时。
- 是否登录失效。登录过期后系统可能跳转到空白登录页或直接render null,表现为白屏。解决:先手动在浏览器里验证登录态,再注入cookie。
- 是否JS报错。在Puppeteer脚本中监听页面console和pageerror事件:
page.on('console', (msg) => console.log('PAGE LOG:', msg.type(), msg.text())); page.on('pageerror', (err) => console.log('PAGE ERROR:', err.message));页面的任何未捕获异常都会在这里打印出来,这是定位白屏最快的途径。
- 渲染进程崩溃。
page.on('error')能捕获一些Chromium内部的致命错误,这类错误通常需要重建Page。
7.2 截图模糊或尺寸不对
截图模糊的原因绝大多数是DPR设置问题。如果截出来的图片尺寸比预期的宽了一倍,说明deviceScaleFactor设置得偏大。反之则说明在CSS像素和设备像素之间没有做对齐。
尺寸不对更常见于fullPage模式。如果文档高度被某一个子组件的min-height撑得很大,截图会拉出超大长图。排查方法是先通过page.evaluate(() => document.body.scrollHeight)打印页面的实际高度,看是不是有异常值。
7.3 PDF页数不对或出现空白页
空白页的经典元凶有两个:
第一个是隐藏元素占位。有些前端框架的modal层在隐藏时依然占据文档流,或者某个div被设置为height: 100vh,打印时会把页面撑高。解决办法是在PDF生成前,对特定容器设置display: none。
第二个是CSS分页规则冲突。比如margin-top和break-after同时作用在一个元素上,可能导致打印引擎额外插入空白页。排查时可以逐类删除页面中可能影响布局的CSS库,做二分定位,直到找出问题代码。
页数不对还和字体加载变动有关。如果字体加载完成后重新布局导致行高变化,原本一页的内容可能被挤到两页,所以要在PDF之前强制等待字体加载完成:
await page.evaluate(async () => { if (document.fonts && document.fonts.ready) { await document.fonts.ready; } });7.4 CPU占用过高或内存溢出
这种问题多发生在批量任务且一个浏览器实例内Page开太多的情况。除了前面提到的并发控制,还可以启用--single-process吗?容器里不太建议。
更有效的方案是定期重建浏览器实例:比如每处理200个任务,关闭当前浏览器,重新启动一个新的。这个操作能彻底回收掉长时间运行产生的内存碎片:
async function restartBrowser() { await browser.close(); browser = await puppeteer.launch({ headless: 'new', args: [...] }); }8. 典型业务场景落地参考
8.1 场景一:定时数据报表截图
最典型的场景是每天早上8点,系统自动登录后台报表页面,截取昨天运营数据的Dashboard长图,发送到企业微信群或邮件。这个场景的核心是抓取时机要准,我通常安排在数据仓库更新完成后的低峰期,并加一个"加载完成"标记的等待逻辑。
8.2 场景二:动态生成电子合同PDF
用户在前端填写合同信息后,后端根据模板生成一份PDF。这里的关键是模板渲染与分页控制。我通常在后端维护一份HTML模板,将变量用占位符替换后,通过setContent加载,再用同一套PDF配置输出。
8.3 场景三:运营活动分享海报
运营经常会用页面转图片做活动分享海报,这种海报通常包含用户昵称、活动二维码、倒计时。用Puppeteer的好处是海报样式可以和前端页面保持一致,不用设计师手工套版。这里注意二维码必须在截图前加载完成,建议页面里用专门的img标签,等图片naturalWidth大于0再截图。
9. 我的一些最终建议
做页面截图和PDF这套能力,最大的价值不是把功能实现出来,而是实现对质量、稳定性和效率的控制。我个人的核心建议有三条:
第一,在项目初期一定要梳理好"等待策略"的统一约定。把"页面数据已加载完成"的判断逻辑封装成通用函数,团队成员写任务时直接调用,而不是每个任务自己拍脑袋硬等几秒钟。
第二,日志和错误记录一定要详细。我在生产环境里给每一个截图/PDF任务都打印了任务ID、URL、输出路径、耗时、浏览器版本、错误堆栈等信息。否则当几十个任务中有一个失败时,没有任何线索可定位。
第三,不要盲目追求并行数量。并发数增加带来吞吐量提升的同时,也放大了内存和CPU压力。刚开始可以设并发3,测试稳定后再逐步往上加并观察资源监控。
如果你正准备在自己项目里引入Puppeteer,希望这篇实践总结能让你一开始就避开那些我踩过的坑。记住一句话:Puppeteer本身不难,难的是理解我们和浏览器之间的约定——何时加载完成、何时渲染稳定、何时可以落盘。把这三件事想透,剩下的就是按需调用接口了。