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 会:
- 启用所需的 CDP 域(
Page、Runtime、Network等) - 通过
Page.getFrameTree()获取初始 frame 树 - 为每个 frame 创建
CdpFrame实例 - 开始监听 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() 时,函数就在这里执行,可以访问页面的 window、document 以及所有全局变量。
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.evaluate 和 Runtime.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 依赖 FrameManager、NetworkManager、EmulationManager、Coverage、Binding、CdpKeyboard、CdpMouse、CdpTouchscreen 等诸多模块。每个 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 顶部定义的debugProtocolSend和debugProtocolReceivelogger 会输出类似puppeteer:protocol:SEND ►和puppeteer:protocol:RECV ◀格式的日志。
下一步
CDP 实现是 Puppeteer 最成熟的协议后端,但并非唯一。下一篇文章将探讨 WebDriver BiDi 实现——一个经过精心分层的架构,拥有忠实于规范的核心层、Puppeteer 适配层,以及一个进程内桥接层,使 BiDi API 能够通过 CDP 转换在 Chrome 上运行。