Read OSS

Playwright 的架构:Monorepo 全景导览

中级

前置知识

  • 基础 TypeScript 知识
  • 了解 npm workspaces 的基本概念
  • 对浏览器自动化有一定了解

Playwright 的架构:Monorepo 全景导览

Playwright 是一款支持 Chromium、Firefox 和 WebKit 的跨浏览器端到端测试与自动化框架。然而,在熟悉的 page.click() API 背后,隐藏着一套精心设计的客户端-服务端架构——它从一开始就面向多语言绑定、远程执行以及清晰的关注点分离而设计。本文将带你梳理这套架构的全貌:monorepo 的组织方式、核心的客户端-服务端分层、边界约束系统,以及将一切串联起来的入口点。

Monorepo 结构与各包的职责

Playwright 的代码仓库以 npm workspace 形式组织,packages/ 目录下共有约 22 个包。根目录的 package.json 声明了 workspace 配置:

package.json#L1-L5

下表列出了核心包之间的关系:

包名 npm 名称 职责
playwright-core playwright-core 核心库:client、server、protocol、CLI
playwright playwright 浏览器下载 + 测试 fixture + 测试运行器
playwright-test @playwright/test 轻量 CLI 封装,重新导出 playwright
protocol (内部包) YAML 协议定义 + 生成的类型文件
injected (内部包) 在浏览器页面上下文中执行的脚本
recorder (内部包) Recorder UI Web 应用
trace-viewer (内部包) Trace viewer Web 应用
html-reporter (内部包) HTML reporter Web 应用
web (内部包) 共享 Web 工具库
playwright-browser-* playwright-browser-chromium 各浏览器的专属下载包

其中最重要的是 playwright-core——它同时包含客户端的公共 API 和服务端的浏览器控制逻辑。playwright 包则在此基础上叠加了测试 fixture、测试运行器和浏览器下载管理功能。

graph TD
    AT["@playwright/test<br/>(CLI wrapper)"] --> PW["playwright<br/>(test runner + fixtures)"]
    PW --> PC["playwright-core<br/>(core library)"]
    PC --> PROTO["protocol<br/>(YAML + generated types)"]
    PC --> INJ["injected<br/>(browser-side scripts)"]
    PW --> REC["recorder<br/>(UI)"]
    PW --> TV["trace-viewer<br/>(UI)"]
    PW --> HR["html-reporter<br/>(UI)"]

提示: 执行 npm install playwright 会安装完整包,包含测试运行器、浏览器下载等所有内容。如果只需要自动化 API 而不需要测试运行器,安装 playwright-core 即可。

客户端与服务端的分层设计

Playwright 最关键的架构决策就是其客户端-服务端分层。即便你在单个 Node.js 脚本中运行 Playwright,底层依然有两个独立的层通过协议进行通信:

  • Clientpackages/playwright-core/src/client/):你直接使用的公共 API——BrowserPageLocator 等。这些对象是轻量级的,它们将方法调用序列化为协议消息。
  • Serverpackages/playwright-core/src/server/):真正执行浏览器控制的逻辑——进程启动、CDP/WebSocket 通信、DOM 操作、网络处理。

为什么要这样拆分?因为 Playwright 支持四种语言绑定:JavaScript/TypeScript、Python、Java 和 C#。非 JavaScript 的绑定将 server 作为子进程运行,通过 stdin/stdout 进行通信。有了清晰的协议边界,所有语言就能共用同一套 server 实现。

sequenceDiagram
    participant JS as Node.js Client
    participant Server as Playwright Server
    participant Browser as Browser Process

    JS->>Server: page.click('.button')
    Note over JS,Server: Protocol message<br/>{guid, method, params}
    Server->>Browser: CDP/Custom Protocol
    Browser-->>Server: Result
    Server-->>JS: Protocol response

客户端的根对象定义在 packages/playwright-core/src/client/playwright.ts#L29-L61。它继承自 ChannelOwner,并对外暴露你日常使用的 chromiumfirefoxwebkit 属性。

服务端的对应实现位于 packages/playwright-core/src/server/playwright.ts#L39-L66。可以看到,服务端的 Playwright 会实例化真正的浏览器类型实现——ChromiumFirefoxWebKit——以及用于 WebDriver BiDi 新标准支持的 BidiChromiumBidiFirefox 变体。

用 DEPS.list 强制约束模块边界

大多数大型 monorepo 会借助 Nx 或 Turborepo 等工具来约束依赖边界。Playwright 采用了一种更轻量的方案:在构建阶段由自定义脚本 utils/check_deps.js 检查各目录下的 DEPS.list 文件。

每个目录都可以有一个 DEPS.list 文件,用于声明该目录下的文件允许导入哪些模块。格式很简单:每个段落指定一个文件,并列出允许的导入路径。

顶层的边界规则定义在 packages/playwright-core/src/DEPS.list#L1-L32

[inProcessFactory.ts]
**

[inprocess.ts]
utils/
server/utils

[outofprocess.ts]
client/
protocol/
utils/
utils/isomorphic
server/utils

关键在于 [inProcessFactory.ts] 后面跟着 **——这意味着整个代码库中只有这一个文件被允许同时导入 client 和 server。其他所有文件都受到严格限制。

客户端的边界更为严格。来看 packages/playwright-core/src/client/DEPS.list#L1-L3

[*]
../protocol/
../utils/isomorphic

客户端只能导入 protocol 类型和 isomorphic 工具库,绝不允许导入 server。这一约束在构建阶段强制执行,从根本上杜绝了意外耦合。

flowchart LR
    subgraph "Allowed Imports"
        direction TB
        C["client/"] -->|"protocol types only"| P["protocol/"]
        C -->|"shared utils"| UI["utils/isomorphic/"]
        S["server/"] --> P
        S --> UI
        S -->|"controlled"| SB["server/chromium/<br/>server/firefox/<br/>server/webkit/"]
    end

    IPF["inProcessFactory.ts"] -->|"** (everything)"| C
    IPF --> S
    
    style IPF fill:#f96,stroke:#333,color:#000

服务端的 DEPS.list 位于 packages/playwright-core/src/server/DEPS.list#L1-L30,它还增加了一层约束:只有 playwright.ts(服务端根文件)才能导入 ./chromium/./firefox/./webkit/ 等浏览器专属模块。这样一来,Page 基类就不会意外引入 Chromium 特有的代码。

提示: 如果你在为 Playwright 贡献代码时遇到依赖错误导致构建失败,查看最近的 DEPS.list 文件即可——它会明确告诉你当前文件允许导入哪些路径。

入口点:进程内模式 vs 进程外模式

Playwright 提供两种运行模式,分别对应两个不同的入口点。

进程内模式(Node.js 默认)

当你执行 require('playwright-core') 时,实际进入的是 packages/playwright-core/src/inprocess.ts#L17-L19

import { createInProcessPlaywright } from './inProcessFactory';
module.exports = createInProcessPlaywright();

这里调用了 packages/playwright-core/src/inProcessFactory.ts#L26-L58 中的核心连接函数,该函数依次完成以下工作:

  1. 创建服务端 Playwright 实例
  2. 分别创建客户端 Connection 和服务端 DispatcherConnection
  3. 先以同步 dispatch 方式将二者连接
  4. 初始化 Playwright channel
  5. 通过 setImmediate() 切换为异步 dispatch
flowchart TD
    A["require('playwright-core')"] --> B["inprocess.ts"]
    B --> C["createInProcessPlaywright()"]
    C --> D["Create server Playwright"]
    C --> E["Create client Connection"]
    C --> F["Create DispatcherConnection"]
    D --> G["Wire sync dispatch"]
    G --> H["Initialize Playwright channel"]
    H --> I["Switch to async dispatch<br/>(setImmediate)"]
    I --> J["Return client PlaywrightAPI"]

先同步、后异步的设计值得深入理解。初始化阶段,客户端发出 initialize() 调用并立即期望得到包含 chromiumfirefoxwebkit 属性的 Playwright 对象。同步 dispatch 确保这次握手在 createInProcessPlaywright() 返回之前就已完成。之后改用 setImmediate(),则是为了避免深层嵌套的协议调用导致栈溢出。

进程外模式(其他语言绑定)

对于 Python、Java、C# 绑定,入口点是 packages/playwright-core/src/outofprocess.ts#L28-L68。它会将 Playwright driver fork 为子进程,并通过 PipeTransport 经由 stdin/stdout 建立通信:

this._driverProcess = childProcess.fork(
  path.join(__dirname, '..', 'cli.js'), ['run-driver'], {
    stdio: 'pipe',
    detached: true,
  });

Connection 通过管道发送 JSON 消息,服务端以与进程内模式完全相同的方式进行 dispatch。这正是四种语言得以共用同一套服务端实现的秘诀。

sequenceDiagram
    participant Python as Python Client
    participant Pipe as stdin/stdout
    participant Driver as Node.js Driver
    participant Browser as Browser

    Python->>Pipe: JSON message
    Pipe->>Driver: PipeTransport
    Driver->>Browser: CDP/Protocol
    Browser-->>Driver: Response
    Driver-->>Pipe: JSON response
    Pipe-->>Python: Result

Protocol YAML 与代码生成

整个 RPC API 的唯一真实来源是 packages/protocol/src/protocol.yml——一个约 4590 行的 YAML 文件,定义了所有的 channel(接口)、方法、事件和初始化器。

下面是一段 channel 定义示例,来自第 774 行附近的 RootPlaywright channel:

Root:
  type: interface
  commands:
    initialize:
      internal: true
      parameters:
        sdkLanguage: SDKLanguage
      returns:
        playwright: Playwright

Playwright:
  type: interface
  initializer:
    chromium: BrowserType
    firefox: BrowserType
    webkit: BrowserType
    android: Android
    electron: Electron

构建时,utils/generate_channels.js 读取这份 YAML 并生成以下产物:

  • TypeScript channel 接口packages/protocol/src/channels.d.ts
  • 校验函数packages/playwright-core/src/protocol/validator.ts
  • 协议元信息packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts

校验函数在协议边界的两端都会被调用,以确保消息符合 schema 定义。这是 Playwright 为 RPC 层提供编译期安全保障的核心机制。

flowchart LR
    YAML["protocol.yml<br/>(source of truth)"] --> GEN["generate_channels.js"]
    GEN --> CH["channels.d.ts<br/>(TypeScript types)"]
    GEN --> VAL["validator.ts<br/>(runtime validators)"]
    GEN --> META["protocolMetainfo.ts<br/>(method metadata)"]
    CH --> CLIENT["Client code"]
    CH --> SERVER["Server code"]
    VAL --> CLIENT
    VAL --> SERVER

下一步

本文为你绘制了 Playwright 架构的整体地图。在下一篇文章中,我们将深入协议层——从 page.click() 调用出发,追踪一条消息如何经由客户端 Connection、穿越协议边界、进入服务端 DispatcherConnection,最终抵达实际的浏览器命令。我们将重点分析 ChannelOwnerDispatcher、对象生命周期管理,以及保持客户端与服务端同步的校验流水线。