Playwright 没封装的能力:用 CDPSession 直接发 CDP 命令
写自动化脚本迟早会撞上这么一天:你要的东西 Playwright 没封装。比如想数一数页面里到底起了几个跨进程 iframe,比如想把页面动画的播放速率调慢一半好看清楚过渡,比如想直接拿一棵穿透 shadow DOM 的 DOM 树。翻遍 docs/src/api/class-page.md 也找不到对应方法——这类需求的出口是 CDPSession。
CDPSession 在 docs/src/api/class-cdpsession.md 里的定位写得很直白:用来讲原始的 Chrome DevTools Protocol,协议方法用 session.send 调,协议事件用 session.on 订阅。这个类自 v1.8 起就在。下面沿着仓库里的文档与源码走一遍,看看这条通道从哪里开、边界划在哪里。
会话从哪来:两个入口
仓库里能建立 CDP 会话的地方只有两处,都是自 v1.11 起可用。
一处是 BrowserContext.newCDPSession,绑到某个具体目标上;另一处是 Browser.newBrowserCDPSession,拿到的是浏览器级别的会话。前者用来发 Network、DOM、Animation 这类跟某个页面相关的命令,后者用来发 Browser.getVersion、Target.setDiscoverTargets 这类跟整个浏览器实例相关的命令——仓库里 tests/library/chromium/oopif.spec.ts 的 countOOPIFs 辅助函数就是用浏览器级会话订阅 Target.targetCreated,再筛出 targetInfo.type === 'iframe' 来数 OOPIF 的。
docs/src/api/class-cdpsession.md 里给的用法示例,JS 是这样:
const client = await page.context().newCDPSession(page);
await client.send('Animation.enable');
client.on('Animation.animationCreated', () => console.log('Animation created!'));
const response = await client.send('Animation.getPlaybackRate');
console.log('playback rate is ' + response.playbackRate);
await client.send('Animation.setPlaybackRate', {
playbackRate: response.playbackRate / 2
});
同一页的 Python 异步版本是这样:
client = await page.context.new_cdp_session(page)
await client.send("Animation.enable")
client.on("Animation.animationCreated", lambda: print("animation created!"))
response = await client.send("Animation.getPlaybackRate")
print("playback rate is " + str(response["playbackRate"]))
await client.send("Animation.setPlaybackRate", {
"playbackRate": response["playbackRate"] / 2
})
注意两处差别:JS 里 page.context() 是方法调用,Python 里 page.context 是属性;返回值在 JS 里是对象属性访问,在 Python 里是字典下标。C# 侧文档写的是 Page.Context.NewCDPSessionAsync(Page),Java 侧是 page.context().newCDPSession(page)。这些都是从各自语言的代码块里逐字对应的,别拿一种绑定的写法套到另一种上。
还有一个容易忽略的签名细节:BrowserContext.newCDPSession 的那个参数在文档里类型标注是 <[Page]|[Frame]>,文档还专门解释了一句——为了向后兼容它仍然叫 page,但实际可以传 Page 也可以传 Frame。
为什么只有 Chromium
CDP 是 Chromium 家族的协议,这层限制在仓库里写了两遍。docs/src/api/class-browsercontext.md 和 docs/src/api/class-browser.md 各挂了一条 note,写明 CDP 会话只在基于 Chromium 的浏览器上支持。
更值得知道的是这条限制落在哪一层。翻 packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts,newCDPSession 方法开头就是一句判断:浏览器类型不是 chromium 就抛 CDP session is only available in Chromium。browserDispatcher.ts 里 newBrowserCDPSession 是同样的写法。也就是说这不是类型系统在编译期拦你,而是运行到服务端派发层才报错——你在 Firefox 或 WebKit 的 project 里写这段代码,构建不会有任何提示。跨浏览器矩阵跑测试的项目,涉及 CDP 的用例得自己按浏览器名做分支,仓库里 oopif.spec.ts 的 assertOOPIFCount 就是先判断 browser.browserType().name() !== 'chromium' 再决定要不要往下走。
同一个仓库里还留着另一条痕迹:tests/bidi/expectations/bidi-chromium-library.txt 把 library/chromium/session.spec.ts › should work with newBrowserCDPSession 这条用例标为 [fail]。这只是仓库里记录的期望状态:在这套跑法下该用例被登记为失败。至于为什么,仓库里没有写,我们也不替它推断——但如果你的项目里有跑 bidi 的通道,涉及 CDP 的用例值得先在自己的环境里确认一遍再纳入。
事件订阅:四种绑定差得最多的地方
send 在四种绑定里形状基本一致:第一个参数是协议方法名字符串,第二个是可选参数。差别在类型上——JS 与 Python 的 params 是 Object,C# 是 Map<string, Object> 且有 alias-csharp: args,Java 是 JsonObject 且有 alias-java: args。返回值 JS 侧是 Object,C# 侧是 JsonElement?,Java 侧是 JsonObject。
事件这一侧的分叉比 send 大得多,docs/src/api/class-cdpsession.md 用 langs 标记划得很清楚:
| 能力 | 标注的语言 | 起始版本 |
|---|---|---|
event: CDPSession.close | 未限定 | v1.59 |
event: CDPSession.event(通配所有 CDP 事件) | langs: js | v1.59 |
method: CDPSession.event(返回 CDPSessionEvent) | langs: csharp | v1.30 |
method: CDPSession.on / off | langs: java | v1.37 |
这张表要这样读:想「不预先知道事件名、把所有 CDP 事件都收下来」的那个通配订阅,文档只标了 js。它的用法是 session.on('event', ({ method, params }) => ...),回调拿到的对象带 method(CDP 事件名)和可选的 params。C# 走的是另一条路,CDPSession.event(eventName) 返回一个 CDPSessionEvent 对象,docs/src/api/class-cdpsessionevent.md 写明它代表一个具名事件、通过 onEvent 挂处理函数。Java 则是显式的 on(eventName, handler) / off(eventName, handler) 一对。
客户端源码 packages/playwright-core/src/client/cdpSession.ts 里能看到这个通配是怎么来的:channel 上收到 event 后,它先 this.emit(event.method, event.params) 按事件名派发一次,再 this.emit('event', event) 往通配通道派发一次。所以同一个 CDP 事件在 JS 侧会同时触发具名监听和通配监听,两边都挂了就会各收一份。
frame 粒度:不是每个 frame 都有会话
既然参数能传 Frame,是不是每个 iframe 都能开一个会话?不是。
packages/playwright-core/src/server/chromium/crBrowser.ts 里 CRBrowserContext.newCDPSession 的实现是:传 Page 就取它的 _targetId;传 Frame 则去页面的 _sessions 里按 frame id 找,找不到就抛 This frame does not have a separate CDP session, it is a part of the parent frame's session。拿到 targetId 之后走 rootSession.attachToTarget(targetId)。
翻成人话:只有被 Chromium 拆成独立 target 的 frame(也就是 OOPIF)才有自己的会话,同进程的普通 iframe 是父 frame 会话的一部分。tests/library/chromium/session.spec.ts 里有一条用例专门断言这个错误消息,而 oopif.spec.ts 里那条用例则演示了两者拿到的东西确实不同:对父 frame 的会话发 DOM.getDocument 带 pierce: true、depth: -1,结果里不含跨进程子页面的资源;对 OOPIF 的会话发同一条命令,结果里就有了。
和高层 API 混用:三处需要提前知道
第一,域的启用状态各管各的。 session.spec.ts 里有条用例叫 should enable and disable domains independently,做法是:先在自己的 CDP 会话上 Runtime.enable、Debugger.enable,然后调 page.coverage.startJSCoverage() 再 stopJSCoverage()(用例注释写明 JS coverage 会启用随后禁用 Debugger 域),最后仍然能在自己的会话上收到 Debugger.scriptParsed。这条用例说明的就是这一件事,再多的结论仓库里没有写。
第二,生命周期是跟着 target 走的。 detach() 的文档说明是:一旦 detach,这个 CDPSession 就不再发事件、也不能再发消息。close 事件的说明则写明它在会话关闭时触发,触发原因可能是 target 被关掉,也可能是你自己调了 detach()。测试文件里几条用例把顺序问题摆得很清楚:先 detach() 再 page.close() 是可以的;反过来页面已经关掉之后再调 detach() 会拿到错误;页面关掉时正在飞的 send 调用会被 reject,之后再发命令会得到「Target page, context or browser has been closed」这类消息。所以清理逻辑的顺序别写反——把 detach() 放在关页面之前。
第三,CDP 调用不吃 Playwright 的超时。 客户端 cdpSession.ts 里 send 与 detach 传给 channel 的都是 kNoTimeout,而 packages/playwright-core/src/client/timeoutSettings.ts 里这个常量的值是 { signal: undefined, timeout: 0 }。这是仓库当前代码里的取值,随版本可能变动。含义是这类调用不套用你在 config 里配的那套超时——一条挂住的协议命令不会被计时器截断,能让它结束的是 target 关闭。所以在 CDP 会话上发可能长期不返回的命令时,超时得你自己在外面兜。
还有一点得说明白:CDP 是原始协议通道,Runtime.evaluate、Network.* 这类命令能改的是浏览器的实际状态,不存在「因为在 Playwright 里所以被沙箱住了」这回事。session.spec.ts 第一条用例就是通过 CDP 往页面里写 window.foo,然后用 page.evaluate 读出来验证。要发什么命令、命令会碰到什么数据,得自己评估。
Windows 侧:WebView2 这条线
如果你的目标不是独立浏览器而是嵌在桌面程序里的 WebView2,路径不一样。docs/src/webview2.md 写明 WebView2 是 WinForms 控件、底层用 Microsoft Edge 渲染,属于 Microsoft Edge 浏览器的一部分,在 Windows 10 与 Windows 11 上可用;Playwright 连它用的是 BrowserType.connectOverCDP。
让 WebView2 开始监听 CDP 连接有两种办法,文档给的是:设置 WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS 环境变量带 --remote-debugging-port=9222,或者调 EnsureCoreWebView2Async 时传这个参数。文档特意补了一句:9222 只是这里的示例端口,任何其它未被占用的端口都可以。
connectOverCDP 自身也带两条提示,docs/src/api/class-browsertype.md 里写着:这个连接的保真度显著低于走 Playwright 协议的 BrowserType.connect,遇到问题或者要用高级功能时多半应该改用后者;另有一条 warning 说 Playwright 维护着一份精挑过的浏览器启动参数,如果你自己启动浏览器而没有传完全相同的参数,连上之后部分功能可能是坏的。
Linux 与 macOS 侧仓库里没有对应的 WebView2 页面,这一节的场景是 Windows 专属的;其它平台上怎么接,仓库里我们没有找到对应说明。
以上代码片段均照抄自仓库文档中的示例,未经实测,以仓库最新代码为准。
最后收一句边界:CDPSession 是逃生舱不是常规入口。凡是高层 API 已经封装的事情(等待、断言、网络拦截),走高层 API 能拿到自动等待与可操作性检查这些东西;掉到 CDP 这一层,这些保障就都不在了,协议命令的语义得你自己去 DevTools Protocol 的文档里对。安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。
本文依据 github.com/microsoft/playwright 仓库于 2026-08-18 的公开内容整理,
事实来自仓库内的文档与源码。我们没有对文中涉及的功能做过实测,
因此不涉及运行速度、稳定性与实际表现的任何描述。
该项目迭代频繁,文中涉及的 API 签名、配置项与默认值随版本变动,请以仓库最新内容为准。
本文不涉及浏览器版本矩阵与版本清单,相关信息请以官方发布说明为准。