深入页面内部:选择器、注入脚本与 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 | css、css:light |
标准 CSS 选择器 |
| XPath | xpath、xpath:light |
XPath 选择器 |
| 文本 | text、internal:text、internal:has-text |
文本内容匹配 |
| 角色 | role、internal:role |
基于 ARIA role 的选择器 |
| 测试 ID | data-testid、internal:testid |
测试属性选择器 |
| 框架 | _react、_vue |
框架组件选择器 |
| 组合器 | internal:has、internal:and、internal: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'}`;
}
每个过滤方法(has、hasText、hasNot、visible)都只是通过 >> 链式语法向选择器字符串追加内容。只有在调用实际操作(click、fill、textContent)时,才会触发与浏览器的通信。
这一设计带来了深远的影响:
- 不存在过期引用 — 每次操作都会重新查找元素
- 可组合性 —
locator.filter().locator().nth()会构建一个复合选择器字符串 - 自动等待 — 执行操作时,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 步重新开始
这一机制与第 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() 如何构建可组合的测试配置。