首页
/ Puppeteer ElementHandle.$$ 详解:以元素为起点的范围化查询与 Selector 引擎实现

Puppeteer ElementHandle.$$ 详解:以元素为起点的范围化查询与 Selector 引擎实现

2026-09-06 15:27:25作者:殷蕙予

本文围绕 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 与 namep/aria)、XPathp/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>>>>);
}

从这段源码可以读出三个关键机制:

  1. @throwIfDisposed() 装饰器:若句柄已 dispose(对应元素被回收),调用会直接抛出错误,避免操作悬空引用。
  2. @bindIsolatedHandle 装饰器:默认路径(options?.isolate 不为 false)先经过 #$$ 私有方法,由该装饰器把查询绑定到隔离句柄上执行——这正是 QueryOptions.isolate 默认 true 时"独立沙箱 realm"语义的落地点;显式传入 isolate: false 则跳过该装饰器,直接走 #$$impl
  3. 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 以 名称=名称/ 开头,则剥离前缀并交由对应处理器;
  • 内置前缀查询器:依次检查 ariapiercexpathtext 四类内置处理器,同样支持 =/ 两种分隔符,例如 xpath//divxpath=/html/body/div
  • 无前缀时的 P-selector 解析:调用 parsePSelectors 尝试解析。若是纯 CSSisPureCSS),使用 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.$$() 是 Puppeteer 中"范围化批量查询"的核心入口:它以当前元素为根、支持 CSS 与 p/ 前缀体系下的 text / aria / xpath / pierce 等多种选择器,并通过 QueryOptions.isolate 在查询隔离性与批量性能之间提供权衡。理解其底层"路由到具体 QueryHandler + 异步迭代器收集句柄"的实现结构后,再结合 $$eval() 的批量求值能力,就能在自动化脚本中以更少的跨上下文调用完成从"定位容器"到"批量提取数据"的完整链路。

登录后查看全文
热门项目推荐
相关项目推荐