服务端5月29日 00:50
Cheerio 和 Puppeteer 有什么区别?爬虫场景怎么选?Cheerio 是纯 HTML 解析器,不执行 JavaScript,解析速度比 Puppeteer 快 100 倍以上,内存占用极低;Puppeteer 启动真实 Chromium,能执行 JS、处理动态渲染、模拟用户交互,但资源消耗大、速度慢。选择依据很简单:目标页面内容是否由 JS 动态生成——查看网页源码(Ctrl+U)能看到数据就用 Cheerio,看不到就用 Puppeteer。
## 追问
**Puppeteer 能不能用 Cheerio 解析页面?**
可以,这是常见的混合模式:Puppeteer 负责加载动态页面拿到渲染后的 HTML,再传给 Cheerio 做数据提取,兼顾动态能力与解析效率。
**怎么判断一个网站是否需要 Puppeteer?**
右键查看网页源码,搜索目标数据。如果源码中没有,说明是 JS 动态加载,需要 Puppeteer;源码中已有,直接 Cheerio 即可。也可以用 `curl` 请求看返回的原始 HTML。
**Cheerio 能否处理需要登录的页面?**
Cheerio 本身只做解析,登录请求通过 axios/fetch 带 cookie 发送即可,不需要 Puppeteer。只有登录过程涉及 JS 渲染或验证码交互时才需要浏览器。
**两者的资源消耗差异有多大?**
Cheerio 解析一个页面通常 <10ms、内存 <50MB;Puppeteer 启动浏览器就要 500ms+、内存 100-300MB。批量爬取时差异是数量级的,100 个页面 Cheerio 几秒搞定,Puppeteer 可能要几分钟。
## 写段代码
```javascript
// Puppeteer 拿动态页面 + Cheerio 解析
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle2' });
const html = await page.content();
await browser.close();
const $ = cheerio.load(html);
$('.item').each((i, el) => {
console.log($(el).text());
});
```标签
Puppeteer
Puppeteer 是一个 Node.js 库,它提供了一个高级 API 来通过 DevTools 协议控制无头 Chrome 或 Chromium。它还可以配置为使用完整(非无头)Chrome 或 Chromium。

