Playwright
目录
- 安装 & 两种模式
- 核心三层架构
- 导航 & 等待策略
- 元素定位 & 操作
- 网络拦截 page.route()
- evaluate & 脚本注入
- 截图 & 文件操作
- 多浏览器 & 设备模拟
- NestJS 集成方案
- 项目结构点评
- 调试技巧
- 常见错误 & 解法
1. 安装 & 两种模式
两种用法的本质区别
| 库模式 | 测试框架模式 | |
|---|---|---|
| 包名 | playwright | @playwright/test |
| 用途 | 生产服务、爬虫、自动化 | E2E 测试、CI/CD |
| 浏览器管理 | 你自己控制生命周期 | 框架自动管理 |
| 有没有 HTTP 服务 | 自己决定 | 没有 |
结论:做服务用 playwright,做测试用 @playwright/test,不要混装。
# 库模式(生产服务)
npm install playwright
npx playwright install chromium # 浏览器二进制单独下载
# 测试模式(E2E 测试)
npm install -D @playwright/test
npx playwright install为什么浏览器要单独安装?
Chromium 二进制文件体积约 200MB+,不打进 npm 包,需要playwright install单独下载。
Docker 部署时容易忘,建议加到postinstall钩子:
// package.json
{
"scripts": {
"postinstall": "npx playwright install chromium"
}
}2. 核心三层架构
Browser(进程级别,重)
└── BrowserContext(会话隔离单元,轻)
└── Page(一个 Tab)三层各自的职责
| 层级 | 类比 | 隔离了什么 | 生命周期 |
|---|---|---|---|
Browser | Chrome 程序本身 | 无 | 服务启动→关闭 |
BrowserContext | 一个浏览器用户 Profile | Cookie / localStorage / IndexedDB / 缓存 / Service Worker | 每个任务/用户 |
Page | 一个 Tab | 页面 DOM / URL | 任务内 |
为什么这样分层
Browser 启动成本高(~300ms,几十 MB 内存),不能每次任务都重建。BrowserContext 是轻量隔离层,并发任务互不污染,创建成本极低。
多实例共享一个 Browser = 节约资源 + 天然隔离 = BrowserPool 的设计基础。
import { chromium } from 'playwright';
// ✅ 正确模式:复用 Browser,每次新建 Context
const browser = await chromium.launch({
headless: true,
args: [
'--no-sandbox', // Docker 容器必须加,否则报错
'--disable-dev-shm-usage', // 低内存环境防止 /dev/shm 不够用
'--disable-blink-features=AutomationControlled', // 隐藏自动化标志,防反爬检测
],
});
const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)...',
viewport: { width: 390, height: 844 },
locale: 'zh-CN',
timezoneId: 'Asia/Shanghai',
});
const page = await context.newPage();
await page.goto('https://example.com');
// 关闭顺序:page → context → browser
// 关闭 context 会自动关闭其下所有 page,通常不需要单独关 page
await context.close();
// browser 在服务退出时才关闭3. 导航 & 等待策略
goto 的 waitUntil 选项(从快到慢)
// commit → 收到响应头就返回(最快,适合不需要等内容的场景)
// domcontentloaded → HTML 解析完成(适合服务端渲染页面)
// load → 所有资源加载完(默认值)
// networkidle → 500ms 内无新请求(最慢,适合 SPA)
// 传统 HTML 页面
await page.goto(url, { waitUntil: 'load', timeout: 15_000 });
// Vue / React 等 SPA 应用(HTML 只是空壳,内容靠 JS 异步渲染)
await page.goto(url, { waitUntil: 'networkidle', timeout: 20_000 });为什么 SPA 要用
networkidle?load事件触发时 JS 刚开始执行,DOM 里什么都没有。networkidle等所有异步请求完成,页面数据才真正渲染出来。
四种等待方式
// 1. waitForSelector — 等元素出现(最常用)
await page.waitForSelector('#submit-btn', {
state: 'visible', // visible(可见)/ attached(在 DOM 里)/ hidden / detached
timeout: 10_000,
});
// 2. waitForResponse — 等接口返回(比 sleep 精确,强烈推荐)
// ⚠️ 必须和触发操作放在 Promise.all 里!
const [response] = await Promise.all([
page.waitForResponse(res =>
res.url().includes('/api/user') && res.status() === 200
),
page.click('#load-btn'), // 触发请求的操作
]);
const data = await response.json();
// 3. waitForNavigation — 等页面跳转完成
// ⚠️ 同样需要 Promise.all
await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle' }),
page.click('#login-btn'),
]);
// 4. waitForFunction — 等自定义条件(最灵活)
await page.waitForFunction(
() => window.__APP_LOADED__ === true,
{ timeout: 15_000 }
);为什么
waitForResponse必须放Promise.all里?
如果先click再waitForResponse,响应可能在waitForResponse注册监听器之前就已经到达,导致永远等不到。Promise.all保证监听器先注册,点击后触发,时序正确。
超时三层防护
// 层级 1:Context 全局默认(所有操作的 fallback)
context.setDefaultTimeout(10_000);
context.setDefaultNavigationTimeout(20_000);
// 层级 2:单次操作覆盖全局设置
await page.click('#btn', { timeout: 5_000 });
// 层级 3:整体任务超时(业务层控制,在 NestJS service 里)
await Promise.race([
runTask(page),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Task timeout')), 60_000)
),
]);4. 元素定位 & 操作
选择器稳定性(从高到低)
// ✅ 最稳:语义选择器(不受样式/结构变化影响)
page.getByRole('button', { name: '提交' })
page.getByLabel('手机号')
page.getByPlaceholder('请输入手机号')
page.getByText('登录')
// ✅ 稳定:id 和 data-testid
page.locator('#submit-btn')
page.getByTestId('phone-input') // 对应 data-testid="phone-input"
// ⚠️ 一般:class(可能被打包工具随机化为 a1b2c3)
page.locator('.submit-button')
// ❌ 脆弱:DOM 路径(HTML 改一点就崩)
page.locator('div.form > div:nth-child(3) > input')为什么优先语义选择器?
Webpack/Vite 构建后 class 名可能变成随机哈希。但aria-role、label、placeholder是面向用户的语义,基本不变。选择器越贴近用户感知,代码越稳定。
常用操作 API
// 填写输入框
await page.fill('#phone', '13800138000'); // 清空后填写(推荐)
await page.locator('#phone').type('138', { delay: 80 }); // 模拟逐字输入(反检测场景)
await page.locator('#phone').clear();
// 点击
await page.click('#submit');
await page.click('#menu', { button: 'right' }); // 右键
await page.dblclick('#item'); // 双击
// 键盘
await page.locator('#phone').press('Enter');
await page.keyboard.press('Escape');
// 下拉框
await page.selectOption('#city', 'shanghai');
await page.selectOption('#tags', ['a', 'b']); // 多选
// 复选框
await page.check('#agree');
await page.uncheck('#subscribe');
await page.locator('#agree').isChecked(); // 读取状态
// 读取值
const text = await page.textContent('#result');
const val = await page.inputValue('#phone');
const attr = await page.getAttribute('img', 'src');
const html = await page.innerHTML('#container');Locator 链式过滤(多元素精确定位)
// 过滤包含特定文字的行,再找其中的按钮
const deleteBtn = page
.locator('tr')
.filter({ hasText: '张三' })
.locator('button.delete');
await deleteBtn.click();
// nth:取第 N 个(0-based)
await page.locator('.product-card').nth(2).click();
// 遍历所有同类元素
const cards = page.locator('.product-card');
const count = await cards.count();
for (let i = 0; i < count; i++) {
const name = await cards.nth(i).textContent();
console.log(name);
}5. 网络拦截 page.route()
page.route() 是 Playwright 最强的 API,可以完全控制浏览器发出的所有 HTTP 请求。
三种处理结果
// 1. abort — 直接阻断请求(屏蔽无用资源,减少内存占用 ~40%)
await page.route('**/*.{png,jpg,gif,webp,svg,woff,woff2}', route => route.abort());
// 2. continue — 修改请求后放行
await page.route('**/api/**', async route => {
await route.continue({
headers: {
...route.request().headers(), // 保留原有 headers
'X-Token': myToken, // 注入认证 token
'X-App-Version': '5.2.1',
},
postData: JSON.stringify(modifiedBody), // 修改请求体
});
});
// 3. fulfill — 直接返回 mock 数据(不发真实请求)
await page.route('**/config.json', route =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ env: 'test', feature: true }),
})
);为什么用
route而不是setExtraHTTPHeaders?setExtraHTTPHeaders对所有请求全局生效,无法按 URL 区分。route可以针对不同接口注入不同 header,甚至完全替换请求体,灵活度远高于全局 header。
MITM 模式(拦截 + 读取响应)
// 让请求正常发出,同时在 Node 侧拿到响应数据
await page.route('**/order/list', async route => {
// 先让请求真正发出去
const response = await route.fetch();
// 在 Node 侧读取响应体(可以解密、分析)
const body = await response.json();
console.log('接口响应明文:', body);
// 把原始响应还给页面,页面感知不到任何拦截
await route.fulfill({ response });
});MITM 模式的价值:
页面行为完全正常,但你在 Node 侧已经截获了所有响应数据。
适合分析加密协议、记录接口数据,不需要侵入页面 JS。
监听 WebSocket 帧
// route() 只拦截 HTTP,WebSocket 需要用 on('websocket')
page.on('websocket', ws => {
console.log('WS 连接建立:', ws.url());
ws.on('framesent', frame => console.log('→ 发送:', frame.payload));
ws.on('framereceived', frame => console.log('← 接收:', frame.payload));
ws.on('close', () => console.log('WS 关闭'));
});取消路由监听
// 某个路由只需要拦截一次
const handler = async (route: Route) => {
// 处理完后取消监听
await page.unroute('**/api/**', handler);
await route.continue();
};
await page.route('**/api/**', handler);6. evaluate & 脚本注入
两个世界之间的边界
Node.js 进程 浏览器进程
───────────── ─────────────
你的 NestJS 代码 ←→ 页面 JS / window / DOM
通过 WebSocket 通信(JSON 序列化)addInitScript — 在页面 JS 之前注入
// 在每个 frame 创建时、任何页面脚本执行之前注入
// 可以 hook 原生函数(覆盖 Date、fetch 等)
// 方式 1:注入函数(可传参数)
await page.addInitScript(
({ aesKey, appId }) => {
window._aesKey = aesKey; // 挂载到 window,页面 JS 可以读到
window._appId = appId;
},
{ aesKey: process.env.AES_KEY, appId: 'myapp' }
);
// 方式 2:注入外部 JS 文件(推荐,文件有 IDE 支持)
await page.addInitScript({ path: './inject/crypto.js' });
// 方式 3:注入字符串
import { readFileSync } from 'fs';
const script = readFileSync('./inject/crypto.js', 'utf-8');
await page.addInitScript(script);为什么 inject/ 目录下的文件用
.js不用.ts?
注入脚本运行在浏览器里,不经过 TypeScript 编译。
写.ts会让你误以为有类型保护,但实际运行的是编译后的.js,容易造成路径和语法的混乱。直接写.js更诚实。
evaluate — 在浏览器上下文执行
// ✅ 正确:只传 JSON-safe 的数据
const result = await page.evaluate(
({ url, token, body }) => {
// 这里是浏览器世界,可以访问 window / document / DOM
return window._axios.post(url, body, {
headers: { Authorization: token }
});
},
{ url: '/api/order', token: myToken, body: requestBody }
// ^^^^
// 第二个参数通过 JSON 序列化传给函数
);
// ❌ 错误:不能传函数、Buffer、Class 实例
await page.evaluate(fn => fn(), myFunction); // 报错
await page.evaluate(buf => buf, Buffer.from('x')); // 报错为什么只能传 JSON-safe 数据?
Node 进程和浏览器进程之间通过 WebSocket 通信,数据必须序列化为 JSON。函数和 Buffer 无法 JSON 化,所以无法跨越这道边界。
evaluate vs evaluateHandle
// evaluate:返回序列化后的值(适合拿数据)
const token = await page.evaluate(
() => localStorage.getItem('token')
); // 在 Node 侧是普通 string,可以直接用
// evaluateHandle:返回 JSHandle(适合操作 DOM 对象引用)
const handle = await page.evaluateHandle(
() => document.querySelector('#app')
);
// handle 是浏览器里 DOM 的引用,不是实际值
// 需要再次 evaluate 才能读属性
const text = await handle.evaluate(el => el.textContent);exposeFunction — 从浏览器调用 Node 函数
// 把 Node 侧的函数暴露给浏览器(反向调用)
await page.exposeFunction('nodeDecrypt', async (ciphertext: string) => {
// 这里运行在 Node 侧,可以用 Node 原生 crypto、读文件、访问 DB
return nodeCryptoLib.decrypt(ciphertext);
});
// 浏览器 JS 里就可以调用:
// const plain = await window.nodeDecrypt(encrypted);
exposeFunction的使用场景:
当浏览器内的解密逻辑需要依赖 Node 原生模块(如node:crypto的特定算法),或需要访问 Node 侧的文件系统、数据库时使用。
它打通了 浏览器 → Node 的调用链,与evaluate的 Node → 浏览器 方向相反。
7. 截图 & 文件操作
截图
// 全页面截图(包含滚动区域)
await page.screenshot({
path: './output/full.png',
fullPage: true,
type: 'png',
});
// 截取特定元素(验证码场景)
const captcha = page.locator('#captcha-img');
const buffer = await captcha.screenshot();
// buffer 是 Node Buffer,可以直接传给 OCR 库(如 ddddocr)
// 转 base64(用于传 API)
const base64 = buffer.toString('base64');生成 PDF
// 只支持 Chromium headless 模式
await page.goto('https://example.com/report', { waitUntil: 'networkidle' });
await page.waitForSelector('.data-loaded'); // 等数据渲染完再截
const pdfBuffer = await page.pdf({
format: 'A4',
printBackground: true, // 打印背景色和背景图片
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' },
});
fs.writeFileSync('./output/report.pdf', pdfBuffer);文件下载
// ⚠️ 和 waitForResponse 一样,必须放 Promise.all
const [download] = await Promise.all([
page.waitForEvent('download'), // 先注册监听
page.click('#export-btn'), // 再触发下载
]);
await download.saveAs('./output/data.xlsx');
console.log('文件名:', download.suggestedFilename());
// 不保存文件,直接读 Buffer
const stream = await download.createReadStream();文件上传
// 直接设置 input[type=file] 的值(不需要弹出系统文件对话框)
await page.setInputFiles('#upload', './upload/image.jpg');
// 多文件
await page.setInputFiles('#upload', ['./a.jpg', './b.jpg']);
// 从 Buffer 上传(不需要临时文件)
await page.setInputFiles('#upload', {
name: 'report.png',
mimeType: 'image/png',
buffer: imageBuffer,
});8. 多浏览器 & 设备模拟
三种浏览器切换
import { chromium, firefox, webkit } from 'playwright';
// 同一套代码,只换一行
const browser = await chromium.launch(); // Chrome / Edge 内核
const browser = await firefox.launch(); // Firefox
const browser = await webkit.launch(); // Safari 内核爬虫为什么几乎只用 Chromium?
大多数中国互联网产品只针对 Chrome 测试,加密/签名逻辑有时依赖 V8 特性。用 Firefox 或 WebKit 可能触发降级逻辑或直接报错,Chromium 最保险。
内置设备模拟
import { devices } from 'playwright';
// 内置 100+ 种设备(iPhone、Android、iPad 等)
const iPhone = devices['iPhone 14 Pro'];
const context = await browser.newContext({
...iPhone, // userAgent + viewport + deviceScaleFactor 一次性设置
locale: 'zh-CN',
timezoneId: 'Asia/Shanghai',
geolocation: { latitude: 31.23, longitude: 121.47 },
permissions: ['geolocation'],
});自定义指纹(反检测)
const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)...',
viewport: { width: 390, height: 844 },
deviceScaleFactor: 3, // 视网膜屏 DPR
isMobile: true,
hasTouch: true,
colorScheme: 'dark',
extraHTTPHeaders: {
'Accept-Language': 'zh-CN,zh;q=0.9',
},
});
// 隐藏 webdriver 标志(在 addInitScript 里覆盖原生属性)
await context.addInitScript(() => {
Object.defineProperty(navigator, 'webdriver', {
get: () => undefined, // 让 navigator.webdriver 返回 undefined 而不是 true
});
// 让 plugins 列表不为空(headless 默认为空,是检测指标之一)
Object.defineProperty(navigator, 'plugins', {
get: () => [1, 2, 3],
});
});9. NestJS 集成方案
无 HTTP 服务的正确写法
// main.ts
// createApplicationContext 启动完整 DI 容器,但不监听任何端口
async function bootstrap() {
const app = await NestFactory.createApplicationContext(AppModule);
// 必须加!监听 SIGTERM/SIGINT,触发 OnModuleDestroy 生命周期
// 不加的后果:Kubernetes 发 SIGTERM 时 browser.close() 不会执行
// → Chromium 子进程变孤儿进程 → 反复发版后内存持续增长
app.enableShutdownHooks();
}
bootstrap();BrowserService — 生命周期管理
// browser/browser.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { chromium, Browser, BrowserContext, Page } from 'playwright';
@Injectable()
export class BrowserService implements OnModuleInit, OnModuleDestroy {
private browser: Browser;
// NestJS 模块初始化时自动调用(相当于构造函数后执行)
async onModuleInit(): Promise<void> {
this.browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage'],
});
}
// 收到 SIGTERM/SIGINT 时自动调用(enableShutdownHooks 触发)
async onModuleDestroy(): Promise<void> {
await this.browser?.close();
}
// 核心方法:创建隔离 Context,任务跑完自动销毁
async runInContext<T>(
fn: (page: Page) => Promise<T>
): Promise<T> {
const context = await this.browser.newContext({
locale: 'zh-CN',
userAgent: 'Mozilla/5.0 ...',
});
const page = await context.newPage();
try {
return await fn(page);
} finally {
// 无论成功还是异常,context 必须关闭
// 否则每次调用都会泄漏一个 Context(~30-80MB 内存)
await context.close();
}
}
}为什么用
OnModuleInit而不是构造函数?
NestJS 构造函数必须是同步的,await chromium.launch()是异步操作。OnModuleInit专门为异步初始化设计,是 NestJS 的最佳实践。
BrowserPool — 并发控制
// browser/browser-pool.service.ts
// 当需要限制并发数量时(防止浏览器内存溢出),在 BrowserService 上再加一层
@Injectable()
export class BrowserPoolService implements OnModuleInit, OnModuleDestroy {
private browsers: Browser[] = [];
private queue: Array<() => void> = [];
private activeCount = 0;
private readonly MAX_CONCURRENCY = 5; // 根据服务器内存调整
// 带并发控制的执行方法
async run<T>(fn: (page: Page) => Promise<T>): Promise<T> {
// 超过并发上限时排队等待
if (this.activeCount >= this.MAX_CONCURRENCY) {
await new Promise<void>(resolve => this.queue.push(resolve));
}
this.activeCount++;
const browser = this.pickBrowser();
const context = await browser.newContext();
const page = await context.newPage();
try {
return await fn(page);
} finally {
await context.close();
this.activeCount--;
// 唤醒队列里等待的下一个任务
this.queue.shift()?.();
}
}
private pickBrowser(): Browser {
// 轮询选择浏览器实例(负载均衡)
const idx = this.activeCount % this.browsers.length;
return this.browsers[idx];
}
}路由拆分(routes/ 目录)
// browser/routes/base.route.ts — 通用规则,每次都加载
export async function applyBaseRoutes(page: Page): Promise<void> {
// 屏蔽无用资源,减少内存和带宽消耗
await page.route('**/*.{png,jpg,gif,webp,svg,ico,woff,woff2}', route =>
route.abort()
);
}
// browser/routes/auth.route.ts — 需要认证的接口
export async function applyAuthRoutes(page: Page, token: string): Promise<void> {
await page.route('**/api/**', async route => {
await route.continue({
headers: {
...route.request().headers(),
'Authorization': `Bearer ${token}`,
'X-App-Version': '5.2.1',
},
});
});
}
// 在 task 里组合使用
async execute(page: Page, opts: TaskOpts): Promise<void> {
await applyBaseRoutes(page);
await applyAuthRoutes(page, opts.token);
// 再加 task 专用的路由规则...
}为什么要把路由规则拆到单独文件?
不同的 task 可能需要不同的拦截规则组合,拆分后可以像积木一样自由组合,而不是每个 task 都重复写一遍相同的屏蔽图片代码。
消息队列集成(MQTT / BullMQ)
// tasks/order.task.ts
@Injectable()
export class OrderTask {
constructor(private readonly browserPool: BrowserPoolService) {}
async execute(task: OrderPayload): Promise<OrderResult> {
return this.browserPool.run(async page => {
// 业务逻辑:只关心"做什么",不关心浏览器怎么管
await applyBaseRoutes(page);
await page.goto(TARGET_URL, { waitUntil: 'networkidle' });
await page.fill('#phone', task.phone);
await page.click('#submit');
const [response] = await Promise.all([
page.waitForResponse(r => r.url().includes('/order/submit')),
page.click('#confirm'),
]);
return response.json();
});
}
}10. 项目结构点评
你的当前结构:
browser_test/
├── core/
├── scripts/
├── services/
├── tasks/
├── utils/
├── browser.enum.ts
├── browser.exception.ts
├── browser.interface.ts
└── browser.module.ts✅ 做得好的地方
| 文件/目录 | 评价 |
|---|---|
browser.enum.ts | 单独枚举文件,避免 magic string,赞 |
browser.exception.ts | 自定义异常类,错误类型清晰 |
browser.interface.ts | 接口/类型定义分离,TypeScript 最佳实践 |
browser.module.ts | 模块文件独立,NestJS 标准做法 |
tasks/ 目录 | 业务逻辑与浏览器控制分离,思路正确 |
⚠️ 建议改进的地方
1. core/ 命名太模糊core 放什么?服务?配置?建议根据实际内容重命名:
core/ → browser-pool/ (如果放池管理相关)
→ config/ (如果放配置)
→ base/ (如果放基类)2. scripts/ 和 inject/ 没有区分
爬虫项目常见两类脚本:
- Node 侧执行的脚本(启动、调试用)→ 放项目根的
scripts/ - 注入到浏览器的 JS → 应该叫
inject/,且只放.js文件
建议:
browser_test/
└── inject/ ← 注入到浏览器的 .js 文件
scripts/ ← Node 侧工具脚本(项目根目录层面)3. services/ 太宽泛,建议按职责细分
services/
├── browser.service.ts ← 浏览器生命周期
└── browser-pool.service.ts ← 并发控制4. 缺少 routes/ 目录(如果有路由拦截逻辑)
拦截规则如果全写在 service 或 task 里,复用性差。建议:
routes/
├── base.route.ts ← 通用规则(屏蔽图片等)
├── auth.route.ts ← 认证注入
└── crypto.route.ts ← 加解密拦截建议的完整结构
browser_test/
├── core/ ← 建议重命名为更具体的名字
├── inject/ ← 注入到浏览器的 .js 文件(不经 tsc)
│ ├── crypto.js
│ └── fingerprint.js
├── routes/ ← 路由/拦截规则(可选,看是否有拦截需求)
│ ├── base.route.ts
│ └── auth.route.ts
├── services/
│ ├── browser.service.ts
│ └── browser-pool.service.ts
├── tasks/
│ ├── order.task.ts
│ └── query.task.ts
├── utils/
├── browser.enum.ts
├── browser.exception.ts
├── browser.interface.ts
└── browser.module.ts总体评价:结构思路是对的,主要问题是命名不够精确。核心文件(enum/exception/interface/module)独立出来的做法很好,继续保持。
11. 调试技巧
有头模式(最直观)
// 开发调试时用有头模式
chromium.launch({
headless: false, // 打开浏览器窗口
slowMo: 500, // 每步慢 500ms,便于观察操作
devtools: true, // 自动打开 DevTools
});pause() — 代码级断点
await page.goto(url);
await page.pause(); // 脚本在这里暂停,可以在 Inspector 里手动操作
// 在 Playwright Inspector 里点"继续",脚本才往下走
await page.click('#btn');Trace Viewer — 最强调试工具
// 生产环境出问题时,开启 trace 录制
await context.tracing.start({
screenshots: true, // 每步截图
snapshots: true, // DOM 快照
sources: true, // 关联源代码
});
// 执行业务逻辑...
await context.tracing.stop({ path: 'trace.zip' });# 在本地查看 trace(可以回放整个操作时间线)
npx playwright show-trace trace.zipTrace Viewer 能看什么?
- 完整操作时间线(每步操作的时间戳)
- 每步操作前后的 DOM 快照
- 所有网络请求的 header / body / response
- 控制台输出和 JS 错误
出了线上 bug 不需要重新复现,下载 trace.zip 即可完整回放。
监听浏览器控制台
// 把浏览器的 console.log 输出到 Node 侧(调试注入脚本时很有用)
page.on('console', msg => {
console.log(`[Browser ${msg.type()}]`, msg.text());
});
// 监听页面 JS 报错
page.on('pageerror', error => {
console.error('[Page Error]', error.message);
});
// 监听所有请求(快速查看接口列表)
page.on('request', req => {
if (req.url().includes('/api/')) {
console.log(`→ ${req.method()} ${req.url()}`);
}
});环境变量调试
# 打印所有 Playwright 内部日志
DEBUG=pw:* node dist/main.js
# 只打印 API 调用记录
DEBUG=pw:api node dist/main.js
# 打印浏览器协议通信(底层 CDP)
DEBUG=pw:protocol node dist/main.js12. 常见错误 & 解法
TimeoutError: waiting for selector
Error: Timeout 10000ms exceeded while waiting for selector '#submit'原因排查顺序:
waitUntil用的是load但页面是 SPA → 改成networkidle- 选择器写错了 → 有头模式打开页面,在 DevTools Console 里验证
document.querySelector('#submit') - 元素在 iframe 里 → 需要先
page.frameLocator('#iframe').locator('#submit') - 网络太慢 → 适当加大 timeout
Context already closed
Error: browserContext.newPage: Target page, context or browser has been closed原因: 在 context.close() 之后还在使用这个 context 或其下的 page。
解法: 检查并发代码,确保每个 context 只被一个任务独占使用。
evaluate 报错 "Argument of type ... is not serializable"
Error: Argument of type 'Buffer' is not serializable原因: evaluate 的参数必须是 JSON-safe 的,Buffer 无法序列化。
解法: 将 Buffer 转成 base64 字符串再传:
await page.evaluate(
(base64) => window.processImage(base64),
buffer.toString('base64') // Buffer → string
);并发时数据互相污染
症状: A 任务的数据出现在 B 任务的结果里。
原因: 多个任务共享了同一个 BrowserContext 或 Page。
解法: 确保每个任务都创建独立的 Context,任务结束后立即 context.close()。
Docker 里 Chromium 启动失败
Error: Failed to launch chromium: error while loading shared libraries解法: Dockerfile 里安装系统依赖:
FROM node:20-slim
RUN apt-get update && apt-get install -y \
chromium \
fonts-noto-cjk \
--no-install-recommends && rm -rf /var/lib/apt/lists/*
ENV PLAYWRIGHT_BROWSERS_PATH=/usr/bin或在 launch() 里指定路径:
chromium.launch({
executablePath: '/usr/bin/chromium',
args: ['--no-sandbox', '--disable-dev-shm-usage'],
});