Playwright route 不生效:Service Worker 或时序绕过

2026-08-18

现象长什么样

你在用例里写了一段拦截,希望某个接口返回假数据:

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.mdPage.route 的说明里有一条 note,逐字写明:Page.route 不会拦截被 Service Worker 拦截的请求,并建议在做请求拦截时把 Service Worker 关掉。docs/src/api/class-browsercontext.mdBrowserContext.route 下挂着一条内容一致的 note。也就是说,这不是某个语言绑定的特例,是两个入口共同的边界。

docs/src/network.md 里还专门有一节讲这件事,并点名了一个具体场景:如果你用了 Mock Service Worker(MSW)这类工具,它会注册自己的 Service Worker 来接管网络请求,请求因此对 BrowserContext.routePage.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 返回 nullWorker,标注 langs: js, python,并且在 Details 里写明这个方法只在 Chromium 上有意义——在其它浏览器上调用是安全的,但永远返回 null。同一段还写明:源自 Service Worker 的请求没有 Request.frame 可用。

docs/src/service-workers-js-python.md 把这条边界写得更硬:对一个 Request.serviceWorker 非空的对象调用 Request.frameResponse.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.mdTestOptions.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 时,serviceWorkersBrowser.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.mdPage.route 有一条 note 写明:它不会拦截 popup 页面的第一个请求,要拦这个请求得用 BrowserContext.routedocs/src/api/class-browsercontext.mdBrowserContext.page 事件那段也是同样口径——用 window.open 打开 popup 时,这个事件在那次网络请求完成、响应开始加载时才触发;如果你想路由或监听新开页面的这次网络请求,用 BrowserContext.routeBrowserContext.request 事件,而不是 Page 上的同名方法(docs/src/api/class-page.mdPage.popup 事件下有逐字相同的一句)。所以「点了一个 target=_blank 的链接,新窗口的首个文档请求没被拦」不是 bug,是注册层级选错了。

优先级也是文档写死的,而且是两条规则叠加。 Page.route 的说明里写明:若一个请求匹配多个已注册的路由,最近注册的那个优先;同时 Page 上注册的路由优先于用 BrowserContext.route 注册的路由。BrowserContext.route 那一侧写的是同一句的另一半。

优先级之上还有一条更容易踩的:Route.continue 会吃掉后续 handler。 docs/src/api/class-route.mdRoute.continue 的 Details 里写明:它会立即把请求发到网络,其它匹配的 handler 不会被调用;如果你想让链上下一个 handler 继续被调用,要用 Route.fallbackRoute.fallback 的说明里进一步写明,多个路由匹配同一个 pattern 时,它们按注册顺序的相反顺序运行,因此最后注册的那个总能覆盖前面的。仓库给的示例是三个 page.route('**/*', ...) 叠在一起,最后注册的先跑。

把这两条合起来看:你在 beforeEach 里写了一个全局 context.route('**/*', route => route.continue()) 做请求改写,又在某个用例里写了针对具体接口的 page.route——按文档语义,page 侧优先,所以这个组合通常没问题;但如果两个 handler 都在同一层且前一个用的是 continue 而不是 fallback,链就在那里断了。

times 用完之后也不再拦。 Page.routeBrowserContext.route 都有 times 选项(docs/src/api/class-page.mddocs/src/api/class-browsercontext.md 里标注自 v1.15 起可用),文档写明它控制一个路由被使用的次数,默认每次都用。第二次跑到同一个接口时发现没拦住,先看这里。

重定向只回调一次。 Page.route 还有一条 note:如果响应是重定向,handler 只会对第一个 URL 被调用。你盯着重定向后的目标 URL 写 glob,自然等不到。

显式移除之后当然不拦。 Page.unroute 移除某条路由,不传 handler 时会移除该 url 对应的全部路由;Page.unrouteAll(文档标注自 v1.41 起可用)移除所有由 Page.routePage.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 还是 fulfillfulfill 的字段对不对,不在本文这条链上。

关于操作系统差异:Windows 与 Linux/macOS 在这一层的行为,我们在仓库文档里没有找到按操作系统区分的说明——serviceWorkers 选项、route 的优先级规则、glob 语义这几处都没有标注平台限定。上面涉及的配置文件(playwright.config.ts)与 conftest.py 两种写法在 Windows 上照抄即可,唯一要留意的是这两个文件放在项目根目录、路径写法照仓库示例。

以上关于 route 与 Service Worker 的组合用法,是按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。该项目持续更新,文中涉及的选项名、默认值与优先级规则随版本变动,请以仓库最新内容为准。


本文依据 github.com/microsoft/playwright 仓库于 2026-08-18 的公开内容整理, 事实来自仓库内的文档与源码。我们没有对文中涉及的功能做过实测, 因此不涉及运行速度、稳定性与实际表现的任何描述。 该项目迭代频繁,文中涉及的 API 签名、配置项与默认值随版本变动,请以仓库最新内容为准。 本文不涉及浏览器版本矩阵与版本清单,相关信息请以官方发布说明为准。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。