一、开篇:为什么要聊浏览器上下文管理的坑
很多做自动化测试、爬虫的朋友,从Selenium转到Playwright的时候,最先注意到的都是Playwright的速度快、API简洁,或是自动等待的好用。但大家往往会忽略一个核心的差异点——浏览器上下文的管理逻辑。这个点看着小,一旦踩坑,轻则脚本跑不通,重则数据串了、结果错了,甚至整个测试环境都乱了。我自己就见过团队里有人因为没搞懂上下文,把A测试用例的登录状态带到了B用例里,跑了一周的回归测试,结果全是无效结果,最后只能推翻重来。今天就把迁移过程中那些容易被忽略的上下文坑点,结合实际代码和场景讲透,帮大家少走弯路。
二、先搞懂:什么是浏览器上下文(用大白话讲)
在聊坑点之前,得先把浏览器上下文的概念讲清楚,不然大家容易和“窗口”“标签页”搞混。用生活化的例子说,浏览器上下文就像你电脑上开的两个独立的Chrome:一个是你平时用的(存了淘宝、微信的登录状态,有自己的Cookie),另一个是你给朋友临时用的(什么登录状态都没有,Cookie、缓存全是空的)。这两个浏览器是完全隔离的,一个里面的操作不会影响另一个。
对应到自动化工具里,Selenium里的“上下文”概念很弱,它的核心操作都是围绕“WebDriver实例”(也就是一个浏览器进程)展开的,不同的标签页、窗口都共享这个实例的所有状态。而Playwright把“浏览器上下文”(BrowserContext)提升到了核心位置,相当于给每个隔离的场景开了一个独立的“浏览器副本”,这也是Playwright能实现多场景隔离、速度更快的核心原因之一。
三、迁移时最容易踩的5个上下文坑点
3.1 坑点一:默认上下文的“全局污染”
很多人刚转Playwright,写的第一行代码就是直接启动浏览器,然后用默认的上下文操作,以为和Selenium一样,一个实例用到底就行。但问题是,Playwright的默认上下文是全局共享的,所有操作都会共用这个上下文的状态,很容易出现不同用例之间的状态污染。
举个例子,你写了两个测试用例:第一个测试用户A登录,第二个测试用户B登录。如果都用默认上下文,第一个用例登录后的Cookie会留在默认上下文里,第二个用例打开登录页的时候,可能直接就跳转到A的主页了,测试结果自然就错了。
这里给大家看错误的代码示例(技术栈:JavaScript):
const { chromium } = require('playwright');
// 错误写法:所有用例共用默认上下文
async function testUserA() {
const browser = await chromium.launch();
const page = await browser.newPage(); // 这里的page属于默认上下文
await page.goto('https://test-login.com');
await page.getByRole('textbox', { name: '用户名' }).fill('userA');
await page.getByRole('textbox', { name: '密码' }).fill('passA');
await page.getByRole('button', { name: '登录' }).click();
// 登录成功,默认上下文里存了userA的Cookie
await browser.close();
}
async function testUserB() {
const browser = await chromium.launch();
const page = await browser.newPage(); // 这里还是默认上下文,会继承之前的Cookie
await page.goto('https://test-login.com');
// 预期是登录页,结果直接跳转到userA的主页,测试失败
await browser.close();
}
正确的做法是,每个独立的场景(比如每个测试用例)都创建一个独立的上下文,用完就关,从根源上避免污染。修改后的代码:
const { chromium } = require('playwright');
// 正确写法:每个用例创建独立上下文
async function testUserA() {
const browser = await chromium.launch();
// 创建独立的上下文,隔离所有状态
const context = await browser.newContext();
const page = await context.newPage(); // page属于这个独立上下文
await page.goto('https://test-login.com');
await page.getByRole('textbox', { name: '用户名' }).fill('userA');
await page.getByRole('textbox', { name: '密码' }).fill('passA');
await page.getByRole('button', { name: '登录' }).click();
// 用完关闭上下文,清理所有状态
await context.close();
await browser.close();
}
async function testUserB() {
const browser = await chromium.launch();
const context = await browser.newContext(); // 全新的上下文,无任何残留
const page = await context.newPage();
await page.goto('https://test-login.com');
// 现在能正常进入登录页,测试正常
await context.close();
await browser.close();
}
3.2 坑点二:上下文关闭的“隐形坑”
很多人会觉得,浏览器关了,上下文自然就关了,状态也会清理。但实际场景中,我们经常会遇到浏览器没关,上下文没关的情况,比如脚本报错中断,或是测试用例中途退出,上下文就会残留。更坑的是,Playwright的上下文如果是用launchPersistentContext(持久化上下文)创建的,关闭的时候不会自动清理缓存,下次启动还会加载之前的状态。
举个爬虫的场景,你爬一个需要登录的网站,用持久化上下文存了登录状态,下次启动的时候想爬别的账号,结果还是用的上次的登录状态,爬了半天数据全是错的。
这里给大家看持久化上下文的错误用法和正确清理方式(技术栈:JavaScript):
const { chromium } = require('playwright');
const path = require('path');
// 错误写法:持久化上下文用完没清理,残留状态
async function crawlUserA() {
// 创建持久化上下文,状态存在指定目录
const userDataDir = path.join(__dirname, 'user-data');
const context = await chromium.launchPersistentContext(userDataDir, {
headless: false
});
const page = await context.newPage();
await page.goto('https://test-crawl.com');
await page.getByRole('textbox', { name: '用户名' }).fill('userA');
await page.getByRole('textbox', { name: '密码' }).fill('passA');
await page.getByRole('button', { name: '登录' }).click();
// 脚本报错,上下文没关,状态残留
throw new Error('爬虫中断');
}
// 正确写法:用完及时关闭上下文,非持久化上下文自动清理
async function crawlUserA() {
const userDataDir = path.join(__dirname, 'user-data');
let context;
try {
context = await chromium.launchPersistentContext(userDataDir, {
headless: false
});
const page = await context.newPage();
await page.goto('https://test-crawl.com');
await page.getByRole('textbox', { name: '用户名' }).fill('userA');
await page.getByRole('textbox', { name: '密码' }).fill('passA');
await page.getByRole('button', { name: '登录' }).click();
// 爬取逻辑
} catch (err) {
console.error('爬虫出错:', err);
} finally {
// 不管成功失败,都关闭上下文
if (context) await context.close();
// 如果是临时场景,直接删除用户数据目录,彻底清理
require('fs').rmSync(userDataDir, { recursive: true, force: true });
}
}
3.3 坑点三:多标签页的“上下文归属”
很多人以为,一个上下文里的所有标签页,操作都是一样的,其实不然。Playwright的每个标签页(Page)都属于某个上下文,但不同的标签页可以有不同的操作,甚至可以通过page.context拿到自己所属的上下文。但迁移的时候,很多人会把Selenium里的“标签页切换”逻辑直接搬过来,忽略了标签页的上下文归属,导致操作错了标签页。
举个场景,你点击一个按钮,弹出了一个新的标签页,要在新标签页里操作。如果直接用browser.newPage()创建新的标签页,那这个新标签页属于默认上下文,而弹出的标签页属于原来的上下文,两者不互通,操作自然失败。
正确的做法是,用context.newPage()创建新标签页,或者监听上下文的page事件,拿到弹出的标签页(技术栈:JavaScript):
const { chromium } = require('playwright');
async function testPopup() {
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://test-popup.com');
// 点击按钮,弹出新标签页
const [popup] = await Promise.all([
context.waitForEvent('page'), // 监听上下文的新标签页事件
page.getByRole('button', { name: '打开新窗口' }).click()
]);
// 拿到弹出的标签页,属于同一个上下文,操作正常
await popup.getByRole('textbox', { name: '输入内容' }).fill('test');
await context.close();
await browser.close();
}
3.4 坑点四:Cookie和缓存的“上下文绑定”
Selenium里的Cookie是绑定到WebDriver实例的,只要实例不关闭,Cookie就存在,不同的窗口、标签页都能共享。但Playwright的Cookie、本地存储、缓存都是绑定到上下文的,每个上下文有自己独立的存储,不同上下文之间完全隔离。
迁移的时候,很多人会把Selenium里的“设置全局Cookie”逻辑直接搬过来,以为设置一次,所有操作都能用。但实际场景中,如果你在上下文A里设置了Cookie,上下文B里是拿不到的,操作就会失败。
举个登录后爬取的场景,你先登录拿到Cookie,然后想在新的上下文里用这个Cookie爬取数据。如果直接把Cookie赋值给新的上下文,格式不对,就会失败。这里给大家看正确的Cookie传递方式(技术栈:JavaScript):
const { chromium } = require('playwright');
async function getCookies() {
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://test-login.com');
await page.getByRole('textbox', { name: '用户名' }).fill('userA');
await page.getByRole('textbox', { name: '密码' }).fill('passA');
await page.getByRole('button', { name: '登录' }).click();
// 拿到当前上下文的Cookie,格式是Playwright要求的数组
const cookies = await context.cookies();
await context.close();
await browser.close();
return cookies;
}
async function crawlWithCookies(cookies) {
const browser = await chromium.launch();
// 创建新的上下文,把拿到的Cookie设置进去
const context = await browser.newContext({
extraHTTPHeaders: {
'Cookie': cookies.map(c => `${c.name}=${c.value}`).join('; ')
}
});
const page = await context.newPage();
await page.goto('https://test-crawl.com');
// 现在能正常登录,爬取数据
await context.close();
await browser.close();
}
// 先拿Cookie,再用Cookie爬取
async function main() {
const cookies = await getCookies();
await crawlWithCookies(cookies);
}
main();
3.5 坑点五:无头模式下的“上下文默认配置”
很多人在测试的时候,为了速度快,会开无头模式。但Playwright的无头模式下,上下文的默认配置和有头模式不一样,比如默认的视口大小、是否允许弹窗、是否开启Cookie等,这些配置如果没改,很容易导致脚本在有头模式下能跑,无头模式下就失败。
举个场景,你有个脚本在有头模式下能正常点击弹窗里的按钮,无头模式下却失败了,原因就是无头模式下默认禁止弹窗,上下文的配置里没开弹窗权限。
正确的做法是,在创建上下文的时候,把需要的配置都明确设置,不要依赖默认配置(技术栈:JavaScript):
const { chromium } = require('playwright');
async function testHeadless() {
const browser = await chromium.launch({
headless: true // 开启无头模式
});
// 创建上下文的时候,明确设置配置,不要依赖默认
const context = await browser.newContext({
viewport: { width: 1280, height: 720 }, // 明确设置视口大小
acceptDownloads: true, // 允许下载
bypassCSP: true, // 绕过内容安全策略
extraHTTPHeaders: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' // 明确设置UA
}
});
const page = await context.newPage();
await page.goto('https://test-headless.com');
// 现在无头模式下也能正常操作
await context.close();
await browser.close();
}
四、迁移后的上下文管理最佳实践
4.1 应用场景
上下文管理的最佳实践,主要适用于以下场景:
- 自动化测试:多用户测试、多场景测试,需要隔离不同用例的状态;
- 爬虫:多账号爬取、多场景爬取,需要隔离不同账号的登录状态;
- 网页自动化:批量操作、多任务处理,需要隔离不同任务的状态。
4.2 技术优缺点
- 优点:
- 隔离性强:不同上下文之间完全隔离,避免状态污染;
- 资源复用:同一个浏览器实例可以创建多个上下文,比启动多个浏览器实例速度快、占用资源少;
- 灵活性高:可以根据不同场景配置不同的上下文,比如不同的UA、不同的Cookie、不同的视口大小。
- 缺点:
- 学习成本:需要理解上下文的概念,和Selenium的管理逻辑不同,迁移初期容易踩坑;
- 配置复杂:需要根据不同场景配置不同的上下文,配置不当容易出现问题。
4.3 注意事项
- 每个独立场景创建独立的上下文,用完及时关闭;
- 持久化上下文用完及时清理,避免残留状态;
- 明确设置上下文的配置,不要依赖默认配置;
- 多标签页操作的时候,注意标签页的上下文归属;
- Cookie、缓存传递的时候,注意格式是否符合Playwright的要求。
五、文章总结
从Selenium迁移到Playwright,浏览器上下文管理是一个容易被忽略但又非常重要的点。很多人迁移初期遇到的“脚本跑不通”“结果错误”“状态污染”等问题,大多和上下文管理不当有关。只要理解了上下文的概念,掌握了迁移过程中的坑点,并且遵循最佳实践,就能顺利完成迁移,充分发挥Playwright的优势。
Comments