Puppeteer ElementHandle.$$ 详解:以元素为起点的范围化查询与 Selector 引擎实现
本文围绕 Puppeteer 官方 API 文档中的 ElementHandle.$$() 方法展开,完整覆盖其方法签名、参数说明与返回值定义,并结合 packages/puppeteer-core 的源码,深入解析该方法的隔离查询(isolation)机制、Selector 前缀路由逻辑以及与之配套的 $$eval() 批量求值流程,帮助你在自动化脚本中准确地在"当前元素"子树内完成批量元素抓取与后续操作。
方法概览与官方签名
ElementHandle.$$() 的语义是:以当前句柄所指向的元素为根,查询其内部所有匹配给定 selector 的元素。它与 Page.$$() 的关键区别在于查询范围——后者以整个页面的 document 为起点,前者则被限制在当前元素的 DOM 子树内。这对于"先定位到某个容器,再提取容器内列表项"的常见场景(如商品卡片、表格行、消息列表)尤为重要。
官方文档给出的 TypeScript 签名如下(与源码保持一致):
class ElementHandle {
$$<Selector extends string>(
selector: Selector,
options?: QueryOptions,
): Promise<Array<ElementHandle<NodeFor<Selector>>>>;
}
方法使用泛型 <Selector extends string> 结合 NodeFor<Selector> 类型工具,能够根据传入的选择器形式(如 CSS 类名、aria/ 选择器等)推断出返回元素的具体 DOM 节点类型,使 TypeScript 调用方在拿到 ElementHandle 后能获得更精确的类型提示。
返回值:Promise<Array<ElementHandle<NodeFor<Selector>>>>,即一个指向所有匹配元素的 元素句柄 数组;没有匹配元素时返回空数组(而非 null,这是它与单元素查询 $() 的语义差异)。
参数说明
| 参数 | 类型 | 说明 |
|---|---|---|
selector |
Selector |
用于查询的选择器。CSS 选择器可直接传入;Puppeteer 特有的选择器语法支持按文本(p/text)、a11y role 与 name(p/aria)、XPath(p/xpath)进行查询,并支持跨 shadow root 组合查询。也可以使用前缀显式指定选择器类型。 |
options |
QueryOptions |
(可选)查询选项,见下文。 |
QueryOptions:控制查询是否隔离执行
QueryOptions 目前只有一个字段,定义在 Page.ts#L469-L479:
export interface QueryOptions {
/**
* Whether to run the query in isolation. When returning many elements
* from {@link Page.$$} or similar methods, it might be useful to turn
* off the isolation to improve performance. By default, the querying
* code will be executed in a separate sandbox realm.
*
* @defaultValue `true`
*/
isolate: boolean;
}
isolate 决定查询代码是否在页面主世界之外的独立沙箱 realm 中执行,默认值为 true。从源码结构看,这一设计意味着:默认情况下,批量查询逻辑运行在一个与页面脚本相互隔离的 JS 上下文中,页面自身代码无法干扰查询过程,也不影响页面全局状态;而当一次性返回大量元素、句柄创建开销成为瓶颈时,可以显式传入 {isolate: false} 关闭隔离以提升性能。
源码级实现:三层结构与 isolate 分支
在 ElementHandle.ts#L415-L451 中,$$() 的实现分为三层:
@throwIfDisposed()
async $$<Selector extends string>(
selector: Selector,
options?: QueryOptions,
): Promise<Array<ElementHandle<NodeFor<Selector>>>> {
if (options?.isolate === false) {
return await this.#$$impl(selector);
}
return await this.#$$(selector);
}
/**
* Isolates {@link ElementHandle.$$} if needed.
*
* @internal
*/
@bindIsolatedHandle
async #$$<Selector extends string>(
selector: Selector,
): Promise<Array<ElementHandle<NodeFor<Selector>>>> {
return await this.#$$impl(selector);
}
/**
* Implementation for {@link ElementHandle.$$}.
*
* @internal
*/
async #$$impl<Selector extends string>(
selector: Selector,
): Promise<Array<ElementHandle<NodeFor<Selector>>>> {
const {updatedSelector, QueryHandler} =
getQueryHandlerAndSelector(selector);
return await (AsyncIterableUtil.collect(
QueryHandler.queryAll(this, updatedSelector),
) as Promise<Array<ElementHandle<NodeFor<Selector>>>>);
}
从这段源码可以读出三个关键机制:
@throwIfDisposed()装饰器:若句柄已 dispose(对应元素被回收),调用会直接抛出错误,避免操作悬空引用。@bindIsolatedHandle装饰器:默认路径(options?.isolate不为false)先经过#$$私有方法,由该装饰器把查询绑定到隔离句柄上执行——这正是QueryOptions.isolate默认true时"独立沙箱 realm"语义的落地点;显式传入isolate: false则跳过该装饰器,直接走#$$impl。QueryHandler.queryAll+ 异步迭代器收集:底层查询并不是一次性同步返回,而是通过QueryHandler.queryAll(this, updatedSelector)产生一个异步迭代流,再经AsyncIterableUtil.collect收集为数组。这与 Puppeteer 的惰性句柄生成模型一致,适合大量元素场景下的流式处理。
Selector 路由:前缀如何决定查询处理器
#$$impl 中真正决定"用什么引擎执行查询"的是 getQueryHandlerAndSelector(),其实现位于 GetQueryHandler.ts#L18-L80:
const BUILTIN_QUERY_HANDLERS = {
aria: ARIAQueryHandler,
pierce: PierceQueryHandler,
xpath: XPathQueryHandler,
text: TextQueryHandler,
} as const;
const QUERY_SEPARATORS = ['=', '/'];
路由规则可以归纳为(与源码逻辑逐条对应):
- 自定义查询器优先:先遍历
customQueryHandlers(用户通过puppeteer.registerCustomQueryHandler()注册的处理器,见 CustomQueryHandler.ts),若 selector 以名称=或名称/开头,则剥离前缀并交由对应处理器; - 内置前缀查询器:依次检查
aria、pierce、xpath、text四类内置处理器,同样支持=和/两种分隔符,例如xpath//div与xpath=/html/body/div; - 无前缀时的 P-selector 解析:调用
parsePSelectors尝试解析。若是纯 CSS(isPureCSS),使用CSSQueryHandler;否则(含跨 shadow root 的 pierce 查询、aria 组合等)序列化后交给PQueryHandler;解析失败则回退为原始字符串交给CSSQueryHandler。
此外,该函数还会根据选择器类型返回 polling 轮询策略:aria 选择器含伪类时使用 PollingOptions.RAF(requestAnimationFrame 轮询),其余默认 PollingOptions.MUTATION(DOM 变更监听),这解释了为什么 a11y 类查询在动态页面上行为与其他选择器略有差异。
需要说明的是:由于 $$() 是一次性快照查询,上述轮询策略主要服务于 waitForSelector 等等待型方法;对 $$() 而言,路由结果的核心价值在于"选择哪种查询引擎"。
实战用法示例
以下示例展示 ElementHandle.$$() 的典型使用方式(基于官方 JSDoc 中的示例改写,可直接复制到测试脚本中运行):
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
const page = await browser.newPage();
// 假设页面中有如下结构:
// <div class="feed">
// <div class="tweet">Hello!</div>
// <div class="tweet">Hi!</div>
// </div>
// 1. 先拿到容器句柄,再在其子树内批量查询
const feedHandle = await page.$('.feed');
const tweets = await feedHandle.$$('.tweet');
console.log(tweets.length); // 2
// 2. 在元素句柄上使用 XPath
const rows = await feedHandle.$$('xpath=.//div[text()="Hello!"]');
// 3. 大量元素时关闭隔离以提升性能
const all = await feedHandle.$$('*', {isolate: false});
await browser.close();
与 $$eval() 的衔接:查完即求值
批量查询结果最常见的后续动作是对每个元素执行一段页面函数。Puppeteer 提供了 ElementHandle.$$eval() 一步完成,其实现见 ElementHandle.ts#L563-L588:
async $$eval<
Selector extends string,
Params extends unknown[],
Func extends EvaluateFuncWith<Array<NodeFor<Selector>>, Params> =
EvaluateFuncWith<Array<NodeFor<Selector>>, Params>,
>(
selector: Selector,
pageFunction: Func | string,
...args: Params
): Promise<Awaited<ReturnType<Func>>> {
pageFunction = withSourcePuppeteerURLIfNone(this.$$eval.name, pageFunction);
const results = await this.$$(selector);
using elements = await this.evaluateHandle(
(_, ...elements) => {
return elements;
},
...results,
);
const [result] = await Promise.all([
elements.evaluate(pageFunction, ...args),
...results.map(results => {
return results.dispose();
}),
]);
return result;
}
可以看到 $$eval 内部正是先调用本文主角 this.$$(selector) 完成批量查询,再把所有句柄打包成页面内数组句柄执行 pageFunction,最后并行 dispose 掉中间句柄——这解释了为什么手动"先 $$ 再逐句柄 evaluate"的模式在元素数量多时会产生大量来回调用与句柄泄漏风险,而 $$eval 是官方推荐的批量求值写法:
const feedHandle = await page.$('.feed');
const listOfTweets = await feedHandle.$$eval('.tweet', nodes =>
nodes.map(n => n.innerText),
);
相邻 API 对比
| 方法 | 作用域 | 返回 | 说明 |
|---|---|---|---|
ElementHandle.$() |
当前元素子树 | 单个句柄或 null |
匹配第一个元素,见 ElementHandle.ts#L381-L392 |
ElementHandle.$$() |
当前元素子树 | 句柄数组 | 本文主题 |
ElementHandle.$$eval() |
当前元素子树 | 函数求值结果 | 批量查询 + 页面内求值一步完成 |
Page.$$() |
整个页面 document |
句柄数组 | Page.$$ 内部实现即 mainFrame().$$()(见 Page.ts#L1278-L1284),再委托给 document 句柄的 $$(见 Frame.ts#L618) |
从 Frame.ts#L618 的实现可以看到,Frame.$$() 最终也是执行 document.$$(selector, options)——即页面级的 $$ 本质上是对 document 这个"元素句柄"调用同一套查询机制,options.isolate 同样生效。
验证与参考
- 方法实现:ElementHandle.ts(
$$位于 L416-L451,$$eval位于 L563-L588) QueryOptions定义:Page.ts- 选择器路由逻辑:GetQueryHandler.ts
- 自定义查询器注册机制:CustomQueryHandler.ts
- 相关测试:elementhandle.test.ts、queryhandler.test.ts、queryselector.test.ts
- 关联文档:ElementHandle 类文档、QueryOptions 文档、NodeFor 文档、Page.$$ 文档
小结
ElementHandle.$$() 是 Puppeteer 中"范围化批量查询"的核心入口:它以当前元素为根、支持 CSS 与 p/ 前缀体系下的 text / aria / xpath / pierce 等多种选择器,并通过 QueryOptions.isolate 在查询隔离性与批量性能之间提供权衡。理解其底层"路由到具体 QueryHandler + 异步迭代器收集句柄"的实现结构后,再结合 $$eval() 的批量求值能力,就能在自动化脚本中以更少的跨上下文调用完成从"定位容器"到"批量提取数据"的完整链路。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00