Read OSS

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.navigationStartedbrowsingContext.domContentLoadedbrowsingContext.loadbrowsingContext.navigationFailedbrowsingContext.fragmentNavigated。第 141 行的 #matches() 方法实现了一种巧妙的惰性匹配策略:它遇到的第一个匹配导航 ID 就会成为自身的 ID。这样便能处理创建时导航 ID 尚未确定的竞争条件。

packages/puppeteer-core/src/bidi/core/Navigation.ts#L99-L149

提示:core/ 与其余 bidi/ 代码分离,在调试上有切实的好处。当某个 BiDi 功能不正常时,你可以迅速判断问题出在规范层(事件有误、状态机有误)还是适配层(映射到 Puppeteer API 有误)。这种分离模式值得在你自己的项目中借鉴。

Puppeteer 适配层

在核心层之上是适配层——BidiBrowserBidiPageBidiFrame——它将 BiDi 概念映射到 Puppeteer 的抽象 API。正是在这里,BrowsingContext 变成了 PageRealm 变成了 evaluate() 的执行上下文,BiDi 事件变成了 Puppeteer 事件。

BidiBrowser 封装了核心层的 SessionBrowser

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 的类型化值:arraysetobjectmapregexpdatenodewindow 以及各种原始类型。这在概念上比 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

桥接层由三个内部组件构成:

  1. NoOpTransport(第 179–208 行):这是一个纯粹用于连线的 transport,将 BidiMapper 的输出回传给 Puppeteer 的 BidiConnection。当 BidiMapper 产生响应时,它会调用 sendMessage(),触发 bidiResponse 事件,该事件随即被捕获并转发至 BidiConnection 的 onmessage

  2. CdpConnectionAdapter(第 70–107 行):对 Puppeteer 的 CDP Connection 进行封装,以满足 chromium-bidi 的 BidiMapper 所期望的接口,并管理各会话的 CDP 客户端适配器。

  3. 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

CoverageEmulationManager 直接从 CDP 实现中导入——当 CDP 连接可用时(即在 BiDi-over-CDP 模式下运行 Chrome 时),BidiPage 会复用它们。

BiDi 连接还支持 goog:cdp 扩展,提供 goog:cdp.sendCommandgoog: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。