Read OSS

深入页面内部:选择器、注入脚本与 DOM 交互

高级

前置知识

  • 第 1-3 篇:架构、协议与浏览器抽象
  • DOM 与 CSS 选择器基础知识
  • 对浏览器执行上下文的基本理解

深入页面内部:选择器、注入脚本与 DOM 交互

我们已经梳理了从 API 调用到协议消息,再到服务端浏览器抽象层的完整链路。但当服务端需要查找某个元素、检查其是否可见,或对其执行点击时,这些操作无法在 Node.js 中直接完成——必须在浏览器页面内部运行代码才行。本文将介绍 Playwright 注入脚本架构的工作原理、选择器引擎系统如何求值查询、Locator 如何在客户端惰性组合选择器,以及自动等待机制如何保障测试的稳定性。

注入脚本架构

Playwright 维护了一个独立的包 packages/injected/,其中的代码专门设计用于在浏览器页面上下文中运行。主入口是 InjectedScript,定义于 packages/injected/src/injectedScript.ts#L1-L60

这段代码在构建阶段单独编译,并以字符串形式打包到服务端(通过 packages/playwright-core/src/generated/injectedScriptSource)。当服务端需要与页面交互时,会将该脚本注入页面的 JavaScript 上下文并执行,从而创建一个 InjectedScript 实例,作为 Playwright 在页面内部的代理。

InjectedScript 类负责处理:

  • 选择器求值(查找匹配选择器的元素)
  • 元素状态查询(可见、启用、稳定等)
  • ARIA 快照生成(用于无障碍测试)
  • 命中目标拦截(检测点击会落在哪个元素上)
  • 选择器生成(用于录制器/代码生成器)
flowchart TD
    subgraph "Node.js Process"
        S["Server (frames.ts)"]
        B["Bundled injectedScriptSource"]
    end
    
    subgraph "Browser Page"
        UC["Utility Context"]
        MC["Main Context"]
        IS["InjectedScript Instance"]
    end
    
    S -->|"evaluate(injectedScriptSource)"| UC
    B -->|"compiled at build time"| S
    UC --> IS
    IS -->|"querySelector"| DOM["DOM Tree"]
    IS -->|"state checks"| DOM
    IS -->|"ARIA tree"| DOM

这里有一个关键设计:utility world 隔离。Playwright 在一个独立的 JavaScript 上下文("utility" world)中执行 InjectedScript,该上下文与页面共享同一个 DOM,但与页面的 JavaScript 命名空间完全隔离。这意味着页面自身的脚本无法干扰 Playwright 的选择器求值——即使页面覆盖了 document.querySelector,Playwright 的代码也不受影响。

提示: 正是由于 utility world 的隔离机制,Playwright 才能可靠地处理那些修改了内置原型或使用框架封装了 DOM 的页面。即便页面重定义了 Element.prototype.click,你的测试也不会因此出问题。

选择器引擎系统

Playwright 支持丰富的选择器引擎。服务端的 Selectors 类位于 packages/playwright-core/src/server/selectors.ts#L23-L56,负责管理内置引擎和自定义引擎:

this._builtinEngines = new Set([
  'css', 'css:light',
  'xpath', 'xpath:light',
  '_react', '_vue',
  'text', 'text:light',
  'id', 'id:light',
  'role', 'internal:attr', 'internal:label', 'internal:text',
  'internal:role', 'internal:testid',
  'internal:has', 'internal:has-not',
  'internal:has-text', 'internal:has-not-text',
  'internal:and', 'internal:or', 'internal:chain',
  'nth', 'visible', 'internal:control',
  // ...
]);

这些引擎可以分为以下几类:

类别 引擎 用途
CSS csscss:light 标准 CSS 选择器
XPath xpathxpath:light XPath 选择器
文本 textinternal:textinternal:has-text 文本内容匹配
角色 roleinternal:role 基于 ARIA role 的选择器
测试 ID data-testidinternal:testid 测试属性选择器
框架 _react_vue 框架组件选择器
组合器 internal:hasinternal:andinternal:or 选择器组合

:light 后缀表示"仅限 light DOM"——即不穿透 shadow DOM,而默认情况下 shadow DOM 遍历是开启的。

选择器可以通过 >> 语法进行链式组合,例如:

role=button >> text=Submit

这表示"先找到 role 为 button 的元素,再在其内部查找文本为 'Submit' 的内容"。每个片段由各自的引擎求值,>> 操作符将它们按顺序组合起来。

flowchart LR
    A["'role=button >> text=Submit'"] --> B["Parse selector"]
    B --> C["Part 1: role=button"]
    B --> D["Part 2: text=Submit"]
    C --> E["Role engine evaluates<br/>→ finds buttons"]
    E --> F["Scope narrows"]
    F --> D
    D --> G["Text engine evaluates<br/>within button scope"]
    G --> H["Final element"]

Locator:客户端惰性组合

Locator 类位于 packages/playwright-core/src/client/locator.ts#L40-L73,是 Playwright 最重要的抽象之一。与返回 ElementHandle 对象(服务端引用)的旧版 API 不同,Locator 是一个纯客户端对象,它只存储一个选择器字符串,并在每次执行操作时重新解析该选择器。

export class Locator implements api.Locator {
  _frame: Frame;
  _selector: string;

  constructor(frame: Frame, selector: string, options?: LocatorOptions) {
    this._frame = frame;
    this._selector = selector;

    if (options?.hasText)
      this._selector += ` >> internal:has-text=${escapeForTextSelector(options.hasText, false)}`;
    if (options?.hasNotText)
      this._selector += ` >> internal:has-not-text=${escapeForTextSelector(options.hasNotText, false)}`;
    if (options?.has)
      this._selector += ` >> internal:has=` + JSON.stringify(options.has._selector);
    if (options?.hasNot)
      this._selector += ` >> internal:has-not=` + JSON.stringify(options.hasNot._selector);
    if (options?.visible !== undefined)
      this._selector += ` >> visible=${options.visible ? 'true' : 'false'}`;
  }

每个过滤方法(hashasTexthasNotvisible)都只是通过 >> 链式语法向选择器字符串追加内容。只有在调用实际操作(clickfilltextContent)时,才会触发与浏览器的通信。

这一设计带来了深远的影响:

  1. 不存在过期引用 — 每次操作都会重新查找元素
  2. 可组合性locator.filter().locator().nth() 会构建一个复合选择器字符串
  3. 自动等待 — 执行操作时,Playwright 会等待选择器匹配到元素
classDiagram
    class Locator {
        +_frame: Frame
        +_selector: string
        +click()
        +fill()
        +locator(): Locator
        +filter(): Locator
        +nth(): Locator
        +first(): Locator
        +last(): Locator
    }
    
    note for Locator "No server-side state!<br/>Just _frame + _selector string.<br/>Browser round-trip only on actions."

提示: 优先使用 Locator,而不是 page.$() / ElementHandle。Locator 不持有服务端引用,因此不会产生"对象已被回收"的错误,也能天然应对动态内容。

Frame 执行与操作流程

Frame 是服务端所有 DOM 交互的执行边界,定义于 packages/playwright-core/src/server/frames.ts,是核心代码中最大的文件,约有 1800 行。

每个 Frame 维护两个执行上下文:

  • Main world:页面自身的 JavaScript 上下文,用于 page.evaluate() 调用以及 _react/_vue 选择器。
  • Utility world:Playwright 专用的隔离上下文,用于选择器求值和状态检查。

packages/playwright-core/src/server/dom.ts#L48-L57 中的 FrameExecutionContext 类负责连接这两个世界:

export class FrameExecutionContext extends js.ExecutionContext {
  readonly frame: frames.Frame;
  readonly world: types.World | null;

让我们来追踪一次完整的 locator.click() 调用流程:

sequenceDiagram
    participant User as locator.click()
    participant Client as Client Frame
    participant Proto as Protocol
    participant Server as Server Frame
    participant IS as InjectedScript
    participant PD as PageDelegate

    User->>Client: click()
    Client->>Proto: {method: "click", params: {selector}}
    Proto->>Server: Frame.click()
    
    loop Retry until timeout
        Server->>IS: querySelector(selector)
        IS-->>Server: elementHandle or null
        alt Element found
            Server->>IS: checkActionability()
            IS-->>Server: visible? stable? enabled?
            alt Actionable
                Server->>PD: rawMouse.click(x, y)
                PD-->>Server: done
                Server-->>Proto: success
            else Not actionable
                Note over Server: Wait and retry
            end
        else Not found
            Note over Server: Wait and retry
        end
    end

自动等待与重试逻辑

自动等待机制是 Playwright 相比早期自动化工具更加可靠的根本原因。当元素尚未就绪时,Playwright 不会立即报错,而是在超时时间内持续重试整个操作。

重试逻辑位于服务端的 Frame 类中。对于 click() 等操作方法,服务端会执行以下步骤:

  1. 通过选择器定位元素
  2. 检查可操作性:元素是否可见、已启用、稳定(未在动画中),且未被其他元素遮挡?
  3. 执行操作
  4. 如果某个步骤因可恢复的错误而失败,则短暂等待后从第 1 步重新开始

这一机制与第 3 篇中介绍的 ProgressController 相互配合。ProgressController 管理整体超时,而 frame 层面的重试循环则在这个时间窗口内持续尝试操作。

浏览器中的 InjectedScript 会高效地执行可操作性检查。对于点击操作,它会检查以下条件:

检查项 含义
visible 元素具有非零边界框,且未设置 visibility: hidden
stable 元素在两个动画帧之间位置未发生变化
enabled 不是已禁用的表单元素
receives events 目标坐标处没有其他元素会拦截点击事件
flowchart TD
    A["Start: click(selector)"] --> B["Resolve selector"]
    B --> C{"Element found?"}
    C -->|No| D["Wait for DOM change"]
    D --> B
    C -->|Yes| E["Check visible"]
    E --> F{"Visible?"}
    F -->|No| D
    F -->|Yes| G["Check stable"]
    G --> H{"Stable?"}
    H -->|No| I["Wait for RAF"]
    I --> G
    H -->|Yes| J["Check enabled"]
    J --> K{"Enabled?"}
    K -->|No| D
    K -->|Yes| L["Scroll into view"]
    L --> M["Check hit target"]
    M --> N{"Receives events?"}
    N -->|No| D
    N -->|Yes| O["Perform click"]
    O --> P["Success ✓"]
    
    D --> Q{"Timeout?"}
    Q -->|Yes| R["Throw TimeoutError"]

packages/playwright-core/src/server/dom.ts#L39 中的 PerformActionResult 类型揭示了所有可恢复的错误状态:

type PerformActionResult = 'error:notvisible' | 'error:notconnected' | 
  'error:notinviewport' | 'error:optionsnotfound' | 'error:optionnotenabled' | 
  { missingState: ElementState } | { hitTargetDescription: string } | 'done';

'done' 之外的所有状态都会触发重试。NonRecoverableDOMError 类则表示不应重试的错误——例如尝试对非输入元素执行 fill 操作。

提示: 如果测试在点击时超时,Playwright 的错误信息中会包含一份"调用日志",精确显示是哪项可操作性检查未通过。查找类似"waiting for element to be visible"或"element is not stable"的提示,即可快速定位问题所在。

下一篇

至此,我们已经完整走过了从用户层 API,到协议层,再到服务端抽象层,最终深入浏览器页面内部的全链路。下一篇文章将聚焦于 Playwright 的测试运行器——多进程架构、fixture 系统、任务流水线,以及 test.extend() 如何构建可组合的测试配置。