WebDriver BiDi:新协议与 CDP 桥接层
前置知识
- ›第 1–3 篇文章
- ›对 WebDriver BiDi 规范有基本了解
WebDriver BiDi:新协议与 CDP 桥接层
WebDriver BiDi 是浏览器自动化协议的未来方向——它是原始 WebDriver 规范的双向、事件驱动继任者,旨在将类似 CDP 的能力引入跨浏览器标准。Puppeteer 的 BiDi 实现之所以引人入胜,不仅在于它能做什么,更在于它的架构方式:一套经过深思熟虑的三层架构,将规范关注点、库关注点和兼容性关注点清晰分离。
在第 3 篇中,我们探讨了 Puppeteer 如何通过 CDP 驱动 Chrome。本文将介绍另一条路径,以及连接两者的桥接层。
BiDi 核心层:忠实于规范的对象模型
在 packages/puppeteer-core/src/bidi/core/ 目录下,Puppeteer 维护着一个严格遵循 WebDriver BiDi 规范的层级。它的设计不受 Puppeteer API 需求的影响,完全以规范为准绳。
核心对象构成了一套与 BiDi 规范对应的层次结构:
classDiagram
class Session {
+connection: Connection
+browser: Browser
+send(method, params)
+subscribe(events)
}
class Browser {
+session: Session
+userContexts: UserContext[]
+addPreloadScript()
+close()
}
class UserContext {
+browser: Browser
+browsingContexts: BrowsingContext[]
+createBrowsingContext()
}
class BrowsingContext {
+userContext: UserContext
+navigate()
+reload()
+captureScreenshot()
}
class Realm {
+browsingContext: BrowsingContext
+evaluate()
+callFunction()
}
class Navigation {
+browsingContext: BrowsingContext
+request: Request
}
Session --> Browser
Browser --> UserContext
UserContext --> BrowsingContext
BrowsingContext --> Realm
BrowsingContext --> Navigation
Session 类是最顶层的入口:
packages/puppeteer-core/src/bidi/core/Session.ts#L23-L52
注意第 44 行 connection 访问器上的 @bubble() 装饰器——它会将 connection 触发的所有事件向上冒泡到 session。我们将在第 6 篇中详细介绍这一装饰器模式。
Session.from() 工厂方法会携带能力需求发送一条 session.new BiDi 命令,然后初始化 Browser 核心对象。这种设计意味着核心层理论上可以脱离 Puppeteer 独立使用。
BrowsingContext 与 Navigation 模型
BrowsingContext 是 BiDi 中与 CDP "target" 对应的概念,代表一个标签页或 frame。但 BiDi 的模型更为丰富:导航(navigation)是一等对象。
packages/puppeteer-core/src/bidi/core/BrowsingContext.ts#L7-L25
Navigation 类按照 BiDi 规范对导航的完整生命周期进行建模:
packages/puppeteer-core/src/bidi/core/Navigation.ts#L25-L50
Navigation 会监听 BiDi 事件,如 browsingContext.navigationStarted、browsingContext.domContentLoaded、browsingContext.load、browsingContext.navigationFailed 和 browsingContext.fragmentNavigated。第 141 行的 #matches() 方法实现了一种巧妙的惰性匹配策略:它遇到的第一个匹配导航 ID 就会成为自身的 ID。这样便能处理创建时导航 ID 尚未确定的竞争条件。
packages/puppeteer-core/src/bidi/core/Navigation.ts#L99-L149
提示: 将
core/与其余bidi/代码分离,在调试上有切实的好处。当某个 BiDi 功能不正常时,你可以迅速判断问题出在规范层(事件有误、状态机有误)还是适配层(映射到 Puppeteer API 有误)。这种分离模式值得在你自己的项目中借鉴。
Puppeteer 适配层
在核心层之上是适配层——BidiBrowser、BidiPage、BidiFrame——它将 BiDi 概念映射到 Puppeteer 的抽象 API。正是在这里,BrowsingContext 变成了 Page,Realm 变成了 evaluate() 的执行上下文,BiDi 事件变成了 Puppeteer 事件。
BidiBrowser 封装了核心层的 Session 和 Browser:
packages/puppeteer-core/src/bidi/Browser.ts#L58-L59
BidiPage 封装了核心层的 BrowsingContext:
packages/puppeteer-core/src/bidi/Page.ts#L28-L32
概念映射关系如下:
| BiDi 概念 | Puppeteer 概念 |
|---|---|
| Session | (内部,由 BidiBrowser 管理) |
| UserContext | BrowserContext |
| BrowsingContext | Page 或 Frame |
| Realm (WindowRealm) | IsolatedWorld / 执行上下文 |
| Navigation | Frame.goto() 返回值的一部分 |
| Request/Response | HTTPRequest/HTTPResponse |
classDiagram
class BidiBrowser {
-session: Session (core)
-browser: Browser (core)
+protocol = 'webDriverBiDi'
+newPage()
+close()
}
class BidiPage {
-browsingContext: BrowsingContext (core)
+goto()
+evaluate()
+screenshot()
}
class BidiFrame {
-browsingContext: BrowsingContext (core)
+evaluate()
+waitForSelector()
}
BidiBrowser --|> Browser : extends abstract
BidiPage --|> Page : extends abstract
BidiFrame --|> Frame : extends abstract
BidiBrowser --> BidiPage : creates
BidiPage --> BidiFrame : contains
序列化:JavaScript 与 BiDi 之间的桥梁
CDP 与 BiDi 最具体的差异之一,在于 JavaScript 值如何跨越协议边界传递。CDP 使用带有对象 ID 的"远程对象"来处理复杂值;BiDi 则采用了一种丰富的序列化格式,可以内联表示数组、map、set、日期、正则表达式等类型。
BidiSerializer 负责处理 JavaScript → BiDi 的转换:
packages/puppeteer-core/src/bidi/Serializer.ts#L19-L40
BidiDeserializer 负责处理 BiDi → JavaScript 的转换:
packages/puppeteer-core/src/bidi/Deserializer.ts#L14-L40
反序列化器处理 BiDi 的类型化值:array、set、object、map、regexp、date、node、window 以及各种原始类型。这在概念上比 CDP 的方式更清晰,但代价是每个值都要经过序列化边界——不存在可以高效传递的"远程对象引用"(不过 BiDi 确实为 DOM 节点提供了 handle 引用)。
flowchart LR
subgraph "Node.js"
A["JavaScript value"] --> B["BidiSerializer.serialize()"]
end
B -->|"BiDi LocalValue"| C["WebDriver BiDi Protocol"]
C -->|"BiDi RemoteValue"| D["BidiDeserializer.deserialize()"]
subgraph "Node.js (return)"
D --> E["JavaScript value"]
end
BiDi-over-CDP 桥接层
架构上最有意思的部分,是 BiDi-over-CDP 桥接层。它让 Puppeteer 在与 Chrome 通信时也能使用 BiDi API——毕竟 Chrome 的 CDP 支持已经相当成熟,而原生 BiDi 支持仍在持续推进中。
packages/puppeteer-core/src/bidi/BidiOverCdp.ts#L25-L64
数据流相当精妙:
sequenceDiagram
participant Puppeteer as Puppeteer (Node.js)
participant BidiConn as BidiConnection
participant NoOp as NoOpTransport
participant BidiMapper as BidiMapper (in-process)
participant CdpAdapter as CdpConnectionAdapter
participant CdpConn as CDP Connection
participant Chrome
Puppeteer->>BidiConn: page.goto(url)
BidiConn->>NoOp: send(BiDi JSON)
NoOp->>BidiMapper: emitMessage(parsed)
BidiMapper->>CdpAdapter: sendCommand('Page.navigate', ...)
CdpAdapter->>CdpConn: send('Page.navigate', ...)
CdpConn->>Chrome: CDP message
Chrome-->>CdpConn: CDP response
CdpConn-->>CdpAdapter: response
CdpAdapter-->>BidiMapper: response
BidiMapper-->>NoOp: sendMessage(BiDi response)
NoOp-->>BidiConn: onmessage(JSON)
BidiConn-->>Puppeteer: result
桥接层由三个内部组件构成:
-
NoOpTransport(第 179–208 行):这是一个纯粹用于连线的 transport,将 BidiMapper 的输出回传给 Puppeteer 的 BidiConnection。当 BidiMapper 产生响应时,它会调用sendMessage(),触发bidiResponse事件,该事件随即被捕获并转发至 BidiConnection 的onmessage。 -
CdpConnectionAdapter(第 70–107 行):对 Puppeteer 的 CDP Connection 进行封装,以满足chromium-bidi的 BidiMapper 所期望的接口,并管理各会话的 CDP 客户端适配器。 -
CDPClientAdapter(第 115–172 行):封装单个 CDP session,将 Puppeteer 的 CDPSession 事件转发到 BidiMapper 的事件系统。
第 55 行的 BidiMapper.BidiServer.createAndStart() 调用,是 chromium-bidi(BiDi 到 CDP 翻译器的参考实现)在进程内启动的地方。这套代码库,正是浏览器最终将原生实现的那套规范。
CDP 与 BiDi 的功能对比
在 Puppeteer 当前的演进阶段,两种协议后端具有不同的功能覆盖范围:
| 能力 | CDP | BiDi | 备注 |
|---|---|---|---|
| 页面导航 | ✅ 完整 | ✅ 完整 | BiDi 的导航模型更清晰 |
| JavaScript 执行 | ✅ 完整 | ✅ 完整 | BiDi 的序列化更丰富 |
| 网络拦截 | ✅ 完整 | ✅ 完整 | 底层 API 不同 |
| 截图 | ✅ 完整 | ✅ 完整 | |
| PDF 生成 | ✅ 完整 | ✅ 原生 | BiDi 原生支持 browsingContext.print |
| 设备模拟 | ✅ 完整 | ⚠️ 通过 CDP 回退 | 使用 goog:cdp 扩展或 CDP 连接 |
| 代码覆盖率 | ✅ 完整 | ⚠️ 通过 CDP | 使用 CDP Profiler 域 |
| Firefox 支持 | ❌ 已移除 | ✅ 必须 | Firefox 仅支持 BiDi |
查看 BidiPage 的源码,可以发现 BiDi 回退到 CDP 的地方——找那些从 ../cdp/ 导入的内容:
packages/puppeteer-core/src/bidi/Page.ts#L33-L39
Coverage 和 EmulationManager 直接从 CDP 实现中导入——当 CDP 连接可用时(即在 BiDi-over-CDP 模式下运行 Chrome 时),BidiPage 会复用它们。
BiDi 连接还支持 goog:cdp 扩展,提供 goog:cdp.sendCommand 和 goog:cdp.getSession 等自定义命令:
packages/puppeteer-core/src/bidi/Connection.ts#L34-L47
这个 Chrome 专属扩展提供了一个逃生通道:当 BiDi 原生不支持某项功能时,Puppeteer 可以通过 BiDi 连接隧穿 CDP 命令。
为什么要支持双协议?
双协议策略服务于 Puppeteer 的长期愿景。CDP 是 Chrome 专属的,并不在任何标准化路径上;BiDi 则是 W3C 规范,Firefox 已原生支持,Chrome 也在持续实现中。通过同一套抽象 API 同时支持两种协议(正如我们在第 1 篇中看到的),Puppeteer 将自身定位为跨浏览器自动化库,并可随着标准的成熟逐步从 CDP 向 BiDi 迁移。
BiDi-over-CDP 桥接层是一项过渡性技术——它让用户现在就能体验 BiDi 的 API,同时等待 Chrome 原生 BiDi 支持逐步完善。PUPPETEER_WEBDRIVER_BIDI_ONLY 标志则让团队和用户能够测试哪些功能可以单独依赖 BiDi,哪些仍需 CDP 回退。
下一步
至此,我们已经介绍了两种协议后端。下一篇文章将从协议转向面向用户的功能:Puppeteer 如何在页面上查找元素。我们将探索 P-selector 解析器、运行在浏览器页面内部的注入脚本运行时,以及基于 RxJS observables 构建的现代 Locator API。