前端5月28日 07:18
Puppeteer 性能优化有哪些核心策略?Puppeteer 在爬虫和自动化测试场景下,性能瓶颈主要来自 Chromium 的资源消耗——每次启动一个浏览器实例就要占 50-100MB 内存,每个 Page 再加 30-80MB,而页面加载时的网络 I/O 和 DOM 渲染又是时间上的最大开销。理解哪些环节最耗资源,才能对症下药。核心优化方向有三个:减少浏览器开销、降低页面加载成本、合理管理并发与内存。
## 浏览器启动与实例管理
每次 `puppeteer.launch()` 都会启动一个完整的 Chromium 进程,开销约 50-100MB 内存。批量任务中复用浏览器实例是最基本也最有效的优化:
```javascript
const browser = await puppeteer.launch({
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--disable-gpu',
'--window-size=1920,1080'
]
});
// 复用同一个 browser,每次任务只开新 page
for (const url of urls) {
const page = await browser.newPage();
await page.goto(url);
// ... 执行任务
await page.close();
}
await browser.close();
```
启动参数中几个关键项的作用:
- `headless: 'new'` — 使用 Chrome 的新版 Headless 模式,比旧版 headless 快约 20-30%,因为它不再走单独的渲染路径,而是和有头模式共享同一套代码
- `--disable-dev-shm-usage` — 在 Docker 等共享内存受限的环境中必不可少,否则 Chromium 会因 `/dev/shm` 空间不足而崩溃,改用 `/tmp` 目录
- `--no-sandbox` — 在容器内运行时需要关闭沙盒,因为容器通常没有足够的权限创建命名空间
- `--disable-gpu` — 无头模式下不需要 GPU 加速,关闭后可减少一个 GPU 进程的内存开销
对于长时间运行的任务,Chromium 存在内存泄漏倾向,运行上千次后内存占用可能翻倍。建议定期重启浏览器实例:
```javascript
let browser;
let taskCount = 0;
const RESTART_THRESHOLD = 500;
async function getBrowser() {
if (!browser || taskCount >= RESTART_THRESHOLD) {
if (browser) await browser.close();
browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-dev-shm-usage']
});
taskCount = 0;
}
taskCount++;
return browser;
}
```
重启阈值需要根据实际内存监控数据调整。一个实用的监控方式是在每次任务后检查进程内存:
```javascript
const used = process.memoryUsage();
if (used.rss > 1024 * 1024 * 1024) { // 超过 1GB
await browser.close();
browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox'] });
}
```
## 页面加载策略
页面加载是时间消耗最大的环节。默认的 `waitUntil: 'load'` 会等待所有资源(图片、CSS、字体、JS)加载完成,对爬虫来说往往不必要。
```javascript
// 爬虫场景:DOM 就绪即可开始提取数据
await page.goto(url, { waitUntil: 'domcontentloaded' });
// 需要 JS 渲染完成后提取动态内容
await page.goto(url, { waitUntil: 'networkidle2' });
// 需要确保所有异步请求都完成(如懒加载图片)
await page.goto(url, { waitUntil: 'networkidle0' });
```
四种策略的耗时对比:`domcontentloaded` 比 `load` 快 2-5 倍,比 `networkidle0` 快 5-10 倍,具体差距取决于页面资源量。选择策略时遵循一个原则:能用 `domcontentloaded` 就不用 `load`,能用 `networkidle2` 就不用 `networkidle0`。
还有一种更精细的做法:先用 `domcontentloaded` 完成初始加载,再手动 `waitForSelector` 等待关键元素出现:
```javascript
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.data-table', { timeout: 5000 });
// 比直接用 networkidle0 更精准,不会浪费时间等无关请求
```
拦截不必要的网络请求可以进一步降低加载时间和内存占用:
```javascript
await page.setRequestInterception(true);
page.on('request', (request) => {
const blocked = ['image', 'font', 'media', 'stylesheet'];
if (blocked.includes(request.resourceType())) {
request.abort();
} else {
request.continue();
}
});
```
这个优化在抓取纯文本数据的场景下效果显著——页面加载速度可提升 50% 以上,内存占用降低 30-40%。但如果需要截屏或分析页面视觉布局,图片和样式表不能拦截,需根据场景灵活调整拦截列表。
设置合理的超时时间同样重要,避免因个别慢页面拖垮整体效率:
```javascript
await page.goto(url, {
waitUntil: 'domcontentloaded',
timeout: 15000 // 15 秒超时,不给慢页面无限等待
});
```
## 并发控制与连接池
`Promise.all` 可以并行处理多个页面,但无限制的并发会导致内存飙升和 CPU 争抢,甚至触发系统 OOM Killer。实际生产中必须控制并发数:
```javascript
async function processWithConcurrency(urls, concurrency = 3) {
const browser = await puppeteer.launch({ headless: 'new' });
const results = [];
for (let i = 0; i < urls.length; i += concurrency) {
const batch = urls.slice(i, i + concurrency);
const batchResults = await Promise.all(
batch.map(async (url) => {
const page = await browser.newPage();
try {
await page.goto(url, { waitUntil: 'domcontentloaded' });
return await page.evaluate(() => document.body.innerText);
} finally {
await page.close();
}
})
);
results.push(...batchResults);
}
await browser.close();
return results;
}
```
并发数的选择取决于机器配置:每打开一个 Page 大约需要 30-80MB 内存。一台 4GB 内存的机器,并发 5-10 个 Page 就接近极限。8GB 内存可以开 10-20 个并发,但还要考虑 CPU 核心数——Chromium 每个渲染进程都会占一个核心,并发数超过核心数时进程切换开销会抵消并发收益。
更推荐的做法是使用 `puppeteer-cluster` 库,它内置了并发控制、自动重试和错误处理:
```javascript
const { Cluster } = require('puppeteer-cluster');
const cluster = await Cluster.launch({
concurrency: Cluster.CONCURRENCY_CONTEXT,
maxConcurrency: 5,
puppeteerOptions: { headless: 'new' }
});
await cluster.task(async ({ page, data: url }) => {
await page.goto(url, { waitUntil: 'domcontentloaded' });
const data = await page.evaluate(() => document.body.innerText);
return data;
});
urls.forEach(url => cluster.queue(url));
await cluster.idle();
await cluster.close();
```
`puppeteer-cluster` 的 `CONCURRENCY_CONTEXT` 模式使用 BrowserContext 而非新 Page 来隔离任务。Context 的创建和销毁比 Page 更轻量,且不会共享 Cookie 和存储——这对爬虫场景很关键,避免不同任务的登录态互相干扰。如果需要更强的隔离(不同 User-Agent、不同代理),可以用 `CONCURRENCY_BROWSER` 模式,每个任务一个独立的浏览器实例,代价是内存开销更大。
对于更大规模的爬虫系统,可以实现一个浏览器连接池:
```javascript
class BrowserPool {
constructor(maxSize = 3) {
this.maxSize = maxSize;
this.browsers = [];
this.queue = [];
}
async init() {
for (let i = 0; i < this.maxSize; i++) {
this.browsers.push(await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-dev-shm-usage']
}));
}
}
async acquire() {
if (this.browsers.length > 0) {
return this.browsers.pop();
}
return new Promise(resolve => this.queue.push(resolve));
}
release(browser) {
if (this.queue.length > 0) {
this.queue.shift()(browser);
} else {
this.browsers.push(browser);
}
}
async closeAll() {
await Promise.all(this.browsers.map(b => b.close()));
}
}
```
## 内存泄漏防治
内存泄漏是 Puppeteer 长时间运行的最大敌人。常见的泄漏源有三类:未关闭的 Page、未关闭的 BrowserContext、事件监听器未移除。
**务必在 finally 块中关闭资源:**
```javascript
async function safeScrape(url) {
const page = await browser.newPage();
try {
await page.goto(url);
return await page.evaluate(() => document.title);
} finally {
await page.close(); // 无论成功还是异常,都关闭 page
}
}
```
**使用 BrowserContext 隔离任务:**
```javascript
const context = await browser.createIncognitoBrowserContext();
const page = await context.newPage();
try {
await page.goto(url);
// ... 执行任务
} finally {
await context.close(); // 关闭 context 会同时关闭所有属于它的 page
}
```
`context.close()` 比 `page.close()` 更彻底,它会清理该上下文下的所有页面、Cookie、LocalStorage 和缓存,防止跨任务数据污染。特别是当一个任务的 Cookie 会影响另一个任务的结果时(比如不同账号登录态),Context 隔离是必须的。
**通过 CDP 定期清理浏览器数据:**
```javascript
const client = await page.target().createCDPSession();
await client.send('Network.clearBrowserCache');
await client.send('Network.clearBrowserCookies');
```
相比 Puppeteer 的 `page.deleteCookie()` 和 `page.evaluate(() => localStorage.clear())`,CDP 方式更高效——一条命令就能清空所有缓存和 Cookie,而不需要逐个删除。
**移除不再需要的事件监听器:**
```javascript
const handler = (request) => { /* ... */ };
page.on('request', handler);
// 任务完成后移除
page.off('request', handler);
```
未移除的监听器会持有对 page 对象的引用,阻止垃圾回收,是隐蔽但常见的泄漏源。
## 选择器与执行效率
Puppeteer 的 Node.js 进程和 Chromium 进程是分离的,`page.$()`、`page.evaluate()` 之间的每次调用都需要跨进程通信(IPC),涉及数据的序列化和反序列化。减少 IPC 调用次数是提升执行速度的关键:
```javascript
// 低效:3 次 IPC 调用
const title = await page.$eval('.title', el => el.textContent);
const price = await page.$eval('.price', el => el.textContent);
const desc = await page.$eval('.desc', el => el.textContent);
// 高效:1 次 IPC 调用完成所有提取
const data = await page.evaluate(() => ({
title: document.querySelector('.title')?.textContent,
price: document.querySelector('.price')?.textContent,
desc: document.querySelector('.desc')?.textContent
}));
```
一次性提取所有数据比多次 `$eval` 快 3-5 倍,因为只产生一次 IPC 开销。这条规则在实际优化中经常被忽略,但对高频调用场景影响显著。
另一个常见的低效模式是反复查询同一个元素:
```javascript
// 低效:每次都重新查找 DOM
for (let i = 0; i < 10; i++) {
const text = await page.$eval('.item', (el, i) => el.children[i].textContent, i);
}
// 高效:一次提取所有子元素文本
const texts = await page.evaluate(() =>
Array.from(document.querySelectorAll('.item')).map(el => el.textContent)
);
```
选择器本身的效率也有差异:ID 选择器 > Class 选择器 > 标签选择器。但在爬虫场景下,选择器通常由目标页面的 DOM 结构决定,优化空间有限。真正值得投入精力的是减少 IPC 调用次数。
## CDP 进阶:性能监控与分析
CDP(Chrome DevTools Protocol)是 Puppeteer 的底层协议,通过 `createCDPSession()` 可以访问比 Puppeteer API 更底层的功能,获取更详细的性能数据:
```javascript
const client = await page.target().createCDPSession();
// 获取页面性能指标
await client.send('Performance.enable');
const { metrics } = await client.send('Performance.getMetrics');
// 关键指标:
// - JSHeapUsedSize:JS 堆已使用大小
// - Nodes:DOM 节点数量(过多说明可能有泄漏)
// - LayoutCount:布局重排次数(过多说明 DOM 操作低效)
```
`Performance.getMetrics` 返回的指标可以帮助判断瓶颈在哪:JSHeapUsedSize 持续增长说明有内存泄漏,Nodes 过多说明 DOM 操作需要优化,LayoutCount 高说明频繁触发了重排。
**性能追踪:**
```javascript
await page.tracing.start({ path: 'trace.json' });
await page.goto(url);
await page.tracing.stop();
// 用 chrome://tracing 打开 trace.json 进行可视化分析
```
生成的 `trace.json` 可以在 Chrome 的 `chrome://tracing` 页面加载,直观看到每个阶段的耗时分布——脚本执行、布局计算、绘制、网络请求各占多少时间。这在定位"页面加载慢到底是卡在哪里"时非常有效。
**网络监控:**
```javascript
await client.send('Network.enable');
client.on('Network.responseReceived', ({ response }) => {
if (response.status >= 400) {
console.log(`请求失败: ${response.url} - ${response.status}`);
}
});
```
通过 CDP 监听网络事件,可以记录所有请求的状态码和耗时,帮助发现哪些第三方请求拖慢了页面,或者哪些接口返回了错误。
## 反检测与稳定性
频繁请求同一站点会触发反爬机制,导致性能骤降(验证码、封 IP、返回空白页)。虽然这不算传统意义上的"性能优化",但反爬触发后带来的重试和超时会严重影响整体效率。几个基本措施:
**隐藏 WebDriver 特征:**
```javascript
await page.evaluateOnNewDocument(() => {
Object.defineProperty(navigator, 'webdriver', { get: () => false });
// 修复 permissions.query 在 headless 中的异常
const originalQuery = window.navigator.permissions.query;
window.navigator.permissions.query = (parameters) =>
parameters.name === 'notifications'
? Promise.resolve({ state: Notification.permission })
: originalQuery(parameters);
});
```
`evaluateOnNewDocument` 在页面脚本执行前注入,确保页面检测时 `navigator.webdriver` 已经是 `false`。
**随机化操作间隔:**
```javascript
const delay = Math.floor(Math.random() * 1000) + 500; // 500-1500ms 随机延迟
await new Promise(resolve => setTimeout(resolve, delay));
await page.click('.next-page');
```
匀速访问是最明显的机器特征。加入随机延迟后,请求模式更接近真实用户,降低被风控系统标记的概率。
**设置合理的 User-Agent:**
```javascript
await page.setUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
);
```
默认的 User-Agent 包含 "HeadlessChrome",是反爬系统最容易识别的特征之一。替换为真实浏览器的 UA 是最基本的反检测措施。
这些措施不能绕过所有检测(比如基于 TLS 指纹的检测),但能显著降低被初级反爬系统识别的概率,避免因触发反爬导致的重试和超时,间接提升整体效率。
## 核心优化优先级
按照投入产出比排序,从高到低:
1. **复用浏览器实例** — 改动最小,收益最大,避免每次任务都启动 Chromium 进程
2. **选择合适的 waitUntil** — 一行代码的改动,可能节省数秒等待时间
3. **拦截无用资源** — 爬虫场景下效果最显著,加载速度和内存双赢
4. **控制并发数** — 防止资源耗尽导致整体性能下降甚至系统崩溃
5. **finally 中关闭 Page/Context** — 防止内存泄漏,保证长时间运行稳定
6. **合并 evaluate 调用** — 减少 IPC 开销,高频场景下效果明显
7. **定期重启浏览器** — 兜底策略,解决 Chromium 自身的内存泄漏问题
面试中回答这个问题的关键不是罗列所有策略,而是说清楚**每个优化解决了什么瓶颈**,以及**不同场景下的取舍**——比如拦截资源在纯数据抓取中合适,但截屏场景下不行;`domcontentloaded` 快但可能拿不到 JS 渲染后的内容;并发数不是越多越好,要结合内存和 CPU 核心数综合考量。
前端5月28日 07:18
Puppeteer 如何实现页面截图与 PDF 生成?## 核心答案
Puppeteer 通过 `page.screenshot()` 和 `page.pdf()` 两个核心方法实现截图与 PDF 生成。截图支持全页、元素级别、裁剪区域等多种模式,可输出 PNG/JPEG 格式;PDF 生成基于 Chrome 的打印渲染引擎,支持自定义纸张、边距、页眉页脚等配置。两者均依赖 Headless Chrome 的渲染能力,PDF 生成仅支持无头模式。
## 截图 API 详解
### page.screenshot() 的关键参数
`screenshot` 方法接受一个可选配置对象,以下参数在实际开发中使用频率最高:
- **path**:文件保存路径,决定输出位置
- **type**:`png` 或 `jpeg`,PNG 支持透明通道,JPEG 体积更小
- **quality**:0-100,仅 JPEG 有效,推荐 80-90 之间平衡质量与体积
- **fullPage**:是否截取完整滚动区域,默认只截视口
- **clip**:`{x, y, width, height}` 裁剪指定区域
- **omitBackground**:设为 `true` 时背景透明,需配合 PNG 格式
- **captureBeyondViewport**:Puppeteer 9+ 新增,控制是否捕获视口外内容
```javascript
// 全页截图——最常用的场景
await page.screenshot({ path: 'full.png', fullPage: true });
// 裁剪区域截图
await page.screenshot({
path: 'clip.png',
clip: { x: 100, y: 100, width: 800, height: 600 }
});
// 透明背景截图(生成水印素材等场景)
await page.screenshot({
path: 'transparent.png',
type: 'png',
omitBackground: true
});
```
### 元素级截图
对特定 DOM 元素截图是自动化测试中的高频需求,直接调用元素实例的 `screenshot` 方法:
```javascript
const element = await page.$('.chart-container');
await element.screenshot({ path: 'chart.png' });
```
元素截图时注意:不支持 `fullPage` 参数,截图范围由元素自身尺寸决定。如果元素存在 `overflow: hidden`,被裁剪的部分不会出现在截图中。
### 视口控制与截图的关系
截图的默认范围是当前视口,视口尺寸通过 `setViewport` 设置:
```javascript
await page.setViewport({ width: 1920, height: 1080 });
await page.screenshot({ path: 'desktop.png' });
await page.setViewport({ width: 375, height: 667 });
await page.screenshot({ path: 'mobile.png' });
```
响应式测试中通常会循环切换多种视口尺寸,每种尺寸截一张图做对比。
## PDF 生成 API 详解
### page.pdf() 的关键参数
`pdf` 方法基于 Chrome 的 `Page.printToPDF` 协议实现,核心参数如下:
- **format**:纸张格式,A0 到 A6、Letter、Legal、Tabloid、Ledger
- **landscape**:`true` 横向打印
- **margin**:`{top, right, bottom, left}` 页边距
- **printBackground**:是否渲染背景色和背景图,默认 `false`
- **displayHeaderFooter**:是否显示页眉页脚
- **headerTemplate / footerTemplate**:HTML 模板字符串,支持 `<span class="pageNumber">` 和 `<span class="totalPages">` 特殊变量
- **pageRanges**:打印页码范围,如 `'1-5, 8'`
- **scale**:缩放比例,默认 1
- **preferCSSPageSize**:优先使用 CSS `@page` 定义的尺寸
```javascript
// 标准 A4 PDF
await page.pdf({ path: 'doc.pdf', format: 'A4' });
// 带页眉页脚的 PDF
await page.pdf({
path: 'with-footer.pdf',
format: 'A4',
displayHeaderFooter: true,
footerTemplate: '<div style="font-size:9px;text-align:center;width:100%;">第 <span class="pageNumber"></span> 页 / 共 <span class="totalPages"></span> 页</div>',
margin: { top: '1cm', right: '1cm', bottom: '1.5cm', left: '1cm' }
});
// 横向 + 自定义纸张
await page.pdf({
path: 'landscape.pdf',
width: '297mm',
height: '210mm',
landscape: true
});
```
### PDF 生成的限制与注意事项
**必须在无头模式下运行**——这是最容易被忽略的限制。有头模式下调用 `page.pdf()` 会直接抛异常。如果项目需要同时进行可视化调试和 PDF 生成,可以通过环境变量动态切换:
```javascript
const browser = await puppeteer.launch({
headless: process.env.GENERATE_PDF ? 'new' : false
});
```
**字体缺失问题**在 Linux 服务器上尤为常见。中文字符渲染成方块或空白,是因为系统缺少中文字体。解决方案是安装字体包(如 `fonts-noto-cjk`)或将字体文件打包进项目。
**背景色丢失**是因为 `printBackground` 默认为 `false`。CSS 中的 `background-color` 和 `background-image` 不会出现在 PDF 中,必须显式设置 `printBackground: true`。
## 截图与 PDF 的实战场景
### 场景一:网页归档与合规存证
金融、法务等行业需要对网页内容做定期归档,保存为 PDF 是最常见的做法:
```javascript
async function archivePage(url, outputPath) {
const browser = await puppeteer.launch({ headless: 'new' });
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle2' });
await page.pdf({
path: outputPath,
format: 'A4',
printBackground: true,
margin: { top: '1cm', right: '1cm', bottom: '1cm', left: '1cm' }
});
await browser.close();
}
```
`waitUntil: 'networkidle2'` 确保异步加载的内容全部渲染完毕。对于 SPA 页面,可能需要额外 `waitForSelector` 等待关键 DOM 挂载。
### 场景二:批量截图的响应式测试
同时输出多种设备尺寸的截图,用于视觉回归检测:
```javascript
async function responsiveScreenshots(url) {
const browser = await puppeteer.launch();
const page = await browser.newPage();
const viewports = [
{ name: 'mobile', width: 375, height: 667 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'desktop', width: 1920, height: 1080 }
];
for (const vp of viewports) {
await page.setViewport(vp);
await page.goto(url, { waitUntil: 'networkidle2' });
await page.screenshot({ path: `${vp.name}.png`, fullPage: true });
}
await browser.close();
}
```
### 场景三:发票/报告 PDF 生成
用 `page.setContent()` 注入 HTML 模板,再调用 `page.pdf()` 生成 PDF,是后端动态生成文档的经典方案:
```javascript
async function generateInvoice(data) {
const browser = await puppeteer.launch({ headless: 'new' });
const page = await browser.newPage();
await page.setContent(buildInvoiceHTML(data), { waitUntil: 'networkidle0' });
await page.pdf({
path: `invoice_${data.number}.pdf`,
format: 'A4',
printBackground: true,
margin: { top: '20px', right: '20px', bottom: '20px', left: '20px' }
});
await browser.close();
}
```
模板中的样式使用内联 CSS 或 `<style>` 标签,不要依赖外部样式表——`setContent` 不会自动加载外部资源。
## 性能优化策略
### 浏览器实例复用
每次截图或生成 PDF 都启动浏览器实例开销很大,推荐复用同一个 browser 对象:
```javascript
const browser = await puppeteer.launch();
for (const url of urls) {
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle2' });
await page.screenshot({ path: `${Date.now()}.png` });
await page.close();
}
await browser.close();
```
### 并行处理
`Promise.all` 配合多个 page 实例实现并行,但要注意控制并发数量,防止内存溢出:
```javascript
const CONCURRENCY = 3;
for (let i = 0; i < urls.length; i += CONCURRENCY) {
const batch = urls.slice(i, i + CONCURRENCY);
await Promise.all(batch.map(async (url) => {
const page = await browser.newPage();
await page.goto(url);
await page.screenshot({ path: `${Date.now()}.png` });
await page.close();
}));
}
```
### 拦截无关资源
截图和 PDF 生成通常不需要图片、字体、音视频资源,拦截这些请求能显著提速:
```javascript
await page.setRequestInterception(true);
page.on('request', (req) => {
const blocked = ['image', 'font', 'media', 'stylesheet'];
if (blocked.includes(req.resourceType())) {
req.abort();
} else {
req.continue();
}
});
```
注意:PDF 生成如果需要保留样式,不应拦截 `stylesheet` 资源。
## 常见问题与排查思路
**截图出现空白或加载不全**——检查是否使用了正确的 `waitUntil` 策略。`domcontentloaded` 只等 DOM 解析,不等待图片和异步内容。推荐 `networkidle2`,它在网络连接不超过 2 个时认为加载完成。对于懒加载页面,需要手动滚动到底部触发加载后再截图。
**PDF 分页位置不理想**——Chrome 的分页算法基于内容高度计算,无法精确控制。可以通过 CSS `break-before`、`break-after`、`break-inside: avoid` 属性影响分页行为。
**中文字体渲染异常**——Linux 服务器需要安装中文字体包。Docker 环境下建议在 Dockerfile 中添加 `RUN apt-get install -y fonts-noto-cjk`。
**内存持续增长**——确保每次操作后调用 `page.close()`,避免 page 实例泄漏。长时间运行的脚本建议定期重启 browser 实例。
**超时错误**——复杂页面可能需要更长的加载时间,通过 `timeout` 参数调整:
```javascript
await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
```
## 面试追问方向
- `page.pdf()` 为什么只支持无头模式?因为 PDF 生成调用的是 Chrome DevTools Protocol 的 `Page.printToPDF`,该协议只在 headless 模式下可用。有头模式下的打印走的是系统打印对话框,无法通过 CTP 直接输出文件。
- 如何实现懒加载页面的完整截图?需要先注入滚动脚本逐步触发懒加载,等所有内容挂载后再截图。
- Puppeteer 截图和 html2canvas 有什么区别?Puppeteer 在真实浏览器渲染后截图,结果与用户看到的一致;html2canvas 在 JS 层重新绘制 DOM,对 CSS 支持有限,跨域图片等场景容易出问题。前端5月28日 07:18
什么是 Puppeteer?核心原理和实战场景有哪些?## 什么是 Puppeteer?
Puppeteer 是 Google Chrome 团队开发的 Node.js 库,通过 Chrome DevTools 协议(CDP)提供高级 API 来控制无头或有头 Chrome/Chromium 浏览器。简单说,它让你用代码驱动浏览器完成截图、爬虫、自动化测试等操作,是前端工程师最常接触的浏览器自动化工具之一。
## 核心架构
Puppeteer 的 API 围绕几个核心对象组织,理解它们的层级关系是掌握 Puppeteer 的基础:
- **Browser**:浏览器实例,通过 `puppeteer.launch()` 创建,是所有操作的入口
- **BrowserContext**:隔离的浏览器会话,类似隐身模式,多个 Context 之间 Cookie、localStorage、缓存互不干扰,适合多账号并行场景
- **Page**:一个标签页,绝大部分操作(导航、点击、截图)都在 Page 上进行
- **Frame**:页面中的 iframe,每个 Page 有一个主 Frame,通过 `page.frames()` 访问子 Frame
```javascript
const browser = await puppeteer.launch({ headless: true });
const context = await browser.createIncognitoBrowserContext();
const page = await context.newPage();
await page.goto('https://example.com');
// 操作完成后
await browser.close();
```
面试要点:Browser 和 BrowserContext 的区别在于——一个 Browser 可以有多个 BrowserContext,它们之间完全隔离,这在爬虫需要多账号并行或测试需要干净环境时非常关键。
## 主要特性
**1. 无头浏览器控制**
默认以 headless 模式运行,不显示浏览器界面但功能完整。Puppeteer 从 v20 起默认使用新的 Headless 模式(`headless: 'new'`),性能更接近有头模式。设置 `headless: false` 可打开可视化窗口调试脚本执行过程。
**2. 页面交互与等待机制**
Puppeteer 的等待机制是面试高频考点,它决定了脚本的稳定性和效率:
- `page.waitForSelector(selector)` — 等待元素出现在 DOM 中
- `page.waitForFunction(fn)` — 等待自定义 JS 函数返回 truthy
- `page.waitForNavigation()` — 等待页面跳转完成
- `page.waitForResponse(urlOrPredicate)` — 等待特定网络响应
```javascript
await page.click('#submit-btn');
await page.waitForSelector('.result', { visible: true });
const text = await page.$eval('.result', el => el.textContent);
```
面试常问:为什么不推荐用 `setTimeout` 硬等待?因为网络延迟不可控,硬等待要么浪费时间要么不够等导致报错。Puppeteer 的 waitFor 系列基于轮询 + 事件监听,条件满足时立即继续执行,既可靠又高效。
另一个高频问题:点击后等待导航应该怎么写?
```javascript
// 错误写法:click 和 waitForNavigation 竞态
await page.click('#link');
await page.waitForNavigation();
// 正确写法:用 Promise.all 并行等待
await Promise.all([
page.waitForNavigation(),
page.click('#link')
]);
```
**3. 网络拦截与请求控制**
通过 `page.setRequestInterception(true)` 可以拦截、修改或 abort 请求,这是爬虫和测试场景的核心能力:
```javascript
await page.setRequestInterception(true);
page.on('request', request => {
if (request.resourceType() === 'image') {
request.abort(); // 屏蔽图片,加速爬虫
} else if (request.url().includes('/api/data')) {
request.continue({
headers: { ...request.headers(), 'X-Custom': 'value' }
}); // 修改请求头
} else {
request.continue();
}
});
```
实际应用:屏蔽无用资源提升页面加载速度(图片、字体、CSS)、mock 接口返回进行前端测试、修改请求头绕过反爬检测。
**4. 截图与 PDF 生成**
```javascript
// 整页截图
await page.screenshot({ path: 'full.png', fullPage: true });
// 指定元素截图
const element = await page.$('.chart');
await element.screenshot({ path: 'chart.png' });
// 生成 PDF
await page.pdf({ path: 'output.pdf', format: 'A4', printBackground: true });
```
注意:PDF 生成仅在无头模式下支持,有头模式调用会报错。
**5. 执行上下文与 page.evaluate**
`page.evaluate()` 在浏览器环境中执行 JS,可以访问 DOM 和 window 对象。这是一个容易踩坑的点:
```javascript
// 正确:通过参数传入
const title = await page.evaluate((sel) => {
return document.querySelector(sel)?.textContent;
}, 'h1');
// 错误:闭包变量无法访问
const sel = 'h1';
const title = await page.evaluate(() => {
return document.querySelector(sel)?.textContent; // sel 未定义!
});
```
原因:`page.evaluate` 的回调函数会被序列化后发送到浏览器环境执行,Node.js 侧的闭包变量不会跟随过去。需要传参的变量必须是可以被结构化克隆算法处理的类型(基本类型、普通对象、数组等),函数和 DOM 元素不行。
如果需要传递复杂对象,可以用 `page.exposeFunction(name, callback)` 把 Node.js 函数暴露到浏览器环境中。
## 主要应用场景
| 场景 | 说明 | 关键 API |
|------|------|----------|
| SPA 爬虫 | 抓取 Vue/React 等单页应用的动态渲染内容 | `page.goto` + `waitForSelector` |
| E2E 自动化测试 | 模拟用户操作流程,验证功能正确性 | `page.click` + `page.type` + 断言 |
| PDF/截图服务 | 将网页批量转成 PDF 或截图 | `page.pdf` + `page.screenshot` |
| 性能监控 | 录制性能轨迹分析加载瓶颈 | `page.tracing.start/stop` |
| Chrome 扩展测试 | 加载扩展并测试交互 | `launch({ args: ['--load-extension=...'] })` |
| 预渲染(SSR 替代) | 构建时生成静态 HTML,提升 SEO | `rendertron` / `puppeteer-renderer` |
## 与 Selenium、Playwright 的对比
| 维度 | Puppeteer | Selenium | Playwright |
|------|-----------|----------|------------|
| 底层协议 | Chrome DevTools Protocol | WebDriver 协议 | CDP + 自有协议 |
| 浏览器支持 | 仅 Chrome/Chromium | Chrome/Firefox/Safari/Edge | Chromium/Firefox/WebKit |
| 自动等待 | waitFor 系列需手动调用 | 需显式等待(WebDriverWait) | 内置 auto-waiting |
| 测试框架 | 无内置,常搭配 Jest/Mocha | 无内置 | 内置 test runner |
| 多标签/多上下文 | 支持 BrowserContext | 支持 Window handles | 原生支持,API 更完善 |
| 维护方 | Google | 社区(Selenium 4 由 W3C 标准驱动) | Microsoft |
| 学习曲线 | 低,API 直观 | 中,需要理解 WebDriver 概念 | 中低,API 设计更现代 |
面试高频追问:2026 年还要学 Puppeteer 吗?Playwright 由原 Puppeteer 团队打造,功能更全面,跨浏览器支持好,新项目优先推荐。但 Puppeteer 在 Chrome 专属场景(扩展测试、CDP 深度调试、Chrome 特性验证)仍有优势,且生态成熟、Stack Overflow 上的资料更多。理解 Puppeteer 的 CDP 原理后迁移到 Playwright 成本很低,两者核心概念一致。
## 反爬处理常见策略
实际用 Puppeteer 做爬虫时,网站的反爬检测是绕不开的问题:
- **设置 User-Agent**:`page.setUserAgent('Mozilla/5.0 ...')` 模拟真实浏览器
- **隐藏 WebDriver 特征**:`page.evaluateOnNewDocument(() => { Object.defineProperty(navigator, 'webdriver', { get: () => false }); })` 去除自动控制标识
- **使用 puppeteer-extra-plugin-stealth**:社区插件,自动注入十余项反检测脚本,最省事的方案
- **代理轮换**:`puppeteer.launch({ args: ['--proxy-server=...'] })` 配合代理池避免 IP 封禁
- **模拟人类行为**:用 `page.type(selector, text, { delay: 100 })` 模拟逐字输入,避免瞬间填写触发风控服务端5月28日 07:17
Puppeteer 如何实现网络请求拦截?有哪些实际应用场景?Puppeteer 通过 CDP(Chrome DevTools Protocol)提供的 Network 域能力实现请求拦截,核心 API 是 `page.setRequestInterception(true)`。启用后,每个请求都会被暂停,必须手动调用 `continue()`、`abort()` 或 `respond()` 才能放行。这一机制在爬虫加速、接口 Mock、安全测试等场景中非常实用。
## 启用请求拦截的基本方式
```javascript
const puppeteer = require("puppeteer");
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
// 启用请求拦截
await page.setRequestInterception(true);
page.on("request", (request) => {
// 每个请求必须被处理,否则页面会卡住
request.continue();
});
await page.goto("https://example.com");
await browser.close();
})();
```
关键点:`setRequestInterception(true)` 必须在页面导航前调用;每个被拦截的请求必须调用 `continue()`、`abort()` 或 `respond()` 之一,否则请求会一直挂起。
## 请求拦截的四种核心操作
### continue —— 放行请求
直接放行原始请求,也可以在放行的同时修改请求参数:
```javascript
page.on("request", (request) => {
// 修改请求头后放行
request.continue({
headers: {
...request.headers(),
Authorization: "Bearer token123",
},
});
});
```
`continue()` 支持覆盖 `url`、`method`、`postData`、`headers` 四个字段,可以实现请求重定向、修改 POST 数据等操作。
### abort —— 终止请求
直接阻止请求发出,常用于屏蔽广告、图片、字体等非必要资源:
```javascript
page.on("request", (request) => {
if (request.resourceType() === "image") {
request.abort();
} else {
request.continue();
}
});
```
`abort()` 可传入错误码,默认是 `failed`,常用值包括 `aborted`、`accessdenied`、`connectionrefused` 等。
### respond —— 直接返回响应
不向服务器发送请求,直接在本地构造响应返回。这是接口 Mock 的核心手段:
```javascript
page.on("request", (request) => {
if (request.url().includes("/api/user")) {
request.respond({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: 1, name: "test-user" }),
});
} else {
request.continue();
}
});
```
`respond()` 支持 `status`、`headers`、`contentType`、`body` 四个字段,可以完整模拟服务器行为。
### 响应监听 —— 获取服务端返回数据
通过 `response` 事件监听服务端实际返回的内容,常用于数据采集和接口监控:
```javascript
page.on("response", async (response) => {
if (response.url().includes("/api/data")) {
const data = await response.json();
console.log("接口返回:", data);
}
});
```
注意:`response.json()` 只能调用一次,且只有 JSON 格式的响应才能解析。
## 资源类型过滤
`request.resourceType()` 返回请求的资源类型,可用于批量过滤:
```javascript
const blockedTypes = ["image", "font", "stylesheet", "media"];
page.on("request", (request) => {
if (blockedTypes.includes(request.resourceType())) {
request.abort();
} else {
request.continue();
}
});
```
Puppeteer 支持的资源类型包括:`document`、`stylesheet`、`image`、`media`、`font`、`script`、`xhr`、`fetch`、`websocket`、`eventsource`、`manifest`、`texttrack`、`other`。
## 实际应用场景
### 爬虫加速:屏蔽非必要资源
爬取数据时,图片、字体、CSS 对数据提取无用,屏蔽后页面加载速度可提升 50% 以上:
```javascript
await page.setRequestInterception(true);
page.on("request", (request) => {
const useless = ["image", "font", "stylesheet", "media"];
if (useless.includes(request.resourceType())) {
request.abort();
} else {
request.continue();
}
});
```
### 接口 Mock:前后端联调
后端接口未就绪时,前端可以用 `respond()` 直接 Mock 数据,不依赖任何 Mock 服务:
```javascript
const mockData = {
"/api/users": { users: [{ id: 1, name: "Alice" }] },
"/api/posts": { posts: [{ id: 1, title: "Hello" }] },
};
page.on("request", (request) => {
for (const [path, data] of Object.entries(mockData)) {
if (request.url().includes(path)) {
request.respond({
status: 200,
contentType: "application/json",
body: JSON.stringify(data),
});
return;
}
}
request.continue();
});
```
### 广告与追踪屏蔽
屏蔽已知广告域名和追踪脚本,减少无关请求:
```javascript
const blockedDomains = ["ads.example.com", "analytics.example.com", "tracker.example.com"];
page.on("request", (request) => {
if (blockedDomains.some((d) => request.url().includes(d))) {
request.abort();
} else {
request.continue();
}
});
```
### 自动注入认证头
需要对所有请求添加 Token 时,用 `continue()` 覆盖 headers 即可,无需在每个请求中手动处理:
```javascript
page.on("request", (request) => {
request.continue({
headers: {
...request.headers(),
Authorization: "Bearer your-token-here",
},
});
});
```
### 网络请求监控与性能分析
记录所有请求和响应的时间戳与状态码,用于性能分析和接口排查:
```javascript
const logs = [];
page.on("request", (request) => {
logs.push({
type: "request",
url: request.url(),
method: request.method(),
resourceType: request.resourceType(),
time: Date.now(),
});
request.continue();
});
page.on("response", (response) => {
logs.push({
type: "response",
url: response.url(),
status: response.status(),
time: Date.now(),
});
});
await page.goto("https://example.com");
console.log("请求总数:", logs.filter((l) => l.type === "request").length);
console.log("响应总数:", logs.filter((l) => l.type === "response").length);
```
## 错误处理
```javascript
page.on("requestfailed", (request) => {
console.error("请求失败:", request.url());
console.error("原因:", request.failure()?.errorText);
});
```
常见失败原因包括:网络断开、DNS 解析失败、SSL 证书错误、被 `abort()` 主动终止等。
## 面试追问与注意事项
**Q:拦截对所有请求都会生效吗?**
不是。导航请求(主文档请求)在部分场景下可能无法被拦截,且 WebSocket 升级请求的处理方式与普通 HTTP 请求不同。
**Q:请求拦截对性能有什么影响?**
启用拦截后,每个请求都要经过 JavaScript 事件循环处理,会增加请求延迟。对于高频请求场景(如 WebSocket 消息),建议按条件拦截而非全量拦截。
**Q:如何避免重复处理请求?**
调用 `request.isInterceptResolutionHandled()` 检查请求是否已被处理,避免在多个监听器中对同一请求重复调用 `continue()` 或 `abort()`。
**Q:与 Playwright 的请求拦截有什么区别?**
Playwright 使用 `page.route()` API,支持路由模式匹配(如 `page.route("**/api/**", handler)`),语法更简洁。Puppeteer 则需要手动判断 URL。两者底层都基于 CDP,核心能力一致。服务端5月28日 07:16
Puppeteer 无头模式和有头模式有什么区别?Puppeteer 的无头模式(Headless)和有头模式(Headful)是两种浏览器运行方式,核心差异在于是否渲染图形界面,这直接决定了它们的性能表现、调试能力和适用场景。
## 核心区别
无头模式下浏览器不创建可视化窗口,所有页面渲染和脚本执行在内存中完成;有头模式则启动完整的 Chrome GUI 窗口,每一步操作都可以肉眼观察。
这个看似简单的差异会引发一系列连锁影响:
- **资源消耗**:无头模式省去了 GUI 渲染的开销,内存占用通常低 30%-50%,启动速度快 20% 左右
- **User Agent 差异**:旧版无头模式的 UA 包含 `HeadlessChrome` 标识,网站可据此识别并拒绝请求;有头模式的 UA 与普通 Chrome 完全一致
- **渲染一致性**:部分网站在无头模式下的渲染结果与有头模式不同,原因包括 GPU 加速差异、字体渲染路径不同、视口默认值不一致等
- **反爬检测**:无头模式缺少 `navigator.plugins`、`window.chrome` 等浏览器特征,更容易被反爬系统检测
## 三种无头模式的演进
Puppeteer 的无头模式并非一成不变,Chrome 的迭代带来了三种变体:
**旧版无头模式(headless: true)**
默认值,基于独立的 HeadlessChrome 实现,与正常 Chrome 共享极少代码。问题在于它的行为与真实浏览器差异较大,容易被网站检测。
```javascript
const browser = await puppeteer.launch({
headless: true
});
```
**新版无头模式(headless: "new")**
Chrome 112+ 引入,使用与有头模式完全相同的 Chrome 代码库,仅跳过可视化输出。渲染结果与有头模式几乎一致,推荐在新项目中优先使用。
```javascript
const browser = await puppeteer.launch({
headless: "new"
});
```
**chrome-headless-shell(headless: "shell")**
Puppeteer 21+ 提供,是专为自动化设计的精简二进制文件,体积更小、启动更快,但不支持扩展和部分 Chrome 特性,适合纯服务端批处理场景。
```javascript
const browser = await puppeteer.launch({
headless: "shell"
});
```
## 有头模式的使用方式
有头模式需要显式关闭 headless,同时可以配合 DevTools 和慢放模式辅助调试:
```javascript
const browser = await puppeteer.launch({
headless: false,
devtools: true, // 自动打开开发者工具
slowMo: 250 // 每步操作延迟 250ms,便于观察
});
```
关键配置项:`slowMo` 让操作可追踪,`devtools` 提供完整调试面板,`defaultViewport` 可设置视口大小。
## 性能对比
| 指标 | 旧版 headless | 新版 headless | headless shell | 有头模式 |
|------|-------------|-------------|--------------|--------|
| 内存占用 | 低 | 中 | 最低 | 高 |
| 启动速度 | 快 | 中 | 最快 | 慢 |
| 渲染一致性 | 差 | 好 | 中 | 基准 |
| 反检测能力 | 弱 | 较强 | 弱 | 强 |
| 扩展支持 | 不支持 | 支持 | 不支持 | 支持 |
## 各模式适用场景
**无头模式适用于:**
- CI/CD 流水线中的自动化测试——服务器通常没有显示器
- 大规模网页抓取——资源占用低,可并发更多实例
- 定时任务和批量处理——截图、PDF 生成、数据采集
- 性能基准测试——减少 GUI 对测试结果的干扰
**有头模式适用于:**
- 脚本开发调试阶段——实时观察页面行为,快速定位问题
- 复杂交互场景调试——如动画、拖拽、弹窗等需要视觉确认的操作
- 反爬对抗——部分网站检测到无头特征后拒绝服务,有头模式可以绕过
- 教学演示——展示自动化流程的每一步
## 无头模式被检测的常见原因及应对
实际项目中,无头模式最常见的坑就是被网站识别。以下是被检测的主要原因和解决思路:
**User Agent 泄露**:旧版 headless 的 UA 包含 `HeadlessChrome`,解决方法是手动覆盖:
```javascript
await page.setUserAgent(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
);
```
**浏览器特征缺失**:无头模式下 `navigator.plugins` 为空、`navigator.languages` 仅含 `"en-US"`、缺少 `window.chrome` 对象。可使用 `puppeteer-extra-plugin-stealth` 自动修补:
```javascript
const puppeteer = require("puppeteer-extra");
const StealthPlugin = require("puppeteer-extra-plugin-stealth");
puppeteer.use(StealthPlugin());
const browser = await puppeteer.launch({ headless: "new" });
```
**WebGL 和 Canvas 指纹**:无头模式下 GPU 加速不可用,Canvas 指纹与有头模式不同。新版 headless 模式已大幅改善此问题。
**最佳实践**:优先使用新版无头模式(`headless: "new"`)+ stealth 插件,绝大多数场景下可获得与有头模式一致的渲染和反检测效果。
## 环境切换的工程实践
在实际项目中,通常需要根据运行环境动态切换模式:
```javascript
const puppeteer = require("puppeteer");
const isDev = process.env.NODE_ENV === "development";
const browser = await puppeteer.launch({
headless: isDev ? false : "new",
devtools: isDev,
slowMo: isDev ? 100 : 0,
args: isDev ? [] : ["--no-sandbox", "--disable-setuid-sandbox"]
});
```
开发环境用有头模式便于调试,生产环境用新版无头模式兼顾性能和一致性。`--no-sandbox` 参数在 Docker 等容器环境中通常必需,因为默认的沙箱机制需要特定内核权限。
## 面试追问方向
- Puppeteer 新版无头模式与旧版的核心实现差异是什么?(共享 Chrome 代码库 vs 独立实现)
- 如何让无头模式通过反爬检测?(stealth 插件 + 新版 headless + UA 覆盖)
- chrome-headless-shell 适合什么场景?有什么限制?(纯服务端批处理,不支持扩展)
- 为什么同样的代码在无头和有头模式下渲染结果不同?(GPU 加速、字体渲染、视口默认值差异)前端5月28日 07:16
Puppeteer 如何实现设备模拟和移动端测试?## 核心概念
Puppeteer 的设备模拟通过 `page.emulate()` 方法实现,它一次性设置视口(viewport)、用户代理(User-Agent)、设备像素比、触摸支持等属性,让无头浏览器完整模拟目标设备的浏览器环境。
从 Puppeteer v21 开始,设备预设从 `puppeteer.devices` 迁移到了 `KnownDevices`,这是面试中容易踩的坑。
## 内置设备与 KnownDevices
### 使用内置设备预设
```javascript
const puppeteer = require('puppeteer');
const { KnownDevices } = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
// v21+ 使用 KnownDevices,旧版用 puppeteer.devices
const iPhone = KnownDevices['iPhone 12'];
await page.emulate(iPhone);
await page.goto('https://example.com');
await page.screenshot({ path: 'iphone12.png' });
await browser.close();
})();
```
### 常用内置设备
Puppeteer 内置了数十种设备预设,覆盖三大平台:
| 平台 | 常用设备 | 典型视口 |
|------|----------|----------|
| iPhone | iPhone 12/13/14/15, iPhone SE, iPhone X | 375×667 ~ 390×844 |
| iPad | iPad Pro, iPad Mini | 1024×1366, 768×1024 |
| Android | Pixel 5, Pixel 4, Galaxy S5 | 393×815, 360×640 |
查看所有可用设备:`Object.keys(KnownDevices)`
### 单个设备的配置结构
```javascript
{
name: 'iPhone 12',
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 ...)',
viewport: {
width: 390,
height: 844,
deviceScaleFactor: 3, // Retina 屏幕像素比
isMobile: true, // 启用移动端行为
hasTouch: true, // 启用触摸事件
isLandscape: false
}
}
```
`isMobile: true` 会影响媒体查询 `@media (pointer: coarse)` 和部分 CSS 行为,不仅是视口大小的变化。
## 自定义设备配置
当内置预设不满足需求时(比如测试未上市的新机型),可以手动构造设备描述符:
```javascript
const customDevice = {
name: 'Custom Foldable',
userAgent: 'Mozilla/5.0 (Linux; Android 13; Foldable) ...',
viewport: {
width: 320, // 折叠态宽度
height: 820,
deviceScaleFactor: 3,
isMobile: true,
hasTouch: true,
isLandscape: false
}
};
await page.emulate(customDevice);
```
也可以单独设置视口,而不使用完整的 `emulate()`:
```javascript
await page.setViewport({
width: 375,
height: 812,
deviceScaleFactor: 3,
isMobile: true,
hasTouch: true
});
```
单独设置用户代理:
```javascript
await page.setUserAgent(
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) ...'
);
```
`emulate()` 与分开设置的区别:`emulate()` 是原子操作,保证视口和 UA 同时生效;分开设置可能在两次调用之间页面触发重排,导致布局闪烁。
## 地理位置与权限模拟
移动端测试经常需要模拟位置信息:
```javascript
const browser = await puppeteer.launch();
const context = await browser.createIncognitoBrowserContext();
const page = await context.newPage();
// 授予地理定位权限(必须在导航前设置)
await context.overridePermissions('https://example.com', ['geolocation']);
// 设置坐标——东京涩谷
await page.setGeolocation({ latitude: 35.6580, longitude: 139.7016 });
await page.goto('https://example.com');
```
权限列表还包括 `'notifications'`、`'camera'`、`'microphone'` 等,对应移动端常见的权限弹窗场景。
## 网络与 CPU 节流
这是移动端测试的核心但常被忽略的环节。真实移动网络的延迟和带宽与桌面完全不同。
### 网络节流
```javascript
const client = await page.createCDPSession();
// 模拟 3G 网络
await client.send('Network.emulateNetworkConditions', {
offline: false,
latency: 200, // 往返延迟 200ms
downloadThroughput: 750 * 1024 / 8, // 750kbps
uploadThroughput: 250 * 1024 / 8, // 250kbps
});
// 模拟离线
await client.send('Network.emulateNetworkConditions', {
offline: true,
latency: 0,
downloadThroughput: -1,
uploadThroughput: -1,
});
```
### CPU 节流
```javascript
// CPU 减速 4 倍,模拟移动端性能
await client.send('Emulation.setCPUThrottlingRate', { rate: 4 });
```
结合网络节流和 CPU 节流,才能真实还原移动端用户的使用体验。仅模拟视口大小而忽略网络和性能条件,测试结果往往偏乐观。
## 时区与语言环境
国际化测试需要模拟不同地区的时区和语言:
```javascript
// 设置时区
await page.emulateTimezone('Asia/Tokyo');
// 设置语言偏好
await page.setExtraHTTPHeaders({
'Accept-Language': 'ja-JP,ja;q=0.9,en;q=0.8'
});
```
## 触摸事件
移动端的核心交互是触摸而非鼠标点击:
```javascript
await page.emulate(KnownDevices['iPhone 12']);
// 触摸点击元素
await page.tap('#submit-btn');
// 底层触摸 API:精确坐标点击
await page.touchscreen.tap(200, 400);
// 模拟滑动——从 (100, 500) 滑动到 (100, 200)
await page.mouse.move(100, 500);
await page.mouse.down();
await page.mouse.move(100, 200, { steps: 10 });
await page.mouse.up();
```
`page.tap()` 内部会触发 `touchstart → touchend → click` 事件序列,与真实触屏行为一致。如果用 `page.click()` 在移动模拟下测试,部分依赖 touch 事件的组件不会响应。
## 实战:响应式设计批量测试
将上述能力组合成一个实用的测试流程:
```javascript
const { KnownDevices } = require('puppeteer');
const puppeteer = require('puppeteer');
async function testResponsive(url) {
const browser = await puppeteer.launch();
const results = [];
const profiles = [
{ label: 'Desktop', viewport: { width: 1440, height: 900, isMobile: false, hasTouch: false } },
{ label: 'Tablet', device: KnownDevices['iPad Pro'] },
{ label: 'Mobile', device: KnownDevices['iPhone 12'] },
];
for (const p of profiles) {
const context = await browser.createIncognitoBrowserContext();
const page = await context.newPage();
if (p.device) {
await page.emulate(p.device);
} else {
await page.setViewport(p.viewport);
}
// 添加 3G 网络节流
const client = await page.createCDPSession();
await client.send('Network.emulateNetworkConditions', {
offline: false, latency: 150,
downloadThroughput: 750 * 1024 / 8,
uploadThroughput: 250 * 1024 / 8,
});
await page.goto(url, { waitUntil: 'networkidle2' });
// 检测布局溢出
const overflow = await page.evaluate(() => {
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
});
// 检测首屏加载时间
const timing = await page.evaluate(() => {
const nav = performance.getEntriesByType('navigation')[0];
return nav ? nav.loadEventEnd - nav.startTime : -1;
});
results.push({ profile: p.label, overflow, loadTime: timing });
await context.close();
}
await browser.close();
return results;
}
```
关键设计:使用 `IncognitoBrowserContext` 隔离每个设备的 Cookie 和缓存,避免状态污染;加入网络节流让性能数据更真实。
## 常见陷阱
**1. `puppeteer.devices` 已废弃**
Puppeteer v21+ 必须使用 `KnownDevices`,旧写法会直接报错。
**2. `emulate()` 后切换页面不生效**
`page.emulate()` 只对当前 page 生效。如果通过 `browser.newPage()` 创建新页面,需要重新调用 `emulate()`。建议使用 Browser Context 级别的设置来统一管理。
**3. 地理位置权限时机**
`overridePermissions` 必须在 `page.goto()` 之前调用,否则页面会先收到定位拒绝,再获得权限也不会自动重新请求。
**4. 触摸事件与 click 的差异**
在 `isMobile: true` 模式下,部分框架(如 React 的 onClick)会响应 click,但原生 `touchstart` 监听器不会触发。测试时优先用 `page.tap()` 而非 `page.click()`。
**5. 横屏模式**
内置设备默认是竖屏。测试横屏需要手动设置 `isLandscape: true` 或交换 width/height,`emulate()` 不会自动旋转。
## 追问:Puppeteer 与 Playwright 的设备模拟有何差异?
Playwright 内置了类似的设备模拟,但 API 更简洁:
```javascript
// Playwright 写法
const iphone = devices['iPhone 12'];
const browser = await chromium.launch();
const context = await browser.newContext({ ...iphone });
// 设备配置在 context 级别生效,所有 page 自动继承
```
关键区别:Playwright 在 BrowserContext 级别设置设备,所有页面自动继承;Puppeteer 在 Page 级别设置,需要每个页面单独配置。Playwright 还内置了网络节流 API(`context.route()`),无需 CDP Session。
前端5月28日 07:15
Puppeteer 如何管理 Cookie 与存储实现会话持久化?## 核心回答
Puppeteer 通过 `page.cookies()` / `page.setCookie()` 管理 Cookie,通过 `page.evaluate()` 操作 LocalStorage、SessionStorage 和 IndexedDB,结合 `userDataDir` 或手动序列化实现会话持久化,利用 `browser.createIncognitoBrowserContext()` 实现多账户隔离。
三种会话持久化方案的对比:
| 方案 | 适用场景 | 优点 | 缺点 |
|------|---------|------|------|
| userDataDir | 长期保持登录态 | 最简单,自动持久化所有数据 | 数据量大,不易清理 |
| 手动序列化 Cookie + Storage | 精确控制需要持久化的数据 | 灵活可控,文件小 | 需要手动处理每种存储 |
| Incognito Context + 手动保存 | 多账户并行 | 完全隔离,互不干扰 | 上下文关闭后数据丢失 |
## Cookie 管理
### 读取与设置 Cookie
Puppeteer 提供了简洁的 Cookie API:
```javascript
// 获取当前页面所有 Cookie
const cookies = await page.cookies();
// 获取指定 URL 的 Cookie(可跨域获取第三方 Cookie)
const cookies = await page.cookies('https://api.example.com');
// 设置单个 Cookie
await page.setCookie({
name: 'session_id',
value: 'abc123',
domain: '.example.com',
path: '/',
expires: Math.floor(Date.now() / 1000) + 3600,
httpOnly: true,
secure: true,
sameSite: 'Lax'
});
// 批量设置
await page.setCookie(
{ name: 'token', value: 'xxx', domain: '.example.com' },
{ name: 'lang', value: 'zh', domain: '.example.com' }
);
```
### 删除与清除 Cookie
```javascript
// 删除指定 Cookie(需匹配 name 和 domain)
await page.deleteCookie({ name: 'session_id', domain: '.example.com' });
// 清除所有 Cookie
const allCookies = await page.cookies();
await page.deleteCookie(...allCookies);
```
### Cookie 的 SameSite 策略
Chrome 80+ 默认将未声明 SameSite 的 Cookie 视为 `Lax`,这对跨域场景影响显著:
- **Strict**:仅同站请求携带,最安全但体验差(从外部链接跳入不带 Cookie)
- **Lax**:同站请求 + 顶级导航的 GET 请求携带(默认值)
- **None**:跨站也携带,但必须同时设置 `secure: true`
在 Puppeteer 中模拟跨站场景时,如果目标站点依赖第三方 Cookie,需要显式设置 `sameSite: 'None'` 并确保 `secure: true`,否则请求可能不带 Cookie 导致鉴权失败。
## 浏览器存储管理
### LocalStorage
LocalStorage 以键值对形式持久化数据,同源共享,无过期时间:
```javascript
// 读取全部
const lsData = await page.evaluate(() => {
return Object.fromEntries(
Array.from({ length: localStorage.length }, (_, i) => {
const key = localStorage.key(i);
return [key, localStorage.getItem(key)];
})
);
});
// 写入
await page.evaluate(() => {
localStorage.setItem('user_id', '12345');
localStorage.setItem('prefs', JSON.stringify({ theme: 'dark' }));
});
// 删除指定项 / 清空
await page.evaluate(() => localStorage.removeItem('user_id'));
await page.evaluate(() => localStorage.clear());
```
### SessionStorage
SessionStorage 与 LocalStorage API 相同,但数据仅在当前标签页生命周期内有效,关闭标签页即清除:
```javascript
await page.evaluate(() => {
sessionStorage.setItem('temp_key', 'temp_value');
});
const data = await page.evaluate(() => {
return Object.fromEntries(
Array.from({ length: sessionStorage.length }, (_, i) => {
const key = sessionStorage.key(i);
return [key, sessionStorage.getItem(key)];
})
);
});
```
### IndexedDB
IndexedDB 适合存储结构化数据,操作较为复杂,Puppeteer 中需要通过 `page.evaluate` 异步操作:
```javascript
const dbData = await page.evaluate(async () => {
return new Promise((resolve, reject) => {
const req = indexedDB.open('myDB', 1);
req.onsuccess = (e) => {
const db = e.target.result;
const tx = db.transaction(['store1'], 'readonly');
const store = tx.objectStore('store1');
const getAll = store.getAll();
getAll.onsuccess = () => resolve(getAll.result);
getAll.onerror = () => reject(getAll.error);
};
req.onerror = () => reject(req.error);
});
});
```
## 会话持久化
### 方案一:userDataDir(推荐用于长期持久化)
启动浏览器时指定 `userDataDir`,Chrome 会将 Cookie、LocalStorage、SessionStorage、IndexedDB 等所有用户数据保存到该目录,下次启动自动恢复:
```javascript
const browser = await puppeteer.launch({
userDataDir: './user_data/session1' // 指定持久化目录
});
const page = await browser.newPage();
await page.goto('https://example.com');
// 所有登录状态、Cookie、Storage 自动持久化到磁盘
```
这种方式最简单,但要注意:目录会随使用逐渐增大,长期运行需要定期清理。
### 方案二:手动序列化 Cookie + Storage
当只需要保存部分数据时,手动序列化更精确:
```javascript
async function saveSession(page, filePath) {
const fs = require('fs');
const cookies = await page.cookies();
const localStorage = await page.evaluate(() => {
return Object.fromEntries(
Array.from({ length: localStorage.length }, (_, i) => {
const key = localStorage.key(i);
return [key, localStorage.getItem(key)];
})
);
});
fs.writeFileSync(filePath, JSON.stringify({ cookies, localStorage }));
}
async function restoreSession(page, filePath) {
const fs = require('fs');
const { cookies, localStorage } = JSON.parse(fs.readFileSync(filePath, 'utf8'));
// 先设置 Cookie,再导航(确保域名匹配)
await page.setCookie(...cookies);
await page.evaluate((data) => {
for (const [k, v] of Object.entries(data)) {
localStorage.setItem(k, v);
}
}, localStorage);
}
```
恢复顺序很重要:先 `setCookie` 再导航到目标页面,这样页面加载时就能携带正确的 Cookie。
## 多账户管理
### 使用 Incognito Browser Context
每个 Incognito 上下文拥有独立的 Cookie 和 Storage,互不干扰:
```javascript
const browser = await puppeteer.launch();
// 账户 A
const ctxA = await browser.createIncognitoBrowserContext();
const pageA = await ctxA.newPage();
await pageA.goto('https://example.com/login');
// ... 登录账户 A
// 账户 B(完全隔离)
const ctxB = await browser.createIncognitoBrowserContext();
const pageB = await ctxB.newPage();
await pageB.goto('https://example.com/login');
// ... 登录账户 B
// 操作完成后关闭上下文
await ctxA.close();
await ctxB.close();
```
### 多账户持久化方案
如果需要在不同运行间恢复多个账户的会话,可以结合 `userDataDir` 和手动序列化:
```javascript
async function loginAndSave(account, sessionDir) {
const browser = await puppeteer.launch({
userDataDir: sessionDir // 每个账户独立目录
});
const page = await browser.newPage();
await page.goto('https://example.com/login');
await page.type('#username', account.username);
await page.type('#password', account.password);
await page.click('#login-button');
await page.waitForNavigation();
await browser.close(); // 数据自动保存到 sessionDir
}
```
## 安全注意事项
- **敏感数据保护**:不要在代码中硬编码密码,使用环境变量 `process.env.PASSWORD`;将包含会话信息的文件加入 `.gitignore`
- **Cookie 安全属性**:设置 `httpOnly: true` 防 XSS、`secure: true` 限 HTTPS 传输、`sameSite: 'Strict'` 防 CSRF
- **会话过期处理**:检查 Cookie 的 `expires` 字段,过期后重新登录,避免用失效会话发请求
- **第三方 Cookie 限制**:Chrome 逐步限制第三方 Cookie,跨域场景需使用 `sameSite: 'None'` 并配合 `secure: true`
## 追问
**Q: userDataDir 和手动序列化如何选择?**
简单场景(只需保持登录)用 userDataDir,复杂场景(需要跨环境迁移、选择性恢复)用手动序列化。手动序列化的优势在于文件小、可审计、可跨机器使用;userDataDir 的优势是零配置、自动覆盖所有存储类型。
**Q: 如何检测 Cookie 是否生效?**
设置 Cookie 后,通过 `page.cookies(url)` 验证返回的 Cookie 列表中是否包含目标项,或在导航后检查页面行为(如是否仍处于登录态)。注意 Cookie 的 domain 和 path 必须匹配目标 URL,否则不会被发送。
**Q: Incognito Context 和 CDP Session 有什么区别?**
Incognito Context 是浏览器层面的隔离,拥有独立的 Cookie 和 Storage;CDP Session 是 DevTools 协议层面的隔离,允许多个客户端独立与页面交互但不隔离存储数据。多账户场景应使用 Incognito Context。
前端5月28日 07:12
Puppeteer 如何实现页面交互和表单操作?Puppeteer 是 Google 维护的 Node.js 浏览器自动化库,通过 DevTools 协议控制 Chrome/Chromium,核心能力就是模拟用户在页面上的真实操作——导航、点击、输入、拖拽、截图等。前端面试中,Puppeteer 的页面交互与表单操作是高频考点,本文将系统梳理常用 API 和实际场景中的最佳实践。
## 页面导航与基础操作
Puppeteer 的一切操作都围绕 `page` 对象展开。最基础的交互就是导航:
```javascript
const browser = await puppeteer.launch();
const page = await browser.newPage();
// 基本导航
await page.goto('https://example.com');
// 等待网络空闲后再继续,适合需要等待异步资源的页面
await page.goto('https://example.com', { waitUntil: 'networkidle2' });
// 设置超时避免无限等待
await page.goto('https://example.com', { timeout: 30000 });
```
`waitUntil` 参数常用取值:`load`(默认,window.onload 触发)、`domcontentloaded`(DOM 解析完成)、`networkidle0`(500ms 内无网络请求)、`networkidle2`(500ms 内不超过2个网络请求)。实际项目中 `networkidle2` 最常用,因为它能容忍长连接(如 WebSocket)同时确保页面主体资源加载完成。
页面刷新、前进后退:
```javascript
await page.reload({ waitUntil: 'networkidle2' });
await page.goBack();
await page.goForward();
```
## 元素选择与定位
Puppeteer 提供三种选择器策略:CSS 选择器、XPath 和文本选择器。
**CSS 选择器**(最常用):
```javascript
// 选择单个元素
const el = await page.$('#submit-btn');
// 选择多个元素
const items = await page.$$('.list-item');
// 批量获取数据(比逐个 evaluate 更高效)
const texts = await page.$$eval('.item', els => els.map(e => e.textContent));
```
**XPath**(适合按文本内容定位):
```javascript
const [el] = await page.$x('//button[contains(text(), "提交")]');
```
**文本选择器**(Puppeteer 较新版本支持):
```javascript
await page.click('text/登录');
```
面试中常问:`page.$` 和 `page.evaluate(querySelector)` 的区别?前者返回 `ElementHandle` 对象(可继续调用 Puppeteer API),后者直接在浏览器上下文执行并返回序列化结果。理解这个区别是正确使用 Puppeteer 的关键。
## 点击与输入操作
点击和输入是最核心的交互 API,也是面试必考项。
**点击操作**:
```javascript
// 基本点击
await page.click('#button');
// 右键、双击
await page.click('#btn', { button: 'right' });
await page.click('#btn', { clickCount: 2 });
// 点击延迟,模拟真实用户
await page.click('#btn', { delay: 100 });
```
**文本输入**:
```javascript
// 逐字符输入,触发 keydown/keypress/keyup 事件
await page.type('#search', 'Puppeteer', { delay: 50 });
// 输入前先清空(v21.1+ 支持 clear 选项)
await page.type('#input', 'new text', { clear: true });
// 旧版本清空方式
await page.click('#input', { clickCount: 3 });
await page.keyboard.press('Backspace');
```
**面试追问:`page.type` 和 `page.evaluate` 直接设置 value 有什么区别?**
`page.type` 逐字符输入,会触发完整的键盘事件链(keydown → keypress → input → keyup),对依赖事件监听的框架(React、Vue)有效;直接设置 `input.value` 不触发事件,可能导致框架状态不同步。所以表单自动化场景应优先使用 `page.type`。
## 键盘与鼠标高级操作
当 CSS 选择器无法定位元素时,键盘和鼠标 API 是重要补充。
**键盘组合键**:
```javascript
// Ctrl+A 全选
await page.keyboard.down('Control');
await page.keyboard.press('A');
await page.keyboard.up('Control');
// 常用按键
await page.keyboard.press('Enter');
await page.keyboard.press('Escape');
await page.keyboard.press('Tab');
```
**鼠标拖拽与精确操作**:
```javascript
// 拖拽操作
await page.mouse.move(100, 100);
await page.mouse.down();
await page.mouse.move(300, 300, { steps: 10 }); // 平滑移动
await page.mouse.up();
// 滚轮
await page.mouse.wheel({ deltaY: 300 });
```
鼠标 API 的坐标是相对视口左上角的 CSS 像素,`steps` 参数控制中间插值点数,值越大移动越平滑,在需要模拟真实用户行为(避免被反爬检测)时很有用。
## 表单操作全场景
面试中表单操作是重点,需要掌握各种控件类型的处理方式。
**文本与文本域**:
```javascript
await page.type('#username', 'admin');
await page.type('#bio', '前端工程师');
```
**下拉选择框**:
```javascript
// 单选
await page.select('#country', 'CN');
// 多选
await page.select('#languages', ['zh', 'en']);
// 获取当前选中值
const value = await page.$eval('#country', el => el.value);
```
**复选框与单选框**:
```javascript
// 复选框——先检查再点击,避免取消选中
const checked = await page.$eval('#agree', el => el.checked);
if (!checked) await page.click('#agree');
// 单选框
await page.click('input[value="male"]');
```
**文件上传**:
```javascript
// 单文件
await page.setInputFiles('#avatar', '/path/to/photo.jpg');
// 多文件
await page.setInputFiles('#docs', ['/path/to/a.pdf', '/path/to/b.pdf']);
// 移除已选文件
await page.setInputFiles('#avatar', []);
```
**表单提交**:
```javascript
// 方式一:点击提交按钮(最常用)
await Promise.all([
page.waitForNavigation(),
page.click('#submit')
]);
// 方式二:通过 JavaScript 提交
await page.$eval('form', form => form.submit());
// 方式三:回车提交
await page.keyboard.press('Enter');
```
注意提交时用 `Promise.all` 包裹 `waitForNavigation` 和点击操作,否则导航可能在等待之前就完成了,导致后续操作失败。这是面试中经常考察的细节。
## 等待策略
等待策略直接决定自动化脚本的稳定性,也是面试高频考点。
```javascript
// 等待元素出现
await page.waitForSelector('.result', { visible: true });
// 等待元素消失(如 loading 遮罩)
await page.waitForSelector('.loading', { hidden: true });
// 等待 XPath
await page.waitForXPath('//div[contains(@class, "result")]');
// 等待自定义条件
await page.waitForFunction(() => {
return document.querySelectorAll('.item').length >= 10;
});
// 等待特定请求完成
await page.waitForResponse(resp => resp.url().includes('/api/data'));
```
**面试追问:为什么不能直接用 `setTimeout` 代替 `waitForSelector`?**
`setTimeout` 是固定等待,太短会失败、太长浪费时间;`waitForSelector` 是条件等待,元素出现立即继续,兼顾可靠性和效率。在实际项目中,硬编码等待时间是脚本不稳定的常见原因。
## iframe 与弹窗处理
这两个场景在实际项目中非常常见,但很多开发者容易忽略。
**iframe 内操作**:
```javascript
// 获取 iframe 的 frame 对象
const frame = await page.frames().find(f => f.name() === 'myiframe');
// 也可以通过选择器获取
const frameEl = await page.$('iframe');
const frame = await frameEl.contentFrame();
// 在 iframe 内操作,API 与 page 相同
await frame.type('#input', 'hello');
await frame.click('#btn');
```
**Dialog 弹窗**:
```javascript
// 监听并自动处理 alert/confirm/prompt
page.on('dialog', async dialog => {
console.log(dialog.type(), dialog.message());
await dialog.accept(); // 或 dialog.dismiss()
});
// prompt 输入值
page.on('dialog', async dialog => {
await dialog.accept('my input');
});
```
Puppeteer 默认会自动 dismiss dialog,如果不手动监听处理,所有 confirm/prompt 都会被取消,导致表单提交行为异常。
## 网络拦截与 Cookie 管理
这两个能力让 Puppeteer 不仅能操作页面,还能控制网络层和状态管理。
**请求拦截**(性能优化与 Mock 数据):
```javascript
await page.setRequestInterception(true);
page.on('request', request => {
// 屏蔽图片和字体,加速页面加载
if (['image', 'font', 'media'].includes(request.resourceType())) {
request.abort();
}
// Mock API 响应
else if (request.url().includes('/api/user')) {
request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ name: 'mock user' })
});
} else {
request.continue();
}
});
```
**Cookie 操作**:
```javascript
// 设置 Cookie(常用于免登录)
await page.setCookie({
name: 'token',
value: 'abc123',
domain: 'example.com'
});
// 获取所有 Cookie
const cookies = await page.cookies();
// 删除 Cookie
await page.deleteCookie({ name: 'token' });
```
Cookie 管理在爬虫和自动化测试中非常实用——通过预设登录态 Cookie 可以跳过登录流程,大幅简化脚本。
## 设备模拟与截图
**设备模拟**(移动端测试必备):
```javascript
const iPhone = puppeteer.devices['iPhone 13'];
await page.emulate(iPhone);
// 单独设置视口
await page.setViewport({ width: 375, height: 812, isMobile: true });
// 模拟地理位置
await page.setGeolocation({ latitude: 39.9, longitude: 116.4 });
```
**截图与 PDF**:
```javascript
// 页面截图
await page.screenshot({ path: 'home.png', fullPage: true });
// 指定元素截图
const el = await page.$('.chart');
await el.screenshot({ path: 'chart.png' });
// 生成 PDF
await page.pdf({ path: 'report.pdf', format: 'A4' });
```
## 实际应用场景
**场景一:登录流程自动化**:
```javascript
async function login(url, username, password) {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto(url);
await page.type('#username', username);
await page.type('#password', password);
// 提交并等待导航完成
await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle2' }),
page.click('#login-btn')
]);
// 验证登录成功
const success = await page.$('.user-avatar') !== null;
await browser.close();
return success;
}
```
**场景二:滚动加载与数据采集**:
```javascript
async function scrapeInfiniteScroll(url) {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle2' });
let prevHeight = 0;
while (true) {
const currHeight = await page.evaluate(() => document.body.scrollHeight);
if (currHeight === prevHeight) break;
prevHeight = currHeight;
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForTimeout(1500);
}
const items = await page.$$eval('.item', els =>
els.map(e => ({ title: e.querySelector('.title')?.textContent, url: e.querySelector('a')?.href }))
);
await browser.close();
return items;
}
```
**场景三:网络 Mock 与接口测试**:
```javascript
async function testWithMock(url) {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setRequestInterception(true);
page.on('request', req => {
if (req.url().includes('/api/config')) {
req.respond({ status: 200, contentType: 'application/json', body: '{"theme":"dark"}' });
} else {
req.continue();
}
});
await page.goto(url);
const theme = await page.$eval('.theme-label', el => el.textContent);
await browser.close();
return theme;
}
```
## 最佳实践总结
**1. 优先使用 Locator API**(Puppeteer v22+):自动等待、自动重试,比手动 waitForSelector + click 更可靠。
**2. 始终处理异步等待**:不要假设页面已经加载完成,显式等待目标元素或网络状态。
**3. 拦截无关资源**:测试和爬虫场景中屏蔽图片、字体、媒体,可显著提升速度。
**4. 资源释放**:`browser.close()` 放在 `finally` 块中确保执行,避免 Chromium 进程残留。
**5. 反检测意识**:使用 `puppeteer-extra` + `stealth` 插件规避反爬检测;模拟真实用户行为(随机延迟、自然鼠标轨迹);避免使用 WebDriver 等可被检测的标识。
**6. 错误重试机制**:网络波动和动态内容加载不可控,关键操作应有 try-catch 和重试逻辑。
**7. 与 Playwright 的选择**:新项目可考虑 Playwright,它由原 Puppeteer 团队打造,支持多浏览器、内置 auto-waiting、API 更现代。但 Puppeteer 生态更成熟、Chrome 支持最深,两者各有优势。
Puppeteer 的页面交互能力覆盖了从基础点击到网络拦截的完整链路。掌握核心 API(导航、选择器、输入、等待)、理解 `ElementHandle` 与浏览器上下文的区别、善用网络拦截和 Cookie 管理应对复杂场景,是面试和实际项目中的关键。面试回答时,先说核心 API 用法,再补充等待策略和最佳实践,最后提一下与 Playwright 的对比,基本就能覆盖大部分考察点。前端5月28日 07:11
Puppeteer 如何进行错误处理和调试?Puppeteer 在浏览器自动化场景下,错误类型多、调试链路长,从脚本层到浏览器层再到网络层都可能出问题。掌握系统的错误处理策略和调试手段,是写出可靠自动化脚本的关键。
## Puppeteer 常见的错误类型有哪些?
Puppeteer 脚本运行时主要会遇到三类错误:
**脚本层错误**——语法错误、逻辑错误,这类错误 Node.js 会直接抛出栈信息,属于常规调试范畴。
**协议层错误**——Puppeteer 通过 CDP(Chrome DevTools Protocol)与浏览器通信,协议调用失败时会抛出特定错误类:
```javascript
const { TimeoutError } = require('puppeteer').errors;
try {
await page.waitForSelector('.dynamic-content', { timeout: 5000 });
} catch (error) {
if (error instanceof TimeoutError) {
console.error('等待元素超时,可能页面未加载完成');
}
}
```
**浏览器层错误**——页面内的 JS 运行时错误、资源加载失败、网络请求异常,需要通过事件监听捕获:
```javascript
// 捕获页面未处理的 JS 错误
page.on('pageerror', error => {
console.error('[页面错误]', error.message);
});
// 捕获资源加载失败
page.on('requestfailed', request => {
console.error('[请求失败]', request.url(), request.failure().errorText);
});
```
## 怎样构建健壮的错误处理机制?
### try-catch 配合 finally 管理生命周期
每个 Puppeteer 脚本都应确保浏览器实例被正确关闭,`finally` 块是关键:
```javascript
async function runTask() {
const browser = await puppeteer.launch();
const page = await browser.newPage();
try {
await page.goto('https://example.com', { waitUntil: 'networkidle2' });
await page.click('#submit');
} catch (error) {
// 区分超时和其他错误
if (error.name === 'TimeoutError') {
console.error('操作超时:', error.message);
} else {
console.error('执行失败:', error.message);
}
// 出错时截图保存现场
await page.screenshot({ path: `error-${Date.now()}.png`, fullPage: true });
} finally {
await browser.close();
}
}
```
### 重试策略处理临时性故障
网络波动、页面加载慢等临时性问题,适合用重试机制解决:
```javascript
async function withRetry(fn, maxRetries = 3, delay = 2000) {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (error) {
console.warn(`第 ${i + 1} 次尝试失败: ${error.message}`);
if (i < maxRetries - 1) {
await new Promise(r => setTimeout(r, delay * (i + 1)));
} else {
throw error;
}
}
}
}
// 使用:自动重试页面导航
const page = await withRetry(() => browser.newPage().then(p => p.goto(url).then(() => p)));
```
### 全局错误事件监听
在 page 级别设置错误监听,防止未捕获的异常导致脚本静默崩溃:
```javascript
page.on('error', err => {
console.error('[Page crash]', err.message);
});
page.on('console', msg => {
if (msg.type() === 'error') {
console.error('[Console Error]', msg.text());
}
});
```
## 有哪些实用的调试手段?
### headless: false + slowMo 可视化调试
最直接的方式是关掉无头模式,肉眼观察浏览器行为:
```javascript
const browser = await puppeteer.launch({
headless: false,
slowMo: 100, // 每步操作放慢 100ms
devtools: true // 自动打开 DevTools
});
```
`slowMo` 的值根据脚本复杂度调整,一般 50-250ms 之间。值太大会导致超时,太小来不及观察。
### DEBUG 环境变量追踪协议通信
Puppeteer 内部基于 CDP 协议通信,通过 `DEBUG` 环境变量可以看到所有协议交互:
```bash
# 查看所有 Puppeteer 内部通信
DEBUG="puppeteer:*" node script.js
# 只看 CDP 协议请求
DEBUG="puppeteer:protocol" node script.js
# 只看 API 调用
DEBUG="puppeteer:api" node script.js
```
这在排查"为什么操作没生效"时非常有效,能看到 Puppeteer 到底发送了什么指令、浏览器返回了什么。
### 截图和 HTML 快照保留现场
在关键步骤截图,配合 HTML 快照,可以还原出错时的完整页面状态:
```javascript
async function debugCheckpoint(page, name) {
const ts = new Date().toISOString().replace(/[:.]/g, '-');
await page.screenshot({ path: `debug-${name}-${ts}.png`, fullPage: true });
const html = await page.content();
require('fs').writeFileSync(`debug-${name}-${ts}.html`, html);
console.log(`[调试快照] ${name} 已保存`);
}
// 在关键步骤之间插入
await debugCheckpoint(page, 'after-login');
await page.click('#next-step');
await debugCheckpoint(page, 'after-click');
```
### 网络请求拦截与监控
拦截和记录网络请求,能定位接口异常、资源加载失败等问题:
```javascript
// 监控所有请求的状态
page.on('response', response => {
if (response.status() >= 400) {
console.warn(`[HTTP ${response.status()}] ${response.url()}`);
}
});
// 拦截并修改请求(模拟接口异常场景)
await page.setRequestInterception(true);
page.on('request', request => {
if (request.url().includes('/api/user')) {
// 模拟接口 500 错误
request.abort();
} else {
request.continue();
}
});
```
## 如何用 CDP Session 做高级调试?
Puppeteer 提供的 API 覆盖了大部分场景,但有些高级调试功能需要直接使用 CDP Session:
```javascript
// 创建 CDP 会话
const client = await page.target().createCDPSession();
// 性能指标采集
await client.send('Performance.enable');
const { metrics } = await client.send('Performance.getMetrics');
console.log('性能指标:', metrics.filter(m => m.name === 'FirstMeaningfulPaint'));
// 追踪页面加载时间线
await page.tracing.start({ path: 'trace.json' });
await page.goto('https://example.com');
await page.tracing.stop();
// trace.json 可在 Chrome DevTools → Performance 面板中打开分析
// 模拟网络条件(测试弱网场景)
await client.send('Network.emulateNetworkConditions', {
offline: false,
latency: 200, // 额外延迟 200ms
downloadThroughput: 500 * 1024, // 下载 500KB/s
uploadThroughput: 250 * 1024, // 上传 250KB/s
});
```
## 面试高频追问:常见踩坑与解决方案
**导航超时怎么处理?** 默认超时 30 秒,可以用 `waitUntil` 参数降低等待条件,或针对性增加超时时间:
```javascript
// 方案1:降低等待条件
await page.goto(url, { waitUntil: 'domcontentloaded' });
// 方案2:单独设置超时
await page.goto(url, { timeout: 60000, waitUntil: 'networkidle2' });
// 方案3:手动等待关键元素
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.main-content');
```
**元素找不到或不可点击怎么办?** 大部分情况是元素还没渲染完成或被遮挡,按以下顺序排查:
```javascript
// 1. 确认元素存在
const element = await page.$(selector);
if (!element) throw new Error(`元素不存在: ${selector}`);
// 2. 确认元素可见
const visible = await element.isIntersectingViewport();
if (!visible) {
await element.scrollIntoView();
}
// 3. 等待元素可交互
await page.waitForSelector(selector, { visible: true });
await element.click();
```
**内存泄漏怎么排查?** Puppeteer 脚本中最常见的泄漏是浏览器实例未关闭和事件监听器未移除:
```javascript
// 始终用 finally 保证关闭
let browser;
try {
browser = await puppeteer.launch();
const page = await browser.newPage();
// ... 操作
} finally {
if (browser) await browser.close();
}
// 长时间运行的脚本,用完后移除监听
const handler = msg => console.log(msg.text());
page.on('console', handler);
// 用完后
page.off('console', handler);
```
**如何调试 headless 模式下的脚本?** headless 环境无法可视化,靠截图和日志定位:
```javascript
// 开启详细日志
process.env.DEBUG = 'puppeteer:*';
// 在出错时自动保存完整上下文
page.on('pageerror', async error => {
const debugInfo = {
url: page.url(),
error: error.message,
html: await page.content().catch(() => '获取失败'),
screenshot: await page.screenshot({ encoding: 'base64' }).catch(() => null)
};
require('fs').writeFileSync('crash-debug.json', JSON.stringify(debugInfo, null, 2));
});
```
掌握以上错误处理和调试方法,可以在实际项目中快速定位 Puppeteer 脚本问题,写出更稳定的自动化流程。
前端5月28日 07:11
Puppeteer 如何使用 Chrome DevTools Protocol (CDP) 进行高级调试和性能分析?Puppeteer 通过 `page.target().createCDPSession()` 创建 CDP 会话,直接与 Chrome DevTools Protocol 通信,访问 Performance、Network、Runtime、DOM、HeapProfiler 等底层域,实现性能指标采集、网络请求拦截、运行时异常捕获、内存堆快照等高级调试能力。
## CDP 会话的创建与基本用法
CDP 会话是所有操作的起点。每个 CDP 命令和事件监听都依赖这个会话对象:
```javascript
const client = await page.target().createCDPSession();
```
创建会话后,需要显式启用对应的域才能使用该域的命令和事件。各域之间相互独立,未启用的域调用会报错:
```javascript
await client.send('Performance.enable');
await client.send('Network.enable');
await client.send('Runtime.enable');
```
发送命令使用 `client.send(method, params)`,监听事件使用 `client.on(event, handler)`。这两个方法覆盖了 CDP 的全部能力,Puppeteer 高层 API 未暴露的功能都可以通过它们实现。
一个关键细节:多个 CDP 会话可以同时存在,但同一个域在不同会话中重复启用不会出错,只是会增加开销。最佳做法是复用同一个 client 实例,按需启用和禁用域:
```javascript
try {
await client.send('Performance.enable');
const { metrics } = await client.send('Performance.getMetrics');
// 处理指标数据...
} finally {
await client.send('Performance.disable');
}
```
## 性能指标采集与追踪
### 获取 Performance Metrics
启用 `Performance` 域后,可以获取浏览器内部的性能指标。这些指标与 Chrome DevTools Performance 面板中的数据一致:
```javascript
await client.send('Performance.enable');
const { metrics } = await client.send('Performance.getMetrics');
const map = Object.fromEntries(metrics.map(m => [m.name, m.value]));
```
核心指标含义:
- `LayoutDuration` — 布局耗时(秒),频繁变动说明存在布局抖动
- `RecalcStyleDuration` — 样式重算耗时,CSS 选择器复杂或 DOM 节点过多时会升高
- `ScriptDuration` — JS 执行耗时,异常升高通常指向长任务或主线程阻塞
- `TaskDuration` — 总任务耗时,包含微任务
- `JSEventListeners` — 当前注册的事件监听器数量,持续增长预示内存泄漏
- `Nodes` — DOM 节点数,超过 1500 会影响渲染性能
这些指标的解读需要结合场景。单独一个指标偏高不一定是问题——比如 `ScriptDuration` 高可能只是因为页面有大量业务逻辑。关键是在相同场景下对比变化趋势,或者在性能优化前后做对照。
### 性能追踪(Tracing)
`Tracing` 域能生成与 Chrome DevTools 相同格式的 trace 文件,可在 `chrome://tracing` 中可视化分析:
```javascript
await client.send('Tracing.start', {
traceConfig: {
includedCategories: ['devtools.timeline', 'blink.user_timing', 'v8.execute']
}
});
await page.goto('https://example.com');
const { value: traceData } = await client.send('Tracing.end');
```
追踪数据可以写入文件后用 DevTools 的 Performance 面板加载,精确定位函数调用耗时和渲染瓶颈。需要注意的是,`Tracing.end` 返回的数据量可能很大(几十 MB),处理时要留意内存占用。
### 监控长任务与布局偏移
CDP 的 `Performance.metrics` 事件会持续推送指标变化,可用于实时监控:
```javascript
client.on('Performance.metrics', ({ metrics }) => {
const map = Object.fromEntries(metrics.map(m => [m.name, m.value]));
if (map.TaskDuration > 50) {
console.warn('长任务:', map.TaskDuration, 'ms');
}
});
```
布局偏移(CLS)无法直接从 `Performance.getMetrics` 获取,需要通过 `PerformanceObserver` 在页面内注入监听,或使用 `Tracing` 追踪 `LayoutShift` 事件。Puppeteer 中注入页面脚本的做法:
```javascript
const cls = await page.evaluate(() => {
return new Promise(resolve => {
let cumulativeShift = 0;
new PerformanceObserver(list => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) cumulativeShift += entry.value;
}
}).observe({ type: 'layout-shift', buffered: true });
setTimeout(() => resolve(cumulativeShift), 5000);
});
});
```
## 网络请求监控与拦截
### 请求与响应监控
启用 `Network` 域后,可以监听完整的请求生命周期:
```javascript
await client.send('Network.enable');
client.on('Network.requestWillBeSent', ({ requestId, request, type }) => {
console.log(`→ ${request.method} ${request.url} [${type}]`);
});
client.on('Network.responseReceived', ({ requestId, response }) => {
console.log(`← ${response.status} ${response.url} ${response.mimeType}`);
});
```
获取响应体需要在响应完成后单独请求——这是初学者常踩的坑:
```javascript
client.on('Network.loadingFinished', async ({ requestId }) => {
const { body, base64Encoded } = await client.send(
'Network.getResponseBody', { requestId }
);
// body 为响应内容,base64Encoded 标识是否需要 atob 解码
});
```
注意:响应体必须在 `Network.loadingFinished` 事件后获取,不能在 `responseReceived` 时获取,此时数据可能尚未传输完毕。在 `responseReceived` 中调用 `getResponseBody` 会抛异常。
### 请求拦截与修改
CDP 提供了 `Fetch` 域(不是 `Network` 域)来实现请求拦截,功能比 Puppeteer 的 `page.setRequestInterception` 更灵活:
```javascript
await client.send('Fetch.enable', {
patterns: [
{ urlPattern: '*api.example.com*', requestStage: 'Request' }
]
});
client.on('Fetch.requestPaused', async ({ requestId, request }) => {
// 修改请求头
const headers = { ...request.headers, 'X-Custom': 'value' };
await client.send('Fetch.continueRequest', {
requestId,
headers: Object.entries(headers).map(([name, value]) => ({ name, value }))
});
});
```
`Fetch` 域支持在 Request 和 Response 阶段分别拦截,可以修改请求头、请求体、响应内容,甚至直接模拟响应。相比 Puppeteer 的 `setRequestInterception`,`Fetch` 域不会阻塞所有请求,只拦截匹配 pattern 的请求,性能更好。
### 网络限速模拟
CDP 可以模拟不同的网络条件,测试弱网表现:
```javascript
await client.send('Network.emulateNetworkConditions', {
offline: false,
latency: 100, // 额外延迟 ms
downloadThroughput: 500 * 1024, // 下载速度 500KB/s
uploadThroughput: 250 * 1024, // 上传速度 250KB/s
});
```
结合 Performance 指标采集,可以量化不同网络条件下页面的性能退化程度。
## 运行时调试与异常捕获
### JavaScript 执行与控制台监听
`Runtime` 域提供了比 `page.evaluate` 更底层的执行能力:
```javascript
await client.send('Runtime.enable');
// 执行表达式并获取返回值
const { result } = await client.send('Runtime.evaluate', {
expression: 'document.querySelectorAll("div").length',
returnByValue: true
});
console.log('DIV 数量:', result.value);
```
`Runtime.evaluate` 和 `page.evaluate` 的区别在于:前者可以指定执行上下文(`contextId`)、超时时间(`timeout`)、是否 await Promise(`awaitPromise`),控制粒度更细。
控制台输出和异常通过事件获取:
```javascript
client.on('Runtime.consoleAPICalled', ({ type, args }) => {
const values = args.map(a => a.value ?? a.description).join(' ');
console.log(`[Console.${type}]`, values);
});
client.on('Runtime.exceptionThrown', ({ exceptionDetails }) => {
const desc = exceptionDetails.exception?.description ?? exceptionDetails.text;
console.error('运行时异常:', desc);
});
```
### 调试器(Debugger)
启用 `Debugger` 域可以设置断点、单步执行,实现真正的源码级调试:
```javascript
await client.send('Debugger.enable');
// 按 URL 和行号设置断点
await client.send('Debugger.setBreakpointByUrl', {
urlRegex: 'app\\.js',
lineNumber: 42
});
client.on('Debugger.paused', async ({ reason, callFrames }) => {
const top = callFrames[0];
console.log(`断点命中: ${top.url}:${top.location.lineNumber}`);
console.log('作用域变量:', top.scopeChain[0]?.object);
await client.send('Debugger.resume');
});
```
这在排查生产环境偶发问题时非常有用——可以远程附加到运行中的浏览器实例,设置条件断点而不影响正常请求:
```javascript
await client.send('Debugger.setBreakpointByUrl', {
urlRegex: 'checkout\\.js',
lineNumber: 100,
condition: 'amount > 10000' // 只在金额大于 10000 时命中
});
```
## 内存分析与泄漏检测
### 堆快照与内存使用
`HeapProfiler` 域能生成与 DevTools Memory 面板相同的堆快照:
```javascript
await client.send('HeapProfiler.enable');
const { totalSize, usedSize } = await client.send('Runtime.getHeapUsage');
console.log(`堆内存: ${(usedSize / 1024 / 1024).toFixed(2)}MB / ${(totalSize / 1024 / 1024).toFixed(2)}MB`);
```
对比两个时间点的堆快照可以定位泄漏对象:
```javascript
// 第一次快照
await page.goto('https://example.com');
const { usedSize: used1 } = await client.send('Runtime.getHeapUsage');
// 执行操作(如反复打开/关闭弹窗)
for (let i = 0; i < 10; i++) {
await page.click('#open-modal');
await page.click('#close-modal');
}
// 第二次快照
const { usedSize: used2 } = await client.send('Runtime.getHeapUsage');
const growth = ((used2 - used1) / 1024 / 1024).toFixed(2);
console.log(`内存增长: ${growth}MB`);
```
如果 `usedSize` 持续增长且不回落,基本可以确认存在内存泄漏。进一步可以用 DevTools 加载堆快照,通过"比较"视图查看新增对象。
### 分配时间线(Allocation Timeline)
`HeapProfiler` 还支持记录内存分配过程:
```javascript
await client.send('HeapProfiler.startSampling', { samplingInterval: 32768 });
// ... 执行操作 ...
const { profile } = await client.send('HeapProfiler.stopSampling');
```
采样数据可以导入 DevTools 的 Memory 面板,以时间线形式查看内存分配的热点函数。
## DOM 与 CSS 监控
### DOM 节点操作
`DOM` 域提供了脱离 `page.$()` 的底层 DOM 操作能力,可以查询节点、获取属性、修改属性:
```javascript
await client.send('DOM.enable');
const { root } = await client.send('DOM.getDocument');
// 查询节点
const { nodeId } = await client.send('DOM.querySelector', {
nodeId: root.nodeId,
selector: '#main-content'
});
// 获取属性
const { attributes } = await client.send('DOM.getAttributes', { nodeId });
// attributes 是扁平数组: [name1, value1, name2, value2, ...]
const attrMap = {};
for (let i = 0; i < attributes.length; i += 2) {
attrMap[attributes[i]] = attributes[i + 1];
}
// 设置属性值
await client.send('DOM.setAttributeValue', {
nodeId,
name: 'data-loaded',
value: 'true'
});
```
### CSS 覆盖
`CSS` 域可以在不修改源文件的情况下覆盖页面样式,常用于测试不同视觉方案或排查样式问题:
```javascript
await client.send('CSS.enable');
await client.send('DOM.enable');
// 获取节点的匹配样式规则
const { matchedCSSRules } = await client.send('CSS.getMatchedStylesForNode', { nodeId });
// 强制设置元素伪类状态(如 :hover)
await client.send('CSS.forcePseudoState', {
nodeId,
forcedPseudoClasses: ['hover']
});
```
## 代码覆盖率分析
`Profiler` 域可以采集 JS 和 CSS 的代码覆盖率,量化未使用代码的比例。这是优化首屏加载性能的重要手段:
```javascript
await client.send('Profiler.enable');
await client.send('Profiler.startPreciseCoverage', {
callCount: true,
detailed: true
});
await page.goto('https://example.com');
await page.click('#navigate');
const { result } = await client.send('Profiler.takePreciseCoverage');
result.forEach(script => {
const total = script.functions.reduce((s, f) => s + f.ranges[0].endOffset - f.ranges[0].startOffset, 0);
const used = script.functions
.filter(f => f.ranges.some(r => r.count > 0))
.reduce((s, f) => s + f.ranges.filter(r => r.count > 0).reduce((ss, r) => ss + r.endOffset - r.startOffset, 0), 0);
console.log(`${script.url}: 使用率 ${((used / total) * 100).toFixed(1)}%`);
});
```
Puppeteer 也提供了 `page.coverage` 高层 API(`page.coverage.startJSCoverage()`),但 CDP 方式能获取更细粒度的调用次数信息,且可以同时采集 JS 和 CSS 覆盖率。
## Page 域与页面生命周期
`Page` 域提供页面级别的生命周期事件,用于监控导航、加载状态和资源树:
```javascript
await client.send('Page.enable');
client.on('Page.loadEventFired', () => {
console.log('页面 load 事件触发');
});
client.on('Page.frameNavigated', ({ frame }) => {
console.log('导航至:', frame.url);
});
client.on('Page.domContentEventFired', () => {
console.log('DOM 解析完成');
});
// 获取页面资源树
const { frameTree } = await client.send('Page.getResourceTree');
console.log('主框架:', frameTree.frame.url);
console.log('子框架数量:', frameTree.childFrames?.length ?? 0);
```
`Page` 域在多 iframe 场景下特别有用,可以追踪每个子框架的导航状态和资源加载情况。
## 完整实战:自动化性能诊断工具
将上述能力组合起来,可以构建一个自动化的页面性能诊断工具:
```javascript
const puppeteer = require('puppeteer');
async function diagnose(url) {
const browser = await puppeteer.launch();
const page = await browser.newPage();
const client = await page.target().createCDPSession();
// 启用所有需要的域
await client.send('Performance.enable');
await client.send('Network.enable');
await client.send('Runtime.enable');
const report = {
url,
performance: {},
network: { requests: [], summary: {} },
errors: [],
memory: {}
};
// 1. 采集网络数据
client.on('Network.requestWillBeSent', ({ request, type }) => {
report.network.requests.push({ url: request.url, method: request.method, type });
});
client.on('Network.responseReceived', ({ response }) => {
if (response.status >= 400) {
report.errors.push({ type: 'HTTP', status: response.status, url: response.url });
}
});
// 2. 捕获运行时异常
client.on('Runtime.exceptionThrown', ({ exceptionDetails }) => {
report.errors.push({
type: 'JS',
message: exceptionDetails.exception?.description ?? exceptionDetails.text
});
});
// 3. 加载页面
const start = Date.now();
await page.goto(url, { waitUntil: 'networkidle2' });
report.loadTime = Date.now() - start;
// 4. 性能指标
const { metrics } = await client.send('Performance.getMetrics');
const map = Object.fromEntries(metrics.map(m => [m.name, m.value]));
report.performance = {
layoutDuration: map.LayoutDuration,
scriptDuration: map.ScriptDuration,
domNodes: map.Nodes,
jsListeners: map.JSEventListeners
};
// 5. 内存使用
const heap = await client.send('Runtime.getHeapUsage');
report.memory = {
usedMB: (heap.usedSize / 1024 / 1024).toFixed(2),
totalMB: (heap.totalSize / 1024 / 1024).toFixed(2)
};
// 6. 网络汇总
report.network.summary = {
total: report.network.requests.length,
byType: report.network.requests.reduce((acc, r) => {
acc[r.type] = (acc[r.type] || 0) + 1;
return acc;
}, {})
};
// 7. 诊断建议
report.recommendations = [];
if (map.LayoutDuration > 0.5) {
report.recommendations.push('布局耗时过长,检查是否存在强制同步布局或布局抖动');
}
if (map.Nodes > 1500) {
report.recommendations.push('DOM 节点数量偏多,考虑虚拟滚动或懒加载');
}
if (map.JSEventListeners > 200) {
report.recommendations.push('事件监听器数量异常,可能存在未清理的监听器导致内存泄漏');
}
if (report.errors.length > 0) {
report.recommendations.push(`发现 ${report.errors.length} 个错误,需优先修复`);
}
await client.send('Performance.disable');
await client.send('Network.disable');
await client.send('Runtime.disable');
await browser.close();
return report;
}
diagnose('https://example.com').then(r => console.log(JSON.stringify(r, null, 2)));
```
## 最佳实践与注意事项
**及时禁用不再使用的域。** 每个启用的域都会产生事件流开销,尤其是 `Network` 和 `Runtime` 域数据量很大。用完后调用 `client.send('XXX.disable')` 释放资源。
**事件监听器需要手动清理。** `client.on()` 注册的监听器不会随页面导航自动移除,在循环场景下会导致重复监听。使用 `client.off()` 或 `client.removeAllListeners()` 清理:
```javascript
const handler = ({ request }) => { /* ... */ };
client.on('Network.requestWillBeSent', handler);
// 操作完成后
client.off('Network.requestWillBeSent', handler);
```
**CDP 命令可能抛异常。** 浏览器版本差异可能导致某些 CDP 方法不可用,务必 try-catch 包裹并做降级处理。
**避免在事件回调中发送 CDP 命令。** 这可能导致命令顺序错乱。应该将数据收集到队列中,在主流程中批量处理。
**多页面共享 CDP 会话不可行。** 每个 CDP 会话绑定到特定的页面目标,跨页面操作需要为每个页面创建独立的会话。对于多 Tab 场景,可以为每个 `browser.newPage()` 创建对应的 CDP 会话。
**生产环境慎用 `HeapProfiler.takeHeapSnapshot`。** 堆快照会暂停主线程,在用户访问期间执行会直接造成页面卡顿。建议在无头模式下的自动化测试中使用,或选择 `startSampling` 采样方式以减少性能影响。前端5月28日 07:10
Puppeteer 如何处理动态网页和单页应用(SPA)?Puppeteer 在处理动态网页和单页应用(SPA)时拥有天然优势——它运行完整的 Chromium 浏览器,能够执行 JavaScript、等待异步加载完成、捕获路由变化,这些都是传统 HTTP 爬虫无法做到的。但真正写出健壮的 SPA 爬虫,关键在于选择正确的等待策略、合理拦截网络请求、以及处理各种边界情况。
## 等待动态内容加载的正确方式
SPA 的核心特征是页面内容由 JavaScript 动态渲染,因此"等待"是 Puppeteer 爬虫的第一要务。三种等待策略各有适用场景:
**waitForSelector — 等待元素出现**
最常用的等待方式,适合目标元素有明确选择器的场景:
```javascript
const puppeteer = require('puppeteer');
async function scrapeDynamicContent() {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://example.com');
// visible: true 确保元素不仅存在于 DOM,而且可见
await page.waitForSelector('.dynamic-content', { visible: true });
const content = await page.$eval('.dynamic-content', el => el.textContent);
await browser.close();
return content;
}
```
**waitForFunction — 等待自定义条件**
当等待条件无法用单一选择器表达时,用 `waitForFunction` 编写判断逻辑:
```javascript
// 等待列表项数量超过阈值
await page.waitForFunction(() => {
return document.querySelectorAll('.item').length > 10;
});
// 等待全局状态就绪
await page.waitForFunction(() => {
return window.__APP_READY__ === true;
});
```
**waitUntil 选项 — 等待网络状态**
`page.goto` 的 `waitUntil` 参数控制何时认为页面加载完成:
- `domcontentloaded`:DOM 解析完成,不等样式和图片
- `load`:所有资源加载完毕
- `networkidle0`:500ms 内无网络请求(适合纯 API 驱动的页面)
- `networkidle2`:500ms 内不超过 2 个网络请求(适合有长连接或分析脚本的页面)
```javascript
// SPA 最常用的加载策略
await page.goto('https://example.com', { waitUntil: 'networkidle2' });
```
## 处理无限滚动与懒加载
无限滚动是 SPA 中最常见的加载模式,核心思路是循环滚动并检测新内容是否出现。
**基础版:检测页面高度变化**
```javascript
async function scrapeInfiniteScroll(page, maxItems = 100) {
const items = [];
let previousHeight = 0;
while (items.length < maxItems) {
await page.evaluate(() => {
window.scrollTo(0, document.body.scrollHeight);
});
// 等待新内容渲染,优先等待选择器而非固定时间
try {
await page.waitForSelector('.item:last-child', { timeout: 3000 });
} catch {
break; // 超时说明没有更多内容
}
const currentHeight = await page.evaluate(() => document.body.scrollHeight);
if (currentHeight === previousHeight) break;
previousHeight = currentHeight;
const newItems = await page.$$eval('.item', els =>
els.map(el => el.textContent.trim())
);
items.push(...newItems);
}
return [...new Set(items)]; // 去重
}
```
**进阶版:等待加载指示器消失**
更可靠的方式是观察"加载中"指示器的出现和消失:
```javascript
async function scrapeInfiniteScrollRobust(page) {
const items = [];
let noNewItemsCount = 0;
while (noNewItemsCount < 3) {
const countBefore = items.length;
await page.evaluate(() => {
window.scrollTo(0, document.body.scrollHeight);
});
// 等加载指示器消失
try {
await page.waitForSelector('.loading-spinner', { hidden: true, timeout: 5000 });
} catch {
noNewItemsCount++;
continue;
}
const currentItems = await page.$$eval('.item', els =>
els.map(el => ({ id: el.dataset.id, text: el.textContent.trim() }))
);
// 只添加新项目
const existingIds = new Set(items.map(i => i.id));
const freshItems = currentItems.filter(i => !existingIds.has(i.id));
if (freshItems.length === 0) {
noNewItemsCount++;
} else {
noNewItemsCount = 0;
items.push(...freshItems);
}
}
return items;
}
```
## 处理 SPA 路由变化
SPA 的路由切换不会触发页面刷新,URL 变了但浏览器不会重新加载,因此需要主动监听和等待。
**等待 URL 变化到目标路径**
```javascript
async function waitForRoute(page, targetPath, timeout = 10000) {
await page.waitForFunction(
(path) => window.location.pathname === path,
{ timeout },
targetPath
);
}
// 使用
await page.click('#about-link');
await waitForRoute(page, '/about');
```
**监听所有路由变化**
通过 `framenavigated` 事件捕获 SPA 内的导航:
```javascript
page.on('framenavigated', (frame) => {
if (frame === page.mainFrame()) {
console.log('路由变化:', frame.url());
}
});
// 触发导航
await page.click('#nav-link');
```
**等待 SPA 渲染完成再提取数据**
路由切换后,新页面的 DOM 还没渲染出来,直接提取会拿到空数据:
```javascript
async function navigateAndExtract(page, linkSelector, contentSelector) {
await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle2' }),
page.click(linkSelector),
]);
// 路由已切换,等待新内容渲染
await page.waitForSelector(contentSelector, { visible: true });
return page.$eval(contentSelector, el => el.textContent);
}
```
## 拦截和监控网络请求
掌握 SPA 的网络请求是高效爬取的关键——你可以直接拿到 API 返回的 JSON 数据,无需解析 DOM。
**等待特定 API 响应**
```javascript
async function waitForAPIResponse(page, urlPattern) {
return page.waitForResponse(
response => response.url().includes(urlPattern) && response.status() === 200
);
}
// 点击触发请求,同时等待响应
const [response] = await Promise.all([
waitForAPIResponse(page, '/api/data'),
page.click('#load-data'),
]);
const data = await response.json();
console.log(data);
```
**拦截请求:屏蔽不需要的资源**
减少不必要的网络请求能显著提升爬取速度:
```javascript
await page.setRequestInterception(true);
page.on('request', (request) => {
const blockedTypes = ['image', 'font', 'media', 'stylesheet'];
if (blockedTypes.includes(request.resourceType())) {
request.abort();
} else {
request.continue();
}
});
```
**修改请求:注入认证信息**
```javascript
await page.setRequestInterception(true);
page.on('request', (request) => {
if (request.url().includes('/api/')) {
request.continue({
headers: {
...request.headers(),
'Authorization': 'Bearer your-token',
},
});
} else {
request.continue();
}
});
```
**注意:** `setRequestInterception` 开启后,所有请求都必须手动调用 `continue()` 或 `abort()`,否则请求会挂起。
## 处理 WebSocket 实时数据
SPA 中的实时功能(聊天、行情、通知)通常依赖 WebSocket,Puppeteer 可以通过 Chrome DevTools Protocol 监听 WebSocket 消息。
```javascript
const client = await page.createCDPSession();
await client.send('Network.enable');
// 接收 WebSocket 消息
client.on('Network.webSocketFrameReceived', (params) => {
console.log('收到:', params.response.payloadData);
});
// 发送 WebSocket 消息
client.on('Network.webSocketFrameSent', (params) => {
console.log('发送:', params.response.payloadData);
});
// WebSocket 关闭
client.on('Network.webSocketClosed', () => {
console.log('WebSocket 连接已关闭');
});
```
这种方式适合监听实时推送数据,比轮询 DOM 更高效。
## 处理 React/Vue 等 SPA 框架
不同框架的渲染机制略有差异,但核心思路一致:等待框架渲染完成标志。
**React 应用**
```javascript
async function scrapeReactApp(page, url) {
await page.goto(url, { waitUntil: 'networkidle2' });
// React 18+ 使用 createRoot,应用挂载到 root 节点
await page.waitForSelector('#root');
// 等待数据加载(如果框架暴露了全局状态)
await page.waitForFunction(() => {
return window.__INITIAL_STATE__?.loaded === true;
});
// 或直接等待目标元素
await page.waitForSelector('[data-loaded="true"]');
return page.$$eval('.data-item', els =>
els.map(el => el.textContent.trim())
);
}
```
**Vue 应用**
```javascript
async function scrapeVueApp(page, url) {
await page.goto(url, { waitUntil: 'networkidle2' });
// Vue 3 应用挂载到 app 节点
await page.waitForSelector('#app');
// 等待 Vue 组件渲染
await page.waitForFunction(() => {
return document.querySelector('.v-cloak') === null;
});
return page.content();
}
```
**通用方案:等待 DOM 稳定**
如果无法判断框架类型,可以等待 DOM 变化趋于稳定:
```javascript
async function waitForDOMStable(page, checkInterval = 500, stableThreshold = 3) {
let lastHTML = '';
let stableCount = 0;
while (stableCount < stableThreshold) {
const currentHTML = await page.evaluate(() => document.body.innerHTML.length);
if (currentHTML === lastHTML) {
stableCount++;
} else {
stableCount = 0;
lastHTML = currentHTML;
}
await new Promise(r => setTimeout(r, checkInterval));
}
}
```
## 实战场景:完整的 SPA 爬虫
把上述技巧组合起来,写一个能应对真实 SPA 的爬虫:
```javascript
async function scrapeSPA(url) {
const browser = await puppeteer.launch({ headless: 'new' });
const page = await browser.newPage();
// 屏蔽无关资源,加速加载
await page.setRequestInterception(true);
page.on('request', (req) => {
['image', 'font', 'media'].includes(req.resourceType())
? req.abort()
: req.continue();
});
await page.goto(url, { waitUntil: 'networkidle2' });
// 收集 API 数据(比解析 DOM 更可靠)
const apiData = [];
page.on('response', async (response) => {
if (response.url().includes('/api/') && response.status() === 200) {
try {
apiData.push(await response.json());
} catch {}
}
});
// 处理无限滚动
const allItems = await scrapeInfiniteScroll(page, 50);
await browser.close();
return { allItems, apiData };
}
```
## 关键陷阱与应对
**1. waitForTimeout 已废弃**
`page.waitForTimeout()` 在 Puppeteer 21+ 中已移除,用原生 `setTimeout` 替代:
```javascript
// 旧写法(已废弃)
await page.waitForTimeout(2000);
// 新写法
await new Promise(resolve => setTimeout(resolve, 2000));
```
**2. 超时与错误处理**
SPA 加载时间不确定,所有等待操作都应设置超时并提供降级方案:
```javascript
try {
await page.waitForSelector('.content', { timeout: 10000 });
} catch {
// 降级:尝试其他选择器或返回默认值
const content = await page.evaluate(() =>
document.querySelector('.fallback-content')?.textContent || ''
);
}
```
**3. SPA 中的内存泄漏**
长时间运行的爬虫中,事件监听器会累积:
```javascript
// 用完即移除
const handler = (response) => { /* ... */ };
page.on('response', handler);
// 完成后
page.off('response', handler);
```
**4. 反爬虫检测**
SPA 站点通常有更复杂的反爬机制:
```javascript
// 伪装浏览器指纹
await page.setViewport({ width: 1920, height: 1080 });
await page.setUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
);
// 注入 stealth 插件
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
const puppeteerExtra = require('puppeteer-extra');
puppeteerExtra.use(StealthPlugin());
```
前端5月28日 07:08
Puppeteer 中有哪些等待机制?如何正确使用它们来处理异步操作?## 核心答案
Puppeteer 提供了 8 种等待机制,按场景分为四类:
- **导航等待**:`waitForNavigation()` — 等待页面跳转完成
- **元素等待**:`waitForSelector()`、`waitForXPath()` — 等待 DOM 元素出现
- **网络等待**:`waitForResponse()`、`waitForRequest()` — 等待网络请求或响应
- **自定义等待**:`waitForFunction()`、`waitForFrame()`、`waitForTimeout()`(已废弃)
选择原则:导航操作用 `waitForNavigation`,元素操作用 `waitForSelector`,API 调试用 `waitForResponse`,复杂条件用 `waitForFunction`。永远不要用 `waitForTimeout` 做硬等待,它已被废弃。
**追问:waitForNavigation 和 click 为什么要用 Promise.all 包裹?**
因为 click 触发的导航是异步的,如果先 click 再 await waitForNavigation,导航可能在 click 返回前就已经完成了,导致 waitForNavigation 永远等不到。用 Promise.all 让两者同时开始监听,才能确保不丢失导航事件。
## 导航等待:waitForNavigation
`page.waitForNavigation()` 等待页面发生导航并完成加载,典型场景是点击链接、提交表单。
```javascript
// 正确写法:用 Promise.all 并行等待
await Promise.all([
page.waitForNavigation(),
page.click('#submit-button')
]);
```
`waitUntil` 参数决定"加载完成"的标准,四个选项适用场景不同:
| 选项 | 触发条件 | 适用场景 |
|------|---------|---------|
| `load` | window.onload 触发 | 静态页面 |
| `domcontentloaded` | DOM 解析完毕 | 只需 DOM 不等资源 |
| `networkidle0` | 500ms 内无网络请求 | SPA 应用,等全部接口返回 |
| `networkidle2` | 500ms 内 ≤2 个网络请求 | 有长连接或轮询的页面 |
`networkidle2` 是实际项目中最常用的选项。很多页面有 WebSocket 长连接或统计上报,用 `networkidle0` 会永远等不到空闲,`networkidle2` 允许最多 2 个连接,正好覆盖这种情况。
超时时间通过 `timeout` 参数设置,默认 30 秒。如果页面加载慢,可以调大:
```javascript
await page.waitForNavigation({
waitUntil: 'networkidle2',
timeout: 60000
});
```
## 元素等待:waitForSelector 与 waitForXPath
### waitForSelector
`page.waitForSelector(selector)` 等待匹配选择器的元素出现在 DOM 中。这是最常用的等待方法,大多数场景下用它就够了。
```javascript
// 等待元素出现
await page.waitForSelector('.result-item');
// 等待元素可见(不仅存在于 DOM,还要有尺寸)
await page.waitForSelector('.modal', { visible: true });
// 等待元素隐藏或消失
await page.waitForSelector('.loading-spinner', { hidden: true });
```
`visible` 和 `hidden` 的区别需要留意:不用这两个选项时,只要元素在 DOM 中就算满足条件,哪怕 `display: none`。加了 `visible: true` 才要求元素实际可见(有非零尺寸)。
### waitForXPath
`page.waitForXPath(xpath)` 是 XPath 版本的元素等待,在需要按文本内容或复杂层级关系定位时有用:
```javascript
// 按文本内容定位
await page.waitForXPath('//button[contains(text(), "提交")]');
// 复杂层级关系
await page.waitForXPath('//div[@class="form"]/following-sibling::button');
```
实际项目中 CSS 选择器能覆盖 90% 的场景,`waitForXPath` 主要用于文本匹配这类选择器不好写的情况。
## 网络等待:waitForResponse 与 waitForRequest
### waitForResponse
`page.waitForResponse()` 等待特定的网络响应返回,在调试接口或等待异步数据加载时非常实用。
```javascript
// 等待特定 URL 的响应
const response = await page.waitForResponse(
'https://api.example.com/data'
);
// 用谓词函数做更精确的匹配
const response = await page.waitForResponse(
res => res.url().includes('/api/users') && res.status() === 200
);
const data = await response.json();
```
一个常见的使用模式是:触发操作的同时等待对应的接口响应,确保数据已经返回:
```javascript
const [response] = await Promise.all([
page.waitForResponse(res => res.url().includes('/api/search')),
page.type('#search-input', 'puppeteer')
]);
```
### waitForRequest
`page.waitForRequest()` 等待特定的网络请求发出。和 `waitForResponse` 的区别是:一个等请求发出,一个等响应回来。
```javascript
// 验证点击按钮后是否发出了正确的请求
const request = await page.waitForRequest(
req => req.url().includes('/api/track') && req.method() === 'POST'
);
```
`waitForRequest` 在验证请求参数、检查埋点是否正确上报时比较常用。
## 自定义等待:waitForFunction
`page.waitForFunction(pageFunction, ...args)` 是最灵活的等待方式,可以等待任意 JavaScript 表达式为真。
```javascript
// 等待列表加载超过 5 项
await page.waitForFunction(
() => document.querySelectorAll('.item').length > 5
);
// 带参数
await page.waitForFunction(
(count) => document.querySelectorAll('.item').length >= count,
{},
10
);
// 等待某个全局变量赋值
await page.waitForFunction(
() => window.__APP_READY__ === true
);
// 等待 SPA 路由切换
await page.waitForFunction(
() => window.location.pathname === '/dashboard'
);
```
当 `waitForSelector` 和 `waitForResponse` 都无法满足需求时(比如等待元素数量变化、等待某个 JS 变量、等待 URL 变化),就用 `waitForFunction`。
`waitForFunction` 的第二个参数可以传入轮询策略:
```javascript
await page.waitForFunction(
() => document.querySelector('.price')?.textContent !== '',
{ polling: 'mutation' } // DOM 变化时检查,比定时轮询高效
);
```
`polling` 支持 `'raf'`(每帧检查)、`'mutation'`(DOM 变化时检查)、数字(毫秒间隔)。DOM 相关等待用 `mutation` 最合理。
## waitForFrame:等待 iframe 加载
`page.waitForFrame()` 等待指定的 iframe 加载完成,处理嵌入页面时使用:
```javascript
const frame = await page.waitForFrame('iframe-name');
const button = await frame.waitForSelector('.btn');
await button.click();
```
多 iframe 场景下,操作前一定要先拿到正确的 frame 对象,再通过 frame 调用 waitForSelector,而不是直接用 page 调用,否则会找不到元素。
## waitForTimeout:已废弃的硬等待
`page.waitForTimeout(milliseconds)` 等待固定时间,已被官方废弃。如果确实需要延时,用原生方式替代:
```javascript
// 已废弃
await page.waitForTimeout(1000);
// 替代方案
await new Promise(resolve => setTimeout(resolve, 1000));
```
硬等待的问题在于:时间设短了不够等,设长了浪费时间,而且无法适应网络波动。应该尽量用条件等待替代,只有在完全没有条件可判断的极端场景下才考虑延时。
## 常见坑与解决方案
### 坑 1:waitForNavigation 和 click 的竞态
这是 Puppeteer 新手最常见的 bug。先 click 再 waitForNavigation,导航可能在 click 返回前就完成了:
```javascript
// 错误写法:可能永远等不到导航
await page.click('#link');
await page.waitForNavigation();
// 正确写法:并行等待
await Promise.all([
page.waitForNavigation(),
page.click('#link')
]);
```
### 坑 2:元素在 DOM 中但不可见
`waitForSelector` 默认只检查元素是否在 DOM 中,不关心是否可见。如果页面有 `display: none` 或 `visibility: hidden` 的元素,不加 `visible: true` 可能拿到不可操作的元素:
```javascript
// 可能拿到隐藏元素
await page.waitForSelector('.dropdown-menu');
// 确保元素可见
await page.waitForSelector('.dropdown-menu', { visible: true });
```
### 坑 3:SPA 页面导航不会触发 waitForNavigation
SPA 内部的路由切换(比如 React Router 或 Vue Router)不会触发浏览器级别的导航事件,`waitForNavigation` 不会触发。这种场景要用 `waitForFunction` 等待 URL 变化或特定元素出现:
```javascript
// SPA 路由切换不能用 waitForNavigation
await Promise.all([
page.waitForFunction(() => window.location.pathname === '/profile'),
page.click('.nav-profile')
]);
```
### 坑 4:networkidle0 在有长连接的页面上永远等不到
如果页面有 WebSocket 或 SSE 连接,网络请求永远不会归零,`networkidle0` 会超时。改用 `networkidle2`:
```javascript
// 有长连接的页面
await page.goto(url, { waitUntil: 'networkidle2' });
```
## 超时处理
所有等待方法都支持 `timeout` 参数,默认 30 秒。可以在页面级别设置默认超时:
```javascript
page.setDefaultTimeout(10000); // 全局默认 10 秒
// 也可以在单次调用中覆盖
await page.waitForSelector('.element', { timeout: 5000 });
```
超时会抛出 `TimeoutError`,用 try/catch 捕获后可以做降级处理:
```javascript
try {
await page.waitForSelector('.optional-banner', { timeout: 3000 });
// banner 出现了,关闭它
await page.click('.banner-close');
} catch (error) {
// banner 没出现,继续执行
}
```
这种"可选元素"的等待模式在实际项目中很常用:元素可能出现也可能不出现,出现了就处理,没出现也不影响主流程。
## 方法选择速查
| 场景 | 方法 | 示例 |
|------|------|------|
| 点击后页面跳转 | waitForNavigation | `await Promise.all([page.waitForNavigation(), page.click('#link')])` |
| 等待动态元素出现 | waitForSelector | `await page.waitForSelector('.item', { visible: true })` |
| 等待接口返回数据 | waitForResponse | `await page.waitForResponse(res => res.url().includes('/api/data'))` |
| 等待复杂条件满足 | waitForFunction | `await page.waitForFunction(() => document.querySelectorAll('.item').length > 5)` |
| 等待 iframe 加载 | waitForFrame | `await page.waitForFrame('iframe-name')` |
| SPA 路由切换 | waitForFunction | `await page.waitForFunction(() => location.pathname === '/new')` |
| 按文本定位元素 | waitForXPath | `await page.waitForXPath('//button[contains(text(),"确认")]')` |
| 验证请求发出 | waitForRequest | `await page.waitForRequest(req => req.url().includes('/track'))` |
Puppeteer 的等待机制核心思路就是用条件等待替代硬编码延时。选对方法、处理好事物的并行和竞态关系,脚本才能既稳定又高效。遇到问题先判断是导航、元素、网络还是自定义条件,然后对号入座选方法,大部分不稳定用例都能解决。
前端5月28日 07:08
Puppeteer 和 Selenium 有什么区别?## 核心结论
Puppeteer 和 Selenium 的根本区别在于通信协议:Puppeteer 基于 Chrome DevTools Protocol (CDP) 直接与浏览器内核通信,而 Selenium 基于 WebDriver 协议通过中间驱动层间接控制浏览器。这决定了两者在性能、能力和适用场景上的所有差异。
**简单选择标准:** 只需要操作 Chrome 且追求性能 → Puppeteer;需要跨浏览器或企业级测试 → Selenium。
## 通信协议的本质差异
这是理解两者所有区别的钥匙。
**CDP(Puppeteer):** 通过 WebSocket 直接连接浏览器的调试端口,指令直达渲染进程。没有中间层翻译,所以快。代价是只能控制实现了 CDP 的浏览器,实际上就是 Chrome/Chromium。
**WebDriver(Selenium):** 测试脚本 → WebDriver 客户端 → WebDriver 服务器(如 chromedriver)→ 浏览器。每一层都是一次进程间通信,不可避免地引入延迟。好处是 WebDriver 是 W3C 标准协议,任何浏览器只要实现 WebDriver 接口就能被 Selenium 控制。
```javascript
// Puppeteer:直接通信,一步到位
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://example.com');
// Selenium:经过驱动层中转
const driver = await new Builder().forBrowser('chrome').build();
await driver.get('https://example.com'); // 命令经 chromedriver 转发
```
## 什么时候选择 Puppeteer
**场景一:网页爬虫和数据抓取**
Puppeteer 的网络拦截能力是爬虫场景的核心武器。可以在请求层面直接屏蔽图片、字体等无关资源,大幅提升抓取速度。Selenium 没有原生的请求拦截能力,只能依赖第三方代理。
```javascript
await page.setRequestInterception(true);
page.on('request', request => {
const blocked = ['image', 'font', 'stylesheet'];
blocked.includes(request.resourceType()) ? request.abort() : request.continue();
});
```
**场景二:性能监控和页面指标采集**
通过 CDP 可以直接读取浏览器内核的性能数据(LCP、FID、CLS 等),这是 Selenium 无法做到的。Chrome DevTools 的 Performance 面板能看到的指标,Puppeteer 都能程序化获取。
**场景三:截图和 PDF 生成**
Puppeteer 的截图 API 支持全页截图、指定元素截图、自定义视口。PDF 生成直接调用 Chrome 的打印引擎,排版效果与浏览器打印预览一致。Selenium 的截图功能相对基础,不支持 PDF 生成。
**场景四:设备模拟和地理位置**
```javascript
// 一行代码模拟 iPhone 12
await page.emulate(puppeteer.devices['iPhone 12']);
// 设置地理位置
await page.setGeolocation({ latitude: 35.6895, longitude: 139.6917 });
```
## 什么时候选择 Selenium
**场景一:跨浏览器兼容性测试**
这是 Selenium 最不可替代的能力。如果你的产品需要保证在 Safari、Firefox、Edge 上都能正常运行,Selenium 是唯一成熟的选择。Puppeteer 对非 Chromium 浏览器的支持非常有限。
**场景二:多语言技术栈**
Selenium 支持 Java、Python、C#、Ruby、JavaScript 等主流语言。后端团队用 Java 写测试、数据团队用 Python 写测试、前端团队用 JavaScript 写测试,都能统一在 Selenium 体系下。Puppeteer 只支持 Node.js。
**场景三:企业级分布式测试**
Selenium Grid 支持在多台机器上并行运行测试,结合 Docker 可以快速搭建大规模测试集群。Puppeteer 本身没有分布式能力,需要借助第三方工具。
**场景四:移动端测试**
通过 Appium(基于 WebDriver 协议),Selenium 生态可以覆盖原生移动应用测试。Puppeteer 只能测试移动端网页,无法触及原生层。
## 性能对比的根因分析
不是"Puppeteer 更快"这么简单。快在哪里?
- **启动速度:** Puppeteer 自带 Chromium,无需额外下载驱动;Selenium 需要匹配浏览器版本下载对应驱动,版本不匹配是常见报错来源
- **指令执行:** CDP 单次指令延迟 <10ms,WebDriver 经驱动中转延迟 50-200ms
- **内存占用:** Puppeteer 可通过 `page.setRequestInterception` 屏蔽无关资源,减少内存消耗;Selenium 无法在请求层做控制
但 Selenium 在 4.x 版本引入了 CDP 支持(`SeV4CDP`),部分缩小了性能差距。不过 CDP 功能在 Selenium 中属于实验性特性,稳定性和 API 完整度不如 Puppeteer。
## 2026 年的新变量:Playwright
讨论 Puppeteer vs Selenium 不能忽略 Playwright。微软推出的 Playwright 同时支持 Chromium、Firefox 和 WebKit,API 设计比 Puppeteer 更现代(自动等待、多页面上下文),性能接近 Puppeteer。
如果你正在做技术选型,决策逻辑应该是:
- 只需要 Chrome → Puppeteer
- 需要多浏览器 + 全新项目 → 优先考虑 Playwright
- 已有 Selenium 基础设施 / 需要 Java 等非 JS 语言 → Selenium
## 追问:Puppeteer 能用来做自动化测试吗?
可以,但要认清局限。Puppeteer 本质是浏览器控制库,不是测试框架。它没有内置的断言库、测试运行器、用例管理。实际项目中通常配合 Jest 或 Mocha 使用。
与 Selenium 作为测试框架的定位不同,Puppeteer 更适合作为工具链中的一环——爬虫用它抓数据,CI 用它做冒烟测试,监控系统用它采集性能指标。如果你需要的是一套完整的端到端测试方案,Selenium + 测试框架的组合更成熟;如果只需要轻量级的浏览器控制能力,Puppeteer 更灵活。
反过来,如果你的爬虫需要绕过反爬检测,Puppeteer 需要配合 stealth 插件隐藏自动化特征,而 Selenium 同样需要类似的反检测处理。两个工具在反爬场景下的表现差异不大,关键在于如何模拟真实用户行为。前端5月28日 07:07
Puppeteer 在实际项目中怎么用?Puppeteer 是 Google 维护的 Node.js 浏览器自动化库,通过 Chrome DevTools Protocol 控制无头浏览器。它的实际应用远不止"跑个脚本打开网页",在爬虫、测试、文档生成、性能监控等场景中都是生产级方案。
## 核心应用场景一览
| 场景 | 典型用途 | 复杂度 |
|------|---------|--------|
| 网页爬虫 | SPA 数据采集、价格监控 | 中 |
| 自动化测试 | E2E 测试、视觉回归 | 中高 |
| PDF 生成 | 报表、发票批量输出 | 低 |
| 性能监控 | 页面加载分析、Core Web Vitals | 中 |
| SEO 审计 | 页面结构检查、可访问性扫描 | 低 |
| 自动化运维 | 表单批量填写、数据录入 | 中 |
下面逐个场景拆解关键实现和踩坑要点。
## 网页爬虫:SPA 和动态内容的克星
传统爬虫(requests/axios)面对 Vue、React 渲染的页面基本无能为力,因为拿到的 HTML 只是空壳。Puppeteer 的优势在于它能等 JavaScript 执行完毕再提取数据。
价格监控是最常见的爬虫场景。核心逻辑:启动浏览器 → 设置 User-Agent 伪装 → 等待目标元素出现 → 提取数据。一段精简实现:
```javascript
const puppeteer = require('puppeteer');
async function monitorPrice(url) {
const browser = await puppeteer.launch({ headless: 'new' });
const page = await browser.newPage();
await page.setUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
);
await page.goto(url, { waitUntil: 'networkidle2' });
await page.waitForSelector('.price', { timeout: 10000 });
const data = await page.evaluate(() => ({
title: document.querySelector('.product-title')?.textContent,
price: document.querySelector('.price')?.textContent,
}));
await browser.close();
return data;
}
```
**踩坑经验:** `networkidle2` 不等于页面完全加载。如果目标元素是懒加载的,建议用 `waitForSelector` 配合超时做二次保障。另外,大批量采集时务必控制并发数,同时打开 20 个标签页会直接把内存撑爆。
**反爬虫要点:** 裸跑 Puppeteer 会被大多数反爬系统识别——`navigator.webdriver` 属性默认为 `true`,WebGL 指纹也暴露无头浏览器特征。生产环境中需要配合 `puppeteer-extra-plugin-stealth` 插件修补这些泄露点,或者使用代理池轮换 IP。
## 自动化测试:E2E 与视觉回归
Puppeteer 在测试领域有两个典型用法:
**端到端流程测试**——模拟用户完整操作路径,验证业务逻辑正确性。比如注册-登录-下单流程,每个步骤的页面跳转和状态变化都能断言。
关键技巧是用 `Promise.all` 包裹点击和等待导航,避免竞态条件:
```javascript
await Promise.all([
page.waitForNavigation(),
page.click('#submit-button'),
]);
```
**视觉回归测试**——截取页面快照与基线图对比,像素级检测 UI 变更。核心依赖 `pixelmatch` 库做图片 diff,差异超过阈值(通常 0.5%)即判定为回归。
实际项目中建议把视觉回归集成到 CI 流程,每次提交自动跑一遍。注意截图的稳定性:字体渲染、动画状态、抗锯齿差异都可能产生误报。解决方法是截图前等动画完成,并用固定视口宽度。
## PDF 生成:报表和发票的批量引擎
服务端生成 PDF 是个老大难问题。用 PDFKit 手动排版太痛苦,用 wkhtmltopdf 中文渲染经常出问题。Puppeteer 的方案最直接:渲染 HTML → 调用 `page.pdf()` 输出。
```javascript
await page.setContent(htmlContent);
await page.pdf({
path: 'report.pdf',
format: 'A4',
printBackground: true,
margin: { top: '20px', bottom: '20px', left: '20px', right: '20px' },
});
```
批量生成发票时,不要每个发票都启停浏览器。用一个浏览器实例复用 Page,速度能提升 5-10 倍。但要注意内存泄漏——每次 `setContent` 后如果页面越来越慢,说明需要定期 `page.close()` 再开新 Page。
## 性能监控:比 Lighthouse 更灵活
Lighthouse 适合一次性审计,但线上持续监控需要自定义方案。Puppeteer 可以精确采集每个页面的 FCP、LCP、DOM 节点数等指标,写入时序数据库做趋势分析。
```javascript
const client = await page.target().createCDPSession();
await client.send('Performance.enable');
await page.goto(url, { waitUntil: 'networkidle2' });
const fcp = await page.evaluate(() =>
performance.getEntriesByType('paint')
.find(e => e.name === 'first-contentful-paint')?.startTime
);
```
通过 CDP Session 还能拦截网络请求、监控 JS 堆内存变化,这些是 Lighthouse 做不到的细粒度采集。
## SEO 审计:自动化页面健康检查
Puppeteer 可以批量扫描网站的 SEO 问题:缺少 title 标签、meta description 过长、H1 缺失或重复、图片缺少 alt 属性等。核心是 `page.evaluate` 在页面上下文中执行 DOM 查询,把结果结构化返回。
相比纯 HTTP 请求的方式,Puppeteer 能检查 JS 渲染后的真实 DOM,对 SPA 应用尤其重要——很多 SPA 的 SEO 问题只有运行后才能发现。
## 请求拦截与资源优化
这是一个跨场景的通用技巧。通过拦截请求可以大幅降低资源消耗:
```javascript
await page.setRequestInterception(true);
page.on('request', (req) => {
const blocked = ['image', 'font', 'stylesheet'];
blocked.includes(req.resourceType()) ? req.abort() : req.continue();
});
```
爬虫场景下屏蔽图片和字体能提速 40% 以上;测试场景下可以 mock 接口返回,实现更可控的测试环境。
## 面试高频追问
**Q: Puppeteer 和 Playwright 怎么选?**
Puppeteer 只支持 Chromium,API 简洁,适合 Chrome 专属场景。Playwright 支持三浏览器(Chromium/Firefox/WebKit),自动等待机制更智能,新增项目推荐 Playwright。但 Puppeteer 生态更成熟,`puppeteer-extra` 插件体系(stealth、recaptcha)在爬虫场景无可替代。选型看需求:爬虫偏 Puppeteer,跨浏览器测试偏 Playwright。
**Q: 无头浏览器如何降低被检测概率?**
三层防护:第一层用 stealth 插件修补 `navigator.webdriver`、Chrome 对象等指纹;第二层用 `ghost-cursor` 模拟真人鼠标轨迹,避免点击坐标过于精确;第三层用代理池轮换 IP 和 User-Agent,避免单 IP 高频请求触发风控。没有银弹,三层全上才能通过中高级反爬。
**Q: Puppeteer 采集任务如何稳定运行在生产环境?**
三个关键点:一是进程管理,用 `puppeteer.connect` 连接常驻浏览器实例而非每次启动,配合 `pm2` 做进程守护;二是内存控制,每处理 50 个页面重启一次浏览器,防止内存泄漏积累;三是错误恢复,`page.on('error')` 监听页面崩溃,`browser.on('disconnected')` 监听浏览器断连,两者都要有自动重连逻辑。
Puppeteer 的应用边界还在扩展——AI Agent 的浏览器操作层、RPA 流程自动化、竞品数据监控,都是 2026 年依然活跃的场景。掌握核心 API 再结合上述实战经验,基本能覆盖日常开发中 90% 的浏览器自动化需求。前端5月28日 06:53
Puppeteer 如何与测试框架集成实现 E2E 和 CI/CD?Puppeteer 可以与 Jest、Mocha、Vitest 等主流测试框架深度集成,完成端到端测试、视觉回归测试和性能测试,再通过 GitHub Actions、Docker 等工具接入 CI/CD 流水线。以下是生产环境中经过验证的集成方式和最佳实践。
## 与 Jest 集成
Jest 是与 Puppeteer 搭配最多的测试框架,jest-puppeteer 提供了开箱即用的预设配置。
安装核心依赖:
```bash
npm install --save-dev puppeteer jest jest-puppeteer @types/puppeteer
```
jest-puppeteer 的配置文件:
```javascript
// jest-puppeteer.config.js
module.exports = {
launch: {
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox'],
},
browserContext: 'incognito',
exitOnPageError: true,
};
```
Jest 配置文件:
```javascript
// jest.config.js
module.exports = {
preset: 'jest-puppeteer',
testMatch: ['**/e2e/**/*.test.js'],
setupFilesAfterEnv: ['./e2e/setup.js'],
testTimeout: 30000,
};
```
setup 文件中处理每个测试的前置条件:
```javascript
// e2e/setup.js
beforeEach(async () => {
await page.setViewport({ width: 1280, height: 720 });
await page.goto('http://localhost:3000', { waitUntil: 'networkidle0' });
});
```
编写一个完整的登录 E2E 测试:
```javascript
// e2e/auth.test.js
describe('用户登录流程', () => {
test('使用正确的凭据登录成功', async () => {
await page.type('[data-testid="username"]', 'testuser');
await page.type('[data-testid="password"]', 'password123');
await page.click('[data-testid="login-btn"]');
await page.waitForSelector('[data-testid="dashboard"]');
const welcome = await page.$eval(
'[data-testid="welcome-msg"]',
el => el.textContent
);
expect(welcome).toContain('欢迎回来');
});
test('使用错误密码登录失败', async () => {
await page.type('[data-testid="username"]', 'testuser');
await page.type('[data-testid="password"]', 'wrongpassword');
await page.click('[data-testid="login-btn"]');
await page.waitForSelector('[data-testid="error-msg"]');
const error = await page.$eval(
'[data-testid="error-msg"]',
el => el.textContent
);
expect(error).toContain('用户名或密码错误');
});
});
```
注意这里使用 `data-testid` 选择器而非 CSS 类名或 ID,这能让测试不依赖 UI 样式变更,提升稳定性。
## 与 Mocha 集成
Mocha 的灵活性更高,适合需要自定义测试生命周期的团队。Puppeteer 的浏览器生命周期需要手动管理。
```javascript
// test/setup.js
const puppeteer = require('puppeteer');
const { expect } = require('chai');
let browser;
before(async () => {
browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox'],
});
});
after(async () => {
await browser.close();
});
beforeEach(async function () {
this.page = await browser.newPage();
await this.page.goto('http://localhost:3000');
});
afterEach(async function () {
await this.page.close();
});
```
Mocha 测试用例:
```javascript
// test/user.spec.js
describe('用户注册功能', function () {
this.timeout(15000);
it('填写完整信息后注册成功', async function () {
const { page } = this;
await page.click('[data-testid="register-link"]');
await page.type('[data-testid="reg-username"]', 'newuser');
await page.type('[data-testid="reg-email"]', 'new@example.com');
await page.type('[data-testid="reg-password"]', 'StrongP@ss1');
await page.click('[data-testid="reg-submit"]');
await page.waitForSelector('[data-testid="reg-success"]');
const text = await page.$eval(
'[data-testid="reg-success"]',
el => el.textContent
);
expect(text).to.include('注册成功');
});
});
```
Mocha 的 `this.timeout()` 需要显式设置,Puppeteer 测试通常需要 10-30 秒的超时时间,不要使用箭头函数,否则无法访问 Mocha 的 `this` 上下文。
## Page Object 模式
无论使用 Jest 还是 Mocha,当测试用例超过 20 个时,必须引入 Page Object 模式。它把页面元素定位和操作封装成独立类,测试用例只关心业务逻辑。
```javascript
// pages/LoginPage.js
class LoginPage {
constructor(page) {
this.page = page;
this.usernameInput = '[data-testid="username"]';
this.passwordInput = '[data-testid="password"]';
this.submitBtn = '[data-testid="login-btn"]';
this.errorMessage = '[data-testid="error-msg"]';
}
async login(username, password) {
await this.page.type(this.usernameInput, username);
await this.page.type(this.passwordInput, password);
await this.page.click(this.submitBtn);
}
async getErrorMessage() {
await this.page.waitForSelector(this.errorMessage);
return this.page.$eval(this.errorMessage, el => el.textContent);
}
}
module.exports = LoginPage;
```
测试用例中使用 Page Object:
```javascript
const LoginPage = require('../pages/LoginPage');
test('登录失败显示错误提示', async () => {
const loginPage = new LoginPage(page);
await loginPage.login('testuser', 'wrongpass');
const error = await loginPage.getErrorMessage();
expect(error).toContain('用户名或密码错误');
});
```
如果 UI 改了 `data-testid` 的值,只需修改 LoginPage 一处,所有引用它的测试自动更新。这就是 Page Object 的核心价值。
## 视觉回归测试
视觉回归测试能捕获 CSS 改动导致的 UI 偏移,Puppeteer 结合 Percy 或 jest-image-snapshot 可以自动完成截图对比。
使用 jest-image-snapshot 的方式:
```bash
npm install --save-dev jest-image-snapshot
```
```javascript
const { toMatchImageSnapshot } = require('jest-image-snapshot');
expect.extend({ toMatchImageSnapshot });
test('首页视觉一致性', async () => {
await page.goto('http://localhost:3000');
await page.waitForSelector('#main-content');
const screenshot = await page.screenshot({ fullPage: true });
expect(screenshot).toMatchImageSnapshot({
failureThreshold: 0.03,
failureThresholdType: 'percent',
});
});
```
`failureThreshold` 设为 3% 是比较合理的起点,太严格会导致大量误报,太宽松又漏掉真正的 UI 变化。首次运行会生成基准截图,后续运行自动对比。
## 性能测试
Puppeteer 可以通过 Chrome DevTools Protocol 采集性能指标,结合 Lighthouse 做更全面的审计。
```javascript
const puppeteer = require('puppeteer');
test('首页核心性能指标', async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('http://localhost:3000', { waitUntil: 'networkidle0' });
// 采集 Core Web Vitals
const metrics = await page.evaluate(() => {
return new Promise(resolve => {
new PerformanceObserver(list => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
resolve({
LCP: lastEntry.startTime,
FID: 0,
CLS: 0,
});
}).observe({ type: 'largest-contentful-paint', buffered: true });
});
});
expect(metrics.LCP).toBeLessThan(2500);
await browser.close();
});
```
性能测试中 `waitUntil: 'networkidle0'` 很关键,确保页面资源加载完成后再采集数据。
## CI/CD 集成
CI/CD 是 Puppeteer 测试从本地走向生产的关键环节。主要解决三个问题:浏览器安装、无头模式运行、测试稳定性。
GitHub Actions 配置:
```yaml
name: E2E Tests
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
container:
image: node:18-slim
steps:
- uses: actions/checkout@v4
- name: Install Chrome dependencies
run: |
apt-get update
apt-get install -y chromium libx11-xcb1 libxcomposite1 libxdamage1 libxi6 libxtst6 libnss3 libatk1.0-0
- name: Install dependencies
run: npm ci
- name: Run E2E tests
run: npm run test:e2e
env:
CI: true
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
```
Docker 配置:
```dockerfile
FROM node:18-slim
RUN apt-get update && apt-get install -y \
chromium \
--no-install-recommends && \
rm -rf /var/lib/apt/lists/*
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["npm", "run", "test:e2e"]
```
Docker 环境下必须设置 `PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true`,使用系统安装的 Chromium,避免 Puppeteer 自带浏览器在容器中启动失败。`--no-sandbox` 参数在 Docker 中也是必需的,因为容器默认以 root 运行,Chrome 要求沙箱模式下不能是 root。
## 测试稳定性实践
Puppeteer 测试在 CI 环境中失败率较高,以下是提升稳定性的关键手段。
**等待策略**:永远不要使用 `waitForTimeout`,改用显式等待。
```javascript
// 错误做法:硬编码等待
await page.waitForTimeout(3000);
// 正确做法:等待元素可见
await page.waitForSelector('[data-testid="result"]', { visible: true });
// 等待网络空闲
await page.waitForNavigation({ waitUntil: 'networkidle2' });
// 等待特定请求完成
await page.waitForResponse(
resp => resp.url().includes('/api/user') && resp.status() === 200
);
```
**测试隔离**:每个测试用例使用独立的浏览器上下文,避免 Cookie 和 Storage 污染。
```javascript
beforeEach(async () => {
context = await browser.createIncognitoBrowserContext();
page = await context.newPage();
await page.goto('http://localhost:3000');
});
afterEach(async () => {
await context.close();
});
```
**失败截图**:测试失败时自动保存截图,方便排查 CI 中的问题。
```javascript
afterEach(async function () {
if (this.currentTest.state === 'failed') {
const timestamp = Date.now();
const testName = this.currentTest.title.replace(/\s+/g, '_');
await page.screenshot({
path: `screenshots/${testName}_${timestamp}.png`,
fullPage: true,
});
}
});
```
**重试机制**:CI 环境中网络和资源加载不稳定,给 E2E 测试加一层重试。
```javascript
// jest.config.js
module.exports = {
preset: 'jest-puppeteer',
retryTimes: 2,
};
```
## 测试分层与并行执行
当测试规模增长后,需要按速度和稳定性分层运行,并利用并行加速。
```javascript
// jest.config.js
module.exports = {
preset: 'jest-puppeteer',
projects: [
{
displayName: 'smoke',
testMatch: ['**/e2e/smoke/**/*.test.js'],
retryTimes: 1,
},
{
displayName: 'critical',
testMatch: ['**/e2e/critical/**/*.test.js'],
retryTimes: 2,
},
{
displayName: 'full',
testMatch: ['**/e2e/full/**/*.test.js'],
retryTimes: 2,
maxWorkers: 4,
},
],
};
```
smoke 测试只覆盖核心路径(登录、关键业务流程),在每次提交时运行;critical 测试覆盖主要功能,在 PR 合并时运行;full 测试覆盖所有场景,在每日构建时运行。
并行执行时注意 `maxWorkers` 不要超过 CPU 核心数,每个 worker 会启动一个浏览器实例,过度并行反而会因资源争抢导致测试超时。
## 从 Puppeteer 迁移到 Playwright 的考虑
如果团队需要跨浏览器测试(Firefox、Safari),或者对自动等待、网络拦截有更高要求,Playwright 是更合适的选择。Playwright 由微软维护,API 设计参考了 Puppeteer 并做了大量改进。
迁移路径:Puppeteer 的 `page` 对象与 Playwright 的 `page` 对象 API 相似但不完全兼容。最稳妥的方式是先保留 Puppeteer 的集成测试,新测试用 Playwright 编写,逐步替换。不要一次性迁移,风险太大。
如果你的项目只需要 Chrome 测试,Puppeteer 仍然是最轻量的选择。