控制三大浏览器:浏览器抽象层
前置知识
- ›第 1-2 篇:架构与协议层
- ›熟悉 Chrome DevTools Protocol(CDP)的基本概念
- ›理解 TypeScript 中的抽象类模式
控制三大浏览器:浏览器抽象层
在第 1、2 篇文章中,我们梳理了用户的 API 调用是如何一步步转化为协议消息并到达服务端的。那么,消息抵达后又发生了什么?服务端需要将其转换为针对 Chromium、Firefox 或 WebKit 的具体命令——这三款浏览器各自使用截然不同的远程控制协议。本文将深入 Playwright 的浏览器抽象层,逐一探讨其类层级结构、核心的 PageDelegate 接口、Chromium 的 CDP 集成、正在推进中的 BiDi 支持、instrumentation 系统以及浏览器注册表。
服务端类层级结构
Playwright 服务端将浏览器控制逻辑组织成一套清晰的层级结构:
BrowserType → Browser → BrowserContext → Page → Frame
每一层都在 packages/playwright-core/src/server/ 下有对应的抽象基类,以及以 CR(Chromium)、FF(Firefox)、WK(WebKit)为前缀的浏览器专属实现。
BrowserType 基类位于 packages/playwright-core/src/server/browserType.ts#L48-L56,是所有浏览器交互的起点:
export abstract class BrowserType extends SdkObject {
private _name: BrowserName;
constructor(parent: SdkObject, browserName: BrowserName) {
super(parent, 'browser-type');
this.attribution.browserType = this;
this._name = browserName;
}
位于 packages/playwright-core/src/server/browserType.ts#L66-L72 的 launch() 方法负责校验选项、检查 Selenium hub 的覆盖配置,并将实际的启动逻辑委托给各浏览器的具体实现。
classDiagram
class BrowserType {
<<abstract>>
+launch()
+launchPersistentContext()
+executablePath()
}
class Chromium {
-_bidiChromium: BrowserType
}
class Firefox
class WebKit
class Browser {
<<abstract>>
+newContext()
+close()
}
class CRBrowser {
+_connection: CRConnection
+_session: CRSession
}
class Page {
+_delegate: PageDelegate
+click()
+fill()
}
BrowserType <|-- Chromium
BrowserType <|-- Firefox
BrowserType <|-- WebKit
Browser <|-- CRBrowser
Browser *-- BrowserContext
BrowserContext *-- Page
服务端的 Playwright 根对象位于 packages/playwright-core/src/server/playwright.ts#L50-L66,负责实例化所有浏览器类型:
this.chromium = new Chromium(this, new BidiChromium(this));
this.firefox = new Firefox(this, new BidiFirefox(this));
this.webkit = new WebKit(this);
注意 BidiChromium 和 BidiFirefox 是作为构造函数参数传入传统实现的——Playwright 正在现有 CDP 和自定义协议通道之外,逐步引入 WebDriver BiDi 支持。
PageDelegate:抽象的核心
Playwright 浏览器抽象层中最关键的接口是 PageDelegate,定义于 packages/playwright-core/src/server/page.ts#L55-L105。通用的 Page 类正是通过它将操作委托给各浏览器的具体实现:
export interface PageDelegate {
readonly rawMouse: input.RawMouse;
readonly rawKeyboard: input.RawKeyboard;
readonly rawTouchscreen: input.RawTouchscreen;
reload(): Promise<void>;
goBack(): Promise<boolean>;
goForward(): Promise<boolean>;
navigateFrame(frame: Frame, url: string, referrer: string | undefined): Promise<GotoResult>;
takeScreenshot(progress: Progress, format: string, ...): Promise<Buffer>;
// ... 20+ more methods
}
每个浏览器专属的页面类(CRPage、FFPage、WKPage)都实现了这个接口。Page 基类持有一个 _delegate: PageDelegate 引用,所有与浏览器相关的操作都通过它转发。
这套设计的精妙之处在于一些注释,它们揭示了各浏览器间细微的行为差异:
// Work around WebKit's raf issues on Windows.
rafCountForStablePosition(): number;
// Work around Chrome's non-associated input and protocol.
inputActionEpilogue(): Promise<void>;
// Work around for asynchronously dispatched CSP errors in Firefox.
readonly cspErrorsAsynchronousForInlineScripts?: boolean;
每款浏览器都有自己的特殊行为,PageDelegate 为每种情况提供了专属的扩展点,避免了在代码库中到处堆砌条件判断。
classDiagram
class PageDelegate {
<<interface>>
+rawMouse: RawMouse
+rawKeyboard: RawKeyboard
+reload()
+goBack()
+navigateFrame()
+takeScreenshot()
+rafCountForStablePosition()
+inputActionEpilogue()
}
class CRPage {
-_client: CRSession
+reload()
+navigateFrame()
}
class FFPage {
-_session: FFSession
+reload()
+navigateFrame()
}
class WKPage {
-_session: WKSession
+reload()
+navigateFrame()
}
PageDelegate <|.. CRPage
PageDelegate <|.. FFPage
PageDelegate <|.. WKPage
提示: 调试浏览器特定问题时,可以在
crPage.ts、ffPage.ts或wkPage.ts中搜索对应的PageDelegate方法,直接对比各浏览器的处理差异。
Chromium:CDP 与 Session 多路复用
Playwright 通过 Chrome DevTools Protocol(CDP)控制 Chromium。位于 packages/playwright-core/src/server/chromium/chromium.ts#L54-L60 的 Chromium 类继承自 BrowserType,负责进程启动和 CDP 连接的初始化。
packages/playwright-core/src/server/chromium/crBrowser.ts#L43-L57 中的 CRBrowser 通过 session 多路复用管理 CDP 连接:
export class CRBrowser extends Browser {
readonly _connection: CRConnection;
_session: CRSession;
readonly _contexts = new Map<string, CRBrowserContext>();
_crPages = new Map<string, CRPage>();
_serviceWorkers = new Map<string, CRServiceWorker>();
每个浏览器标签页都有独立的 CDP session,从而实现对多个页面的并发通信。CRConnection 则将这些 session 多路复用到单一的 WebSocket 连接上。
sequenceDiagram
participant PW as Playwright Server
participant Conn as CRConnection
participant Root as Root CDP Session
participant S1 as Session (Tab 1)
participant S2 as Session (Tab 2)
PW->>Conn: Connect via WebSocket
Conn->>Root: Target.getTargets()
Root-->>Conn: [page1, page2]
Conn->>S1: Target.attachToTarget(page1)
Conn->>S2: Target.attachToTarget(page2)
PW->>S1: Page.navigate(url)
PW->>S2: Runtime.evaluate(expr)
位于 packages/playwright-core/src/server/chromium/crBrowser.ts#L59-L69 的静态方法 CRBrowser.connect() 负责建立连接并附加到已有的目标页面。值得注意的是,Playwright 对 CDP 进行了封装而非直接暴露——这使它能够统一各浏览器的行为差异,并添加 CDP 原生不具备的自动等待等特性。
BiDi:正在崛起的新选择
Playwright 正在逐步引入对 WebDriver BiDi 协议的支持。该协议旨在跨浏览器厂商标准化自动化接口(类似 CDP,但面向所有浏览器)。BiDi 实现位于 packages/playwright-core/src/server/bidi/:
| 文件 | 用途 |
|---|---|
bidiChromium.ts |
基于 CDP 的 Chromium BiDi |
bidiFirefox.ts |
Firefox 原生 BiDi |
bidiBrowser.ts |
共享的 BiDi 浏览器逻辑 |
bidiPage.ts |
BiDi 页面实现 |
bidiConnection.ts |
BiDi WebSocket 协议 |
bidiOverCdp.ts |
通过 CDP 隧道传输的 BiDi |
如前所述,BiDi 支持以构造函数参数的形式传入传统浏览器类型。这种设计让用户可以在每次启动时按需选择 BiDi,而无需全量替换现有协议。
BidiChromium(BiDi 经由 CDP 隧道)与 BidiFirefox(原生 BiDi)之间的区别,折射出当前生态的现状:Chrome 在 CDP 之上实现了 BiDi,而 Firefox 则是原生支持。
SdkObject、Instrumentation 与 ProgressController
服务端所有对象都继承自 SdkObject,定义于 packages/playwright-core/src/server/instrumentation.ts#L47-L71:
export class SdkObject extends EventEmitter {
guid: string;
attribution: Attribution;
instrumentation: Instrumentation;
SdkObject 提供三项能力:全局唯一标识符(guid)、归因链(记录该对象所属的 playwright → browserType → browser → context → page → frame 路径),以及访问 Instrumentation 接口的入口。
位于 packages/playwright-core/src/server/instrumentation.ts#L80-L93 的 Instrumentation 接口定义了一组生命周期钩子,用于支撑 tracing、调试和录制器功能:
export interface Instrumentation {
onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>;
onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>;
onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>;
onPageOpen(page: Page): void;
onPageClose(page: Page): void;
onBrowserOpen(browser: Browser): void;
onBrowserClose(browser: Browser): void;
onDialog(dialog: Dialog): void;
onDownload(page: Page, download: Download): void;
}
位于 packages/playwright-core/src/server/instrumentation.ts#L108-L128 的实现借助 Proxy 动态分发事件到已注册的监听器,并按 BrowserContext 进行作用域隔离。这意味着 trace 录制器只会收到其所附加的 context 产生的事件。
位于 packages/playwright-core/src/server/progress.ts#L26-L56 的 ProgressController 为每个面向用户的操作包裹了超时管理逻辑。正如第 2 篇文章中 Dispatcher._runCommand() 所展示的,每条协议命令都会获得独立的 ProgressController:
export class ProgressController {
readonly metadata: CallMetadata;
private _forceAbortPromise = new ManualPromise<any>();
async run<T>(task: (progress: Progress) => Promise<T>, timeout?: number): Promise<T> {
const deadline = timeout ? monotonicTime() + timeout : 0;
// ... timeout and abort management
}
flowchart TD
A["User calls page.click()"] --> B["Dispatcher._runCommand()"]
B --> C["ProgressController.createForSdkObject()"]
C --> D["controller.run(task, timeout)"]
D --> E{"Timeout reached?"}
E -->|No| F["Execute task"]
E -->|Yes| G["Throw TimeoutError"]
F --> H{"Abort signal?"}
H -->|No| I["Return result"]
H -->|Yes| J["Reject with error"]
提示: 调试 Playwright 的超时问题时,传入服务端方法的
Progress对象有一个log()函数。这些日志会出现在错误信息的 "Call log" 部分,清晰呈现超时发生时服务端正在执行的操作。
浏览器注册表与自定义构建
位于 packages/playwright-core/src/server/registry/index.ts#L1-L48 的浏览器注册表负责管理浏览器二进制文件的下载与可执行路径解析,具体包括:
- 根据当前平台解析正确的可执行文件路径
- 从 CDN 镜像下载浏览器(支持多镜像回退)
- 管理自定义浏览器构建版本
Playwright 维护了多个 CDN 镜像:
const PLAYWRIGHT_CDN_MIRRORS = [
'https://cdn.playwright.dev/dbazure/download/playwright',
'https://playwright.download.prss.microsoft.com/dbazure/download/playwright',
'https://cdn.playwright.dev',
];
Playwright 方案的一个重要特点是:Firefox 和 WebKit 需要使用经过定制补丁的构建版本,这些补丁维护在 browser_patches/ 目录中。与仅支持 Chrome 的 Puppeteer 不同,Playwright 对 Firefox 和 WebKit 打了补丁,以暴露所需的自动化 API。这正是 playwright install 需要下载特定浏览器版本的原因——它们是定制构建版本,而非官方发布版。
下一步
了解了服务端如何抽象三大浏览器之后,下一篇文章将深入浏览器页面内部。我们将探讨注入脚本的架构、选择器引擎系统、Locator 如何惰性组合选择器字符串,以及让 Playwright 交互操作更可靠的自动等待重试机制。