Read OSS

CDP 实现详解:Targets、Frames 与 Isolated Worlds

高级

前置知识

  • 第 1-2 篇文章
  • 对 Chrome DevTools Protocol 核心概念(targets、sessions、execution contexts)有基本了解

CDP 实现详解:Targets、Frames 与 Isolated Worlds

在第 2 篇中,我们完成了连接的建立,此时已拥有一个连接到 Chrome 的 CdpBrowser 实例。但 Puppeteer 究竟是如何驱动浏览器的?它如何感知新标签页的打开,如何构建 frame 树,page.evaluate() 中的代码又在哪里执行?

本文将深入探讨 CDP 实现层——Puppeteer 中最成熟、功能最完整的协议后端。它也是最复杂的一层:targets、frames、network 和 execution contexts 的各个 manager 都通过 CDP 事件协同运作。

CdpBrowser 与 Target 层级结构

在启动流程的末尾,CdpBrowser._create() 被调用,首要工作之一便是初始化 TargetManager。它负责追踪浏览器中的每一个 "target"——包括页面、workers、service workers,以及拥有独立 execution context 的 iframe。

packages/puppeteer-core/src/cdp/TargetManager.ts#L41-L54

源码中的注释说得很清楚:"TargetManager 使用 CDP 的 auto-attach 机制来拦截新 target,并允许 Puppeteer 的其余部分在 target 暂停时配置监听器。"这一点至关重要——CDP 的 Target.setAutoAttach 配合 waitForDebuggerOnStart: true,会在新 target 执行任何 JavaScript 之前将其暂停,从而为 Puppeteer 争取到设置拦截和插桩的时间窗口。

TargetManager 内部维护着多个 Map:

packages/puppeteer-core/src/cdp/TargetManager.ts#L56-L80

classDiagram
    class CdpBrowser {
        -connection: Connection
        -targetManager: TargetManager
        -defaultContext: CdpBrowserContext
        +newPage()
        +pages()
        +targets()
    }
    class TargetManager {
        -discoveredTargetsByTargetId: Map
        -attachedTargetsByTargetId: Map
        -attachedTargetsBySessionId: Map
        +getAvailableTargets()
    }
    class CdpTarget {
        <<abstract>>
        -targetInfo: TargetInfo
        -session: CDPSession
        +url()
        +type()
    }
    class PageTarget {
        +page()
        +_initialize()
    }
    class WorkerTarget {
        +worker()
    }
    CdpBrowser --> TargetManager
    TargetManager --> CdpTarget : manages
    CdpTarget <|-- PageTarget
    CdpTarget <|-- WorkerTarget
    CdpTarget <|-- OtherTarget
    CdpTarget <|-- DevToolsTarget

Target 通过工厂函数创建。当 TargetManager 收到 Target.attachedToTarget 事件时,会根据 target 类型实例化对应的 CdpTarget 子类:PageTarget 处理页面和 iframe,WorkerTarget 处理 web workers 和 service workers,OtherTarget 则处理其余所有类型(browser targets、background pages 等)。

FrameManager 与 Frame 树

每个 CdpPage 都持有一个 FrameManager,负责构建和维护页面的 frame 树。页面创建时,FrameManager 会:

  1. 启用所需的 CDP 域(PageRuntimeNetwork 等)
  2. 通过 Page.getFrameTree() 获取初始 frame 树
  3. 为每个 frame 创建 CdpFrame 实例
  4. 开始监听 frame 生命周期事件

packages/puppeteer-core/src/cdp/FrameManager.ts#L43-L59

FrameManager 使用一个独立的 FrameTree 数据结构(区别于原始的 CDP frame 树)来追踪父子关系。当 CDP 触发 Page.frameAttached 时,FrameManager 创建新的 CdpFrame 并将其挂载到树中;当 Page.frameDetached 触发时,该 frame 及其所有子 frame 会被一并清理。

