首页
/ Playwright Service Workers 实战指南:禁用策略、激活等待与 Worker 请求路由

Playwright Service Workers 实战指南:禁用策略、激活等待与 Worker 请求路由

2026-09-06 14:44:57作者:温玫谨Lighthearted

本篇技术指南基于 Playwright 官方文档 Service Workers 展开,系统讲解 Playwright 对 Service Worker 的完整支持体系:通过 serviceWorkers 上下文选项控制注册策略、使用 BrowserContext 事件捕获并等待 Service Worker 激活、区分页面请求与 Worker 请求的网络事件模型,以及仅针对 Service Worker 发起的请求进行路由拦截。读完后,你可以针对依赖 Service Worker 缓存的应用编写确定性的自动化测试,并精确模拟离线与响应改写场景。

什么是 Service Workers,Playwright 的支持边界

Service Workers 是浏览器原生的请求拦截机制:页面通过原生 Fetch API(fetch)以及脚本、CSS、图片等网络资源发起的请求,都可以被 Service Worker 代理处理。它可以在页面与外部网络之间充当网络代理,执行缓存逻辑;只要 Service Worker 注册了 FetchEvent 监听器,还能为用户提供离线体验。

大量站点把 Service Worker 仅当作透明的性能优化手段:用户感知到页面变快了,但应用实现本身对它的存在无感知,运行在开启或关闭 Service Worker 的环境里表现功能等价。

Playwright 对 Service Worker 的支持有两个重要前提,官方文档中均有明确标注:

  • 仅 Chromium 系浏览器支持:Service Worker 相关 API 只在 Chromium-based 浏览器上可用,Firefox 与 WebKit 不在支持范围内。这一点从源码结构也能印证:Service Worker 的服务端实现集中在 crServiceWorker.ts,是 Chromium 协议(CDP)专属的 CRServiceWorker 类,而 WebKit 与 Firefox 的驱动目录中不存在对应实现。
  • 普通网络 Mock 不需要本文方案:如果只是想做通用的请求拦截、路由与 Mock,应优先使用 Playwright 内置的 page.route / context.route 能力(参见 网络指南),那些 API 不需要理解 Service Worker 的任何细节。只有当测试目标本身涉及 Service Worker 发起的请求(例如验证缓存命中、改写 Worker 的响应)时,才需要本文介绍的事件模型与路由技巧。

禁用 Service Workers:让测试更确定

测试环境中,Service Worker 带来的缓存与异步注册会让断言变得不稳定且拖慢执行速度。Playwright 允许在测试期间直接禁用 Service Worker,官方建议的做法是将 serviceWorkers 选项设为 'block'

JavaScript / TypeScript 写法

playwright.config.ts 中通过 use 设置:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    serviceWorkers: 'block'
  },
});

Python 写法

conftest.py 中通过 browser_context_args fixture 注入上下文选项:

import pytest

@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
    return {
        **browser_context_args,
        "service_workers": "block"
    }

该选项的合法取值为 'allow'(默认)与 'block' 两种,类型定义见 types.d.ts。需要留意官方文档同时给出的警示:如果你的被测页面实际依赖 Service Worker,禁用后的行为可能与真实环境不同——因此"默认禁用换确定性,按需放行保真实"是两条并行的策略,应按测试目标选择。

底层实现:一条注入脚本完成屏蔽

禁用逻辑的实现非常轻量。从源码看,当上下文创建时检测到 serviceWorkers === 'block',Playwright 会向该上下文注入一段初始化脚本,把 navigator.serviceWorker.register 替换为空实现并打印一条警告:

// packages/playwright-core/src/server/browserContext.ts (L175-176)
if (this._options.serviceWorkers === 'block')
  await this.addInitScript(nullProgress,
    `if (navigator.serviceWorker) navigator.serviceWorker.register = async () => {
       console.warn('Service Worker registration blocked by Playwright');
     };`);

也就是说,被屏蔽的不是浏览器能力本身,而是应用侧的注册入口——任何 navigator.serviceWorker.register(...) 调用都会静默失败。对应测试 browsercontext-service-worker-policy.spec.ts 验证了完整行为链:默认策略下 registrationPromise 能成功 resolve;而 serviceWorkers: 'block' 时,页面 console 会捕获到 Service Worker registration blocked by Playwright 这条警告。该文件同时覆盖了 about:blank 页面上不抛错的边界情况。

获取 Service Worker 实例并等待激活

当测试需要与 Service Worker 交互时(读取缓存、注入逻辑、验证行为),首先要拿到它的句柄。Playwright 通过 BrowserContext 提供两类入口:

  • context.serviceWorkers:列出上下文中当前所有 Service Worker 实例(返回 Array<Worker>,类型定义见 types.d.ts);
  • context.waitForEvent('serviceworker') / expect_event("serviceworker"):在预期页面将触发注册时,先挂起等待,注册完成后拿到实例。

JavaScript 写法

const serviceWorkerPromise = context.waitForEvent('serviceworker');
await page.goto('/example-with-a-service-worker.html');
const serviceworker = await serviceWorkerPromise;

Python 写法(同步与异步)

with context.expect_event("serviceworker") as worker_info:
  page.goto("/example-with-a-service-worker.html")
service_worker = worker_info.value
async with context.expect_event("serviceworker") as worker_info:
  await page.goto("/example-with-a-service-worker.html")
service_worker = await worker_info.value

拿到实例后即可使用 Worker 对象的标准能力:url() 返回 Worker 脚本地址,evaluate() 在 Worker 上下文中执行表达式,waitForEvent() 等待其上的事件。这些方法都定义在客户端类 Worker 中,Service Worker 与 Web Worker 共享该基类,仅通过内部 _context 归属区分。

关键时序:注册事件早于激活,必须显式等待

一个容易踩坑的时序问题:serviceworker 事件在 Service Worker 接管页面之前就会触发,此时对 Worker 执行 evaluate 可能拿到尚未就绪的执行环境。官方文档给出了一段实现无关(implementation agnostic)的等待激活方案——在页面上下文中检查 navigator.serviceWorker 的注册状态,未激活则等待 controllerchange 事件:

await page.evaluate(async () => {
  const registration = await window.navigator.serviceWorker.getRegistration();
  if (registration.active?.state === 'activated')
    return;
  await new Promise(resolve => {
    window.navigator.serviceWorker.addEventListener('controllerchange', resolve);
  });
});
page.evaluate("""async () => {
  const registration = await window.navigator.serviceWorker.getRegistration();
  if (registration.active?.state === 'activated')
    return;
  await new Promise(resolve => {
    window.navigator.serviceWorker.addEventListener('controllerchange', resolve);
  });
}""")
await page.evaluate("""async () => {
  const registration = await window.navigator.serviceWorker.getRegistration();
  if (registration.active?.state === 'activated')
    return;
  await new Promise(resolve => {
    window.navigator.serviceWorker.addEventListener('controllerchange', resolve);
  });
}""")

这段脚本的逻辑是幂等的:已激活则直接返回,否则挂起直到浏览器把页面控制权交给 Worker。配合一个注册了 clients.claim() 的 Worker 脚本(即激活后立即接管页面),可以稳定复现"等待激活"路径。仓库中的测试资产 tests/assets/serviceworkers/fetch/sw.js 正是这种模式的最小实现:

self.addEventListener('fetch', event => {
  self.intercepted.push(event.request.url)
  event.respondWith(fetch(event.request));
});

self.addEventListener('activate', event => {
  event.waitUntil(clients.claim());
});

网络事件模型:页面请求与 Worker 请求如何区分

这是整篇文档最核心的部分。Service Worker 引入后,一次页面资源获取可能产生两条独立的请求链路,Playwright 通过 BrowserContext 把它们全部上报,并提供清晰的归属标识。

事件归属规则

任何由 Service Worker 发起的网络请求都通过 BrowserContext 对象报告:

  • context.requestcontext.requestFinishedcontext.responsecontext.requestFailed 事件正常触发;
  • context.route 能拦截到这些请求;
  • 对应的 Request 对象上,request.serviceWorker() 返回该 Service Worker 实例,而 request.frame抛出异常(Worker 请求没有归属 Frame)。

对于 页面 发起的请求,则通过 response.fromServiceWorker() 判断该请求是否被 Service Worker 的 fetch 处理器接管——返回 true 表示响应实际来自 SW 的 respondWith

事件流推演:一个透明代理 Worker

考虑文档中给出的透明代理示例——Worker 对页面的每个请求原样转发:

self.addEventListener('fetch', event => {
  // actually make the request
  const responsePromise = fetch(event.request);
  // send it back to the page
  event.respondWith(responsePromise);
});

self.addEventListener('activate', event => {
  event.waitUntil(clients.claim());
});

index.html 注册了该 Worker,随后页面 fetchdata.json,Playwright 将按以下顺序发出请求事件(对应网络生命周期事件同步触发):

事件 发起者(Owner) URL 可被 route 拦截 Response.fromServiceWorker()
context.request Frame index.html
page.request Frame index.html
context.request Service Worker transparent-service-worker.js
context.request Service Worker data.json
context.request Frame data.json 是(true)
page.request Frame data.json 是(true)

两个要点值得展开:

  1. data.json 出现两次 context.request 事件:一条由 Frame 拥有(页面视角),一条由 Service Worker 拥有(Worker 实际发出 fetch 的视角)。若监听 context.request 统计请求量,遇到有 fetch 处理器的 SW 时必须按 request.serviceWorker() 去重,否则会把同一个资源数两遍。
  2. 只有 Worker 侧那条 data.json 请求可被路由。Frame 侧的请求根本没有机会触达外部网络(fetch 处理器已注册,浏览器不会再发出原始请求),因此对 Frame 侧请求调用 route.fulfill / route.continue 没有意义;真正能改写响应的是 Worker 侧那条。

一个必须记住的陷阱

官方文档特别加粗警告:request.serviceWorker() 非空的 Request / Response 上调用 request.frameresponse.frame 会抛异常。这是因为 Worker 请求不存在归属 Frame。编写通用的 route 处理器时,应先判 serviceWorker() 再访问 frame,顺序颠倒会直接让路由回调崩溃。

仅路由 Service Worker 发起的请求

理解了归属标识后,"只拦截 Worker 请求、放行页面请求"就是一个条件分支。官方示例(JavaScript):

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();
  }
});
def handle_route(route: Route):
  if route.request.service_worker:
    # NB: accessing route.request.frame here would THROW
    route.fulfill(content_type="text/plain", status=200, body="from sw")
  else:
    route.continue_()

context.route("**", handle_route)
async def handle_route(route: Route):
  if route.request.service_worker:
    # NB: accessing route.request.frame here would THROW
    await route.fulfill(content_type="text/plain", status=200, body="from sw")
  else:
    await route.continue_()

await context.route("**", handle_route)

注意 JS 中 request.serviceWorker() 是方法调用,Python 中 request.service_worker 是属性访问,两者语义一致:非空即代表该请求由 Service Worker 发出。这条技巧的典型用途包括:模拟 Worker 拉取资源时断网/超时以验证回退逻辑、为 Worker 的预取请求注入固定响应、或验证缓存未命中时 Worker 发出的真实请求内容。

底层链路:CRServiceWorker 如何上报请求

从源码看,Worker 请求能进入 context.route 的完整链路是:Chromium 协议侧为每个 Service Worker 会话创建独立的 CRNetworkManager,请求经过 CDP 的 Fetch 域拦截后,回调到 CRServiceWorker.requestStarted

requestStarted(request: network.Request, route?: network.RouteDelegate) {
  this.browserContext.emit(BrowserContext.Events.Request, request);
  if (route)
    new network.Route(request, route).handle(this.browserContext.requestInterceptors);
}

事件直接以 BrowserContext 为发射源(而非 Page),这正是文档中"Worker 请求通过 BrowserContext 上报"结论的实现依据。同一类还同步了 offlinehttpCredentialsextraHTTPHeadersuserAgent 等上下文选项到 Worker 的网络栈——从 updateOffline 等方法可以看出,context.setOffline 同样作用于 Service Worker 的请求,这意味着"离线模式 + SW 缓存"组合测试(验证缓存命中路径)是完全可操作的。另外源码中保留了 PLAYWRIGHT_DISABLE_SERVICE_WORKER_NETWORK 环境变量用于在开发时关闭 Worker 网络检测,属于调试用开关,测试代码一般无需接触。

已知限制

文档同时明确了当前版本的一条已知限制:更新后的 Service Worker 主脚本(SW 脚本自身的新版本下载)请求目前无法被路由。这与透明代理示例中"Worker 脚本 URL 可被路由"并不矛盾——首次加载的脚本请求可拦截,而浏览器在后台检查 Worker 脚本更新时发出的验证请求走的是浏览器内部通道,绕过了 Playwright 的拦截层。编写"强制 Worker 脚本被 Mock"的测试时需要规避该场景,通常做法是固定 Worker 脚本内容后依赖缓存,或改用 fulfill 响应页面侧对脚本的初始加载。

小结

回到 官方文档原文,Playwright 的 Service Worker 支持可以浓缩为四件事:

  1. 策略控制serviceWorkers: 'block' 以一行注入脚本屏蔽注册,换取测试确定性(实现策略测试);
  2. 实例获取与激活等待waitForEvent('serviceworker') / expect_event("serviceworker") 拿句柄,controllerchange 幂等等待确保可安全 evaluate
  3. 双链路事件模型request.serviceWorker()response.fromServiceWorker() 两个标识符区分 Frame 侧与 Worker 侧请求,且 Worker 请求上访问 frame 会抛异常;
  4. 定向路由context.route 中按 serviceWorker() 分支,精准改写 Worker 发起的请求。

需要再次强调适用前提:以上能力均限于 Chromium 系浏览器;若测试目标是 Firefox 或 WebKit 且页面依赖 Service Worker,只能验证"无 Worker"路径下的行为。

登录后查看全文
热门项目推荐
相关项目推荐