Playwright route 不生效:Service Worker 或时序绕过
现象长什么样
你在用例里写了一段拦截,希望某个接口返回假数据:
await page.route('**/api/fetch_data', route => route.fulfill({
status: 200,
body: testData,
}));
await page.goto('https://example.com');
这段是 docs/src/network.md 里的原样示例。执行用例之后,页面渲染的还是真实数据,handler 里的断点或日志一次都没进去。更迷惑的是同一个仓库里别的用例拦得好好的,只有这一个不行。
这类问题在 Playwright 仓库文档里其实有几处白纸黑字的说明,只是分散在网络指南、Service Worker 指南和 API 文档三个地方。下面把它们按排查顺序串起来。
第一个嫌疑人:请求被 Service Worker 接管了
docs/src/api/class-page.md 在 Page.route 的说明里有一条 note,逐字写明:Page.route 不会拦截被 Service Worker 拦截的请求,并建议在做请求拦截时把 Service Worker 关掉。docs/src/api/class-browsercontext.md 的 BrowserContext.route 下挂着一条内容一致的 note。也就是说,这不是某个语言绑定的特例,是两个入口共同的边界。
docs/src/network.md 里还专门有一节讲这件事,并点名了一个具体场景:如果你用了 Mock Service Worker(MSW)这类工具,它会注册自己的 Service Worker 来接管网络请求,请求因此对 BrowserContext.route 和 Page.route 不可见。文档给出的建议是:如果你既要做网络测试又要做 mock,就用内置的 route 来做响应 mock。
怎么确认是这个原因
有两个可执行的判定动作,都来自 API 文档里写明的字段:
第一,看响应是不是 Service Worker 的 fetch handler 完成的。docs/src/api/class-response.md 写明 Response.fromServiceWorker 返回 boolean,表示这个 Response 是否由 Service Worker 的 Fetch Handler 通过 FetchEvent.respondWith 完成。
第二,看请求本身是谁发的。docs/src/api/class-request.md 写明 Request.serviceWorker 返回 null 或 Worker,标注 langs: js, python,并且在 Details 里写明这个方法只在 Chromium 上有意义——在其它浏览器上调用是安全的,但永远返回 null。同一段还写明:源自 Service Worker 的请求没有 Request.frame 可用。
docs/src/service-workers-js-python.md 把这条边界写得更硬:对一个 Request.serviceWorker 非空的对象调用 Request.frame 或 Response.frame 会抛异常。所以你在事件回调里顺手打印 frame 信息时,看到的可能不是「没进 handler」,而是回调自己炸了。
那份指南里还给了一张事件表,值得对着读一遍:页面注册了一个透明转发的 Service Worker 之后再去取 data.json,会出现两次 BrowserContext.request 事件,一次属于 Frame,一次属于 Service Worker;其中只有 Service Worker 那一次是可以被 BrowserContext.route 路由的,Frame 那一侧因为根本不会有机会打到外网,所以不可路由。你日志里看到「请求明明发生了但没被拦」,很可能看到的就是 Frame 那一条。
仓库文档给出的处置
docs/src/api/params.md 定义了这个上下文选项:serviceWorkers,取值是 "allow" 或 "block" 两个,'allow' 表示允许站点注册 Service Worker,'block' 表示 Playwright 会阻止所有 Service Worker 的注册。文档写明默认是 'allow'——这是仓库当前文档里的默认值,随版本可能变动。
三种用法对应三处不同的写法,别串:
用 Test Runner(JS/TS)时,docs/src/test-api/class-testoptions.md 里 TestOptions.serviceWorkers 挂在 use 下面:
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
serviceWorkers: 'allow'
},
});
这段是仓库文件里的原样代码。这里有一处值得指出来的不一致:docs/src/service-workers-js-python.md 的 JS 小节正文写的是「要禁用 service workers,把 TestOptions.serviceWorkers 设为 'block'」,紧跟其后的代码块里写的却是 serviceWorkers: 'allow'。两处放在一起看,正文与示例对不上,照抄代码块是关不掉的。以正文那句语义为准,你要写的值是 'block'。
用 pytest 插件(Python)时,同一份指南的 Python 小节给的是改 browser_context_args:
import pytest
@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
return {
**browser_context_args,
"service_workers": "block"
}
注意 Python 侧的键名是 service_workers,取值 "block",跟 JS 的驼峰写法不是一个字符串,别照着 JS 那边抄。
用 Library 模式直接建 context 时,serviceWorkers 是 Browser.newContext 的选项之一,两条 route 的 note 里也是这么指的。
处置后怎么验证
关掉之后,原来那两条判定动作应该反过来:Response.fromServiceWorker 不再为 true,Request.serviceWorker(在 Chromium 上)为 null,handler 开始被调用。
如果你的目的不是关掉它,而恰恰是要路由 Service Worker 自己发出的请求,docs/src/service-workers-js-python.md 给的写法是在 context.route 里按来源分支:
await context.route('**', async route => {
if (route.request().serviceWorker()) {
// NB: calling route.request().frame() here would THROW
await route.fulfill({
contentType: 'text/plain',
status: 200,
body: 'from sw',
});
} else {
await route.continue();
}
});
这段同样是仓库原样代码,注释里那句「在这里调 frame 会抛异常」是仓库自己写的。
还有一条已知限制要记住:那份指南的 Known Limitations 一节写明,请求更新版 Service Worker 主脚本代码的那次请求目前无法被路由。
第二个嫌疑人:注册的时机与层级
Service Worker 排除掉之后,接下来看的是「你在什么时候、往哪一层注册的」。
层级差异是文档写死的。 docs/src/api/class-page.md 里 Page.route 有一条 note 写明:它不会拦截 popup 页面的第一个请求,要拦这个请求得用 BrowserContext.route。docs/src/api/class-browsercontext.md 里 BrowserContext.page 事件那段也是同样口径——用 window.open 打开 popup 时,这个事件在那次网络请求完成、响应开始加载时才触发;如果你想路由或监听新开页面的这次网络请求,用 BrowserContext.route 与 BrowserContext.request 事件,而不是 Page 上的同名方法(docs/src/api/class-page.md 的 Page.popup 事件下有逐字相同的一句)。所以「点了一个 target=_blank 的链接,新窗口的首个文档请求没被拦」不是 bug,是注册层级选错了。
优先级也是文档写死的,而且是两条规则叠加。 Page.route 的说明里写明:若一个请求匹配多个已注册的路由,最近注册的那个优先;同时 Page 上注册的路由优先于用 BrowserContext.route 注册的路由。BrowserContext.route 那一侧写的是同一句的另一半。
优先级之上还有一条更容易踩的:Route.continue 会吃掉后续 handler。 docs/src/api/class-route.md 在 Route.continue 的 Details 里写明:它会立即把请求发到网络,其它匹配的 handler 不会被调用;如果你想让链上下一个 handler 继续被调用,要用 Route.fallback。Route.fallback 的说明里进一步写明,多个路由匹配同一个 pattern 时,它们按注册顺序的相反顺序运行,因此最后注册的那个总能覆盖前面的。仓库给的示例是三个 page.route('**/*', ...) 叠在一起,最后注册的先跑。
把这两条合起来看:你在 beforeEach 里写了一个全局 context.route('**/*', route => route.continue()) 做请求改写,又在某个用例里写了针对具体接口的 page.route——按文档语义,page 侧优先,所以这个组合通常没问题;但如果两个 handler 都在同一层且前一个用的是 continue 而不是 fallback,链就在那里断了。
times 用完之后也不再拦。 Page.route 与 BrowserContext.route 都有 times 选项(docs/src/api/class-page.md 与 docs/src/api/class-browsercontext.md 里标注自 v1.15 起可用),文档写明它控制一个路由被使用的次数,默认每次都用。第二次跑到同一个接口时发现没拦住,先看这里。
重定向只回调一次。 Page.route 还有一条 note:如果响应是重定向,handler 只会对第一个 URL 被调用。你盯着重定向后的目标 URL 写 glob,自然等不到。
显式移除之后当然不拦。 Page.unroute 移除某条路由,不传 handler 时会移除该 url 对应的全部路由;Page.unrouteAll(文档标注自 v1.41 起可用)移除所有由 Page.route 与 Page.routeFromHAR 创建的路由,并带一个 behavior 选项,取值是 "wait"、"ignoreErrors"、"default" 三个。共享的 fixture 里如果有人调过 unrouteAll,你的路由就是被清掉的。
第三个嫌疑人:pattern 根本没匹配上
docs/src/network.md 的 Glob URL patterns 一节把规则写得很明确:单个 * 匹配除 / 以外的任意字符,** 匹配包含 / 的任意字符;? 只匹配问号本身,想匹配任意字符要用 *;花括号可以列可选项;反斜杠用于转义。最关键的一句是后面的 Important notes:glob 必须匹配整个 URL,而不是其中一段。文档举的例子是 https://example.com/*.js 能匹配 https://example.com/file.js,但匹配不上 https://example.com/path/file.js。写复杂匹配时文档建议改用 RegExp。
判定动作很简单:把 handler 的 pattern 临时换成 '**/*',如果这时 handler 进来了,问题就在 pattern;如果还是不进,回到前两节。
顺带一条副作用,两处 route 文档都用 note 标了:启用路由会禁用 http 缓存。这不影响拦不拦得住,但会改变你观察到的请求条数。
什么情况说明不是这个原因
这一节比前面几节更重要,省下来的时间都在这里。
- 你跑的不是 Chromium 系浏览器。
docs/src/service-workers-js-python.md开头就有一条 warning:Service Worker 只在基于 Chromium 的浏览器上受支持;BrowserContext.serviceWorker事件同样标注了这一条。在 Firefox 或 WebKit 上重现的失效,跟 Service Worker 无关,直接跳过第一节。 Response.fromServiceWorker为 false 且Request.serviceWorker为 null。 两个信号同时为阴性时,别再去调serviceWorkers: 'block',那不是病因。- 设成
'block'之后现象一模一样。 说明请求从来就没经过 Service Worker,去查层级、times、重定向和 pattern。 - 换成
'**/*'就能拦到。 那是 pattern 的问题,不是 Service Worker 的问题。 - handler 进来了但页面还是真数据。 这属于另一类问题:handler 内部是
continue还是fulfill、fulfill的字段对不对,不在本文这条链上。
关于操作系统差异:Windows 与 Linux/macOS 在这一层的行为,我们在仓库文档里没有找到按操作系统区分的说明——serviceWorkers 选项、route 的优先级规则、glob 语义这几处都没有标注平台限定。上面涉及的配置文件(playwright.config.ts)与 conftest.py 两种写法在 Windows 上照抄即可,唯一要留意的是这两个文件放在项目根目录、路径写法照仓库示例。
以上关于 route 与 Service Worker 的组合用法,是按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。该项目持续更新,文中涉及的选项名、默认值与优先级规则随版本变动,请以仓库最新内容为准。
本文依据 github.com/microsoft/playwright 仓库于 2026-08-18 的公开内容整理,
事实来自仓库内的文档与源码。我们没有对文中涉及的功能做过实测,
因此不涉及运行速度、稳定性与实际表现的任何描述。
该项目迭代频繁,文中涉及的 API 签名、配置项与默认值随版本变动,请以仓库最新内容为准。
本文不涉及浏览器版本矩阵与版本清单,相关信息请以官方发布说明为准。