导航处理尤为复杂。当 Page.frameNavigated 触发时,FrameManager 需要:

  • 更新 frame 的 URL
  • 区分同文档导航与跨文档导航并分别处理
  • 管理 execution context 的生命周期(旧 context 被销毁,新 context 被创建)
  • LifecycleWatcher 协调,以决定 page.goto() 何时完成
sequenceDiagram
    participant CDP as Chrome (CDP)
    participant FM as FrameManager
    participant Frame as CdpFrame
    participant IW as IsolatedWorld
    participant EC as ExecutionContext

    CDP->>FM: Page.frameNavigated
    FM->>Frame: Update URL, handle lifecycle
    FM->>IW: Clear execution context
    CDP->>FM: Runtime.executionContextCreated
    FM->>IW: Set new execution context
    IW->>EC: Create ExecutionContext
    Note over EC: Ready for evaluate()

Isolated Worlds 与 Execution Contexts

这是 Puppeteer CDP 实现中最重要的概念之一。每个 frame 都拥有两个 "world"——相互隔离的 JavaScript execution context:

packages/puppeteer-core/src/cdp/IsolatedWorlds.ts#L1-L21

MAIN_WORLD 是页面本身的 JavaScript context——与页面脚本共享同一运行环境。调用 page.evaluate() 时,函数就在这里执行,可以访问页面的 windowdocument 以及所有全局变量。

PUPPETEER_WORLD 是 Puppeteer 创建的隔离 context。它对页面脚本不可见,但仍能访问 DOM。Puppeteer 的工具函数(即 PuppeteerUtil bundle)——选择器查询、轮询辅助、文本匹配算法——都注入并运行在这里。借助隔离 world,Puppeteer 既不会污染页面的全局作用域,也不会与页面自身的 JavaScript 产生冲突。

IsolatedWorld 类继承自 API 层的抽象类 Realm

packages/puppeteer-core/src/cdp/IsolatedWorld.ts#L13-L29

flowchart TD
    subgraph "CdpFrame"
        MW["MAIN_WORLD<br/>(IsolatedWorld)"]
        PW["PUPPETEER_WORLD<br/>(IsolatedWorld)"]
    end
    MW --> MEC["ExecutionContext<br/>(page's JS context)"]
    PW --> PEC["ExecutionContext<br/>(isolated context)"]
    MEC --> |"page.evaluate()"| PAGE["Page's window/document"]
    PEC --> |"PuppeteerUtil injected"| DOM["DOM access (no page JS)"]
    PEC --> |"waitForSelector() runs here"| POLL["Polling/querying"]

ExecutionContext 类封装了 CDP 的 Runtime.evaluateRuntime.callFunctionOn 调用,负责处理参数和返回值的序列化:

packages/puppeteer-core/src/cdp/ExecutionContext.ts#L12-L34

提示: 如果遇到 page.evaluate() 无法访问页面上某个变量的问题,请确认代码是否意外运行在了 PUPPETEER_WORLD 中。$eval$$eval 方法在 MAIN_WORLD 中执行函数,而内部的选择器操作则运行在 PUPPETEER_WORLD 中。

CdpPage:统筹协调各个 Manager

CdpPage 是抽象类 Page 的具体实现,也是整个代码库中体量最大的文件之一。它将众多 manager 串联在一起:

packages/puppeteer-core/src/cdp/Page.ts#L7-L80

光看 import 列表就一目了然——CdpPage 依赖 FrameManagerNetworkManagerEmulationManagerCoverageBindingCdpKeyboardCdpMouseCdpTouchscreen 等诸多模块。每个 manager 负责各自对应的 CDP 域:

Manager CDP 域 职责
FrameManager Page, Runtime Frame 树、导航、execution contexts
NetworkManager Fetch, Network 请求拦截、身份认证
EmulationManager Emulation 视口、媒体特性、地理位置
Coverage Profiler, CSS 代码覆盖率收集
CdpKeyboard/Mouse/Touchscreen Input 用户输入模拟
classDiagram
    class CdpPage {
        -frameManager: FrameManager
        -emulationManager: EmulationManager
        -keyboard: CdpKeyboard
        -mouse: CdpMouse
        -touchscreen: CdpTouchscreen
        +goto()
        +evaluate()
        +screenshot()
        +pdf()
        +setViewport()
    }
    class FrameManager {
        -networkManager: NetworkManager
        -frameTree: FrameTree
        +mainFrame()
        +frames()
    }
    class NetworkManager {
        +setRequestInterception()
        +authenticate()
    }
    class EmulationManager {
        +emulateViewport()
        +emulateMediaFeatures()
    }
    CdpPage --> FrameManager
    CdpPage --> EmulationManager
    FrameManager --> NetworkManager

CdpPage 将大部分事件路由委托给 FrameManager。当你调用 page.on('request', handler) 时,事件实际上源自 NetworkManager,经由 FrameManager 传递,最终由 CdpPage 重新触发。这种事件委托模式让每个 manager 专注于自身的职责范围,同时为用户在 Page 对象上提供统一的事件接口。

网络拦截与导航生命周期

NetworkManager 处理所有与网络相关的 CDP 事件:使用 Fetch 域进行请求拦截,使用 Network 域进行通用的请求/响应追踪。

packages/puppeteer-core/src/cdp/NetworkManager.ts#L9-L28

通过 page.setRequestInterception(true) 开启请求拦截后,NetworkManager 会激活 CDP 的 Fetch 域。被拦截的请求会暂停执行,直到用户代码调用 request.continue()request.respond()request.abort() 为止。

导航流程通过 LifecycleWatcher 来协调,它在网络事件与页面生命周期事件之间架起了一座桥梁:

packages/puppeteer-core/src/cdp/LifecycleWatcher.ts#L26-L53

Puppeteer API 事件名与 CDP 协议事件的对应关系如下:

Puppeteer 事件 CDP 事件 含义
load Page.loadEventFired load 事件触发
domcontentloaded Page.domContentEventFired DOMContentLoaded 触发
networkidle0 自定义(500ms 内无活跃连接) 无网络活动
networkidle2 自定义(500ms 内活跃连接 ≤2) 网络接近空闲

当你调用 page.goto(url, {waitUntil: 'networkidle0'}) 时,LifecycleWatcher 会同时监控导航响应和网络空闲状态,只有两个条件都满足时才会 resolve——若超时或发生导航错误则 reject。

sequenceDiagram
    participant User
    participant Page
    participant FM as FrameManager
    participant LW as LifecycleWatcher
    participant NM as NetworkManager

    User->>Page: goto(url, {waitUntil: 'load'})
    Page->>FM: frame.goto(url)
    FM->>LW: new LifecycleWatcher(frame, 'load')
    FM->>FM: CDP Page.navigate(url)
    loop Until lifecycle met
        NM-->>LW: Network events (requests in flight)
        FM-->>LW: Frame lifecycle events
    end
    LW-->>User: HTTPResponse

CDP 连接层

支撑这一切的底层是 Connection 类,它管理原始的 CDP 协议通信:

packages/puppeteer-core/src/cdp/Connection.ts#L35-L60

Connection 维护一个 CallbackRegistry 用于请求-响应的关联匹配(每条 CDP 命令都有唯一 ID,响应通过该 ID 进行匹配),以及一个 CDPSession 对象的 Map 用于与特定 target 通信。slowMo 参数会为每条命令添加人为延迟,便于调试时观察执行过程。

提示: 设置 DEBUG=puppeteer:protocol:* 可以查看所有收发的 CDP 消息。Connection.ts 顶部定义的 debugProtocolSenddebugProtocolReceive logger 会输出类似 puppeteer:protocol:SEND ►puppeteer:protocol:RECV ◀ 格式的日志。

下一步

CDP 实现是 Puppeteer 最成熟的协议后端,但并非唯一。下一篇文章将探讨 WebDriver BiDi 实现——一个经过精心分层的架构,拥有忠实于规范的核心层、Puppeteer 适配层,以及一个进程内桥接层,使 BiDi API 能够通过 CDP 转换在 Chrome 上运行。