首页
/ Puppeteer AwaitableIterable:统一同步与异步可迭代集合的类型设计与查询系统源码解析

Puppeteer AwaitableIterable:统一同步与异步可迭代集合的类型设计与查询系统源码解析

2026-09-05 19:57:51作者:郜逊炳

AwaitableIterable<T> 是 Puppeteer 内部查询系统(QueryHandler 体系)中的核心类型抽象,定义为 Iterable<T> | AsyncIterable<T> 的联合类型。本篇以 API 文档中该类型的签名为起点,结合当前仓库源码,解析它为何存在、在 querySelectorAll、ARIA 查询、浏览器端句柄"转置"(transpose)等关键链路中承担什么角色,以及开发者在注册自定义查询处理器(custom query handler)时如何正确地使用同步 Iterable 或异步 AsyncIterable 作为返回值。

一、类型签名:一行定义背后的双重世界

文档 docs/api/puppeteer.awaitableiterable.md 给出的完整定义如下:

export type AwaitableIterable<T> = Iterable<T> | AsyncIterable<T>;

该签名在仓库中的权威定义位于 types.ts(第 56 行,标注为 @public):

/**
 * @public
 */
export type AwaitableIterable<T> = Iterable<T> | AsyncIterable<T>;

它表达的是一个非常实际的约束:Puppeteer 的查询函数需要在同一个函数签名下,同时容纳两种本质不同的迭代协议——

  • Iterable<T>:同步可迭代集合(如数组、Set、同步 generator),调用 Symbol.iterator 后同步 next()
  • AsyncIterable<T>:异步可迭代集合(如 AsyncGenerator),调用 Symbol.asyncIteratornext() 返回 Promise。

为什么必须兼容两者?因为 Puppeteer 的查询逻辑最终要在两个执行世界中运行:

  1. 浏览器内(injected 代码):此时 DOM 已就绪,查询天然可以是同步 Iterable<Node>,无需等待任何 IO;
  2. Node.js 侧(Puppeteer 进程):结果需要跨 CDP/WebDriver BiDi 通道传输,每一步 next() 都是一次网络往返,只能是 AsyncIterable

AwaitableIterable 正是让这两类实现可以共用同一接口、同一套消费代码的"桥梁类型"。

同族类型对照

在同一文件 types.ts 中,还有两个紧密相关的类型,理解它们有助于把握整体类型体系:

类型 定义 可见性
Awaitable<T> T | PromiseLike<T>(第 61 行) @public
AwaitableIterator<T> Iterator<T> | AsyncIterator<T>(第 51 行) @internal
AwaitableIterable<T> Iterable<T> | AsyncIterable<T>(第 56 行) @public

其中 Awaitable<T> 是"值可能已就绪、也可能是 Promise"的泛化,与 AwaitableIterable 是同一思想在标量层面的对应;AwaitableIterator 则是迭代器(iterator)粒度的版本,主要服务于浏览器端句柄转置逻辑。AwaitableIterable 是三者中唯一出现在公共 API 表面、供外部扩展者(自定义查询处理器)直接面对的类型。

二、核心应用一:QuerySelectorAll 与 QueryHandler.queryAll

AwaitableIterable 最典型的落点是查询函数的返回类型。QueryHandler.ts 定义了内部类型别名(第 23 行附近):

export type QuerySelectorAll = (
  node: Node,
  selector: string,
  PuppeteerUtil: PuppeteerUtil,
) => AwaitableIterable<Node>;

注意这里的语义:注入浏览器内的 querySelectorAll 实现被约束为返回 AwaitableIterable<Node>。对于纯 CSS 选择器,注入端可以直接返回 document.querySelectorAll(...)NodeList,同步可迭代);而对于需要异步逻辑的选择器(如 XPath 或带轮询语义的自定义 handler),也可以返回 AsyncIterable。Puppeteer 外层统一按异步方式消费,调用方因此不需要区分底层是哪种实现。

对外暴露的 QueryHandler.queryAllQueryHandler.ts 第 105-117 行)本身就是一个异步 generator:

static async *queryAll(
  element: ElementHandle<Node>,
  selector: string,
): AwaitableIterable<ElementHandle<Node>> {
  using handle = await element.evaluateHandle(
    this._querySelectorAll,
    selector,
    LazyArg.create(context => {
      return context.puppeteerUtil;
    }),
  );
  yield* transposeIterableHandle(handle);
}

它做两件事:

  1. 通过 evaluateHandle 在页面内执行注入端的 querySelectorAll,得到一个"指向浏览器内可迭代集合的句柄"(JSHandle<AwaitableIterable<Node>>);
  2. 调用 transposeIterableHandle 把浏览器内的迭代过程"转置"为 Node 侧逐元素拉取的 AsyncIterable<ElementHandle<Node>>

也就是说,AwaitableIterable<ElementHandle<Node>> 这个返回类型,精确刻画了"从远程浏览器中逐个(或分批)产出本地元素句柄"的行为。

三、核心应用二:HandleIterator——把浏览器端 Iterable 转置为本地句柄流

HandleIterator.tsAwaitableIterable 类型在类型系统中被最重使用的模块。其入口函数签名为:

export async function* transposeIterableHandle<T>(
  handle: JSHandle<AwaitableIterable<T>>,
): AsyncIterableIterator<HandleFor<T>>

参数 JSHandle<AwaitableIterable<T>> 的含义是:句柄所指向的浏览器内值本身必须是一个可迭代集合(同步或异步均可)。函数体先在浏览器内把该集合包装成 AsyncGeneratoryield* iterable,兼容两种协议),再分批拉取回 Node 侧:

const DEFAULT_BATCH_SIZE = 20;

async function* fastTransposeIteratorHandle<T>(
  iterator: JSHandle<AwaitableIterator<T>>,
  size: number,
) {
  using array = await iterator.evaluateHandle(async (iterator, size) => {
    const results = [];
    while (results.length < size) {
      const result = await iterator.next();
      if (result.done) {
        break;
      }
      results.push(result.value);
    }
    return results;
  }, size);
  // ... 将数组属性逐个转为 HandleFor<T> 并 yield,最后统一 dispose
}

async function* transposeIteratorHandle<T>(
  iterator: JSHandle<AwaitableIterator<T>>,
) {
  let size = DEFAULT_BATCH_SIZE;
  while (!(yield* fastTransposeIteratorHandle(iterator, size))) {
    size <<= 1;  // 满批则批次翻倍:20 → 40 → 80 ...
  }
}

这里有两个值得注意的实现细节:

  • 批量转置 + 自适应批次:默认每次从浏览器拉 20 个元素(DEFAULT_BATCH_SIZE = 20),若恰好取满则批次大小左移一位翻倍,显著降低大列表查询的 CDP 往返次数;
  • 自动资源回收:每批句柄通过 DisposableStack 注册 disposeSymbol 回调(第 38-43 行),批次消费结束后统一释放,避免浏览器端对象泄漏。

从源码结构看,AwaitableIterable 在此处的价值在于:transposeIterableHandle 完全不需要知道浏览器内那个值究竟是 NodeListSet 还是某个 AsyncGenerator——它只要求"可迭代",包装与拉取逻辑对两种协议透明。

四、核心应用三:AsyncIterableUtil——用 for await 统一消费两种集合

AsyncIterableUtil.ts 是仓库中所有工具函数的统一消费层,其每个方法的首参数都是 AwaitableIterable<T>

export class AsyncIterableUtil {
  static async *map<T, U>(
    iterable: AwaitableIterable<T>,
    map: (item: T) => Promise<U>,
  ): AsyncIterable<U> {
    for await (const value of iterable) {
      yield await map(value);
    }
  }

  static async *flatMap<T, U>(
    iterable: AwaitableIterable<T>,
    map: (item: T) => AwaitableIterable<U>,
  ): AsyncIterable<U> { /* for await + yield* */ }

  static async collect<T>(iterable: AwaitableIterable<T>): Promise<T[]> {
    const result = [];
    for await (const value of iterable) {
      result.push(value);
    }
    return result;
  }

  static async first<T>(
    iterable: AwaitableIterable<T>,
  ): Promise<T | undefined> { /* 取首个元素即返回 */ }
}

关键技巧是:for await...of 可以直接消费同步 Iterable(TypeScript/JS 规范保证异步迭代协议可以包裹同步迭代器),因此这组工具函数不需要任何 Symbol.iteratorSymbol.asyncIterator 的分支判断,就能同时处理两种来源。这解释了 AwaitableIterable 为何设计成"联合类型 + 异步消费"的形态——消费端永远按最慢的(异步)协议走,实现端则按各自最自然的方式返回。

五、核心应用四:ARIA 查询与 queryAXTree 的惰性产出

公共 API 中 AwaitableIterable 还出现在抽象方法 ElementHandle.queryAXTree 的签名里(api/ElementHandle.ts 第 1033-1036 行,标记为 @internal):

abstract queryAXTree(
  name?: string,
  role?: string,
): AwaitableIterable<ElementHandle<Node>>;

两个协议实现都遵循该契约:

  • CDP 实现(cdp/ElementHandle.ts 第 180-208 行):先一次性向 Accessibility.queryAXTree 请求整棵无障碍树,过滤 ignored 节点与 NON_ELEMENT_NODE_ROLES 后,通过 AsyncIterableUtil.map(results, node => this.realm.adoptBackendNode(node.backendDOMNodeId)) 惰性地将每个节点转成本地 ElementHandle——只有消费方真正 next() 时才发起 adopt 调用;
  • BiDi 实现(bidi/ElementHandle.ts 第 125 行)同样以 AwaitableIterable<ElementHandle<Node>> 为返回类型。

ARIA 查询处理器 ARIAQueryHandler.ts 则展示了类型在继承体系中的传递:

static override async *queryAll(
  element: ElementHandle<Node>,
  selector: string,
): AwaitableIterable<ElementHandle<Node>> {
  const {name, role} = parseARIASelector(selector);
  yield* element.queryAXTree(name, role);
}

static override queryOne = async (
  element: ElementHandle<Node>,
  selector: string,
): Promise<ElementHandle<Node> | null> => {
  return (
    (await AsyncIterableUtil.first(this.queryAll(element, selector))) ?? null
  );
};

queryOne 借助 AsyncIterableUtil.first 直接消费 queryAll 产出的 AwaitableIterable,取第一个匹配项——这正是"同步/异步可迭代统一消费"模式的典型小样本。

六、消费端闭环:page.$$ 如何落到 AwaitableIterable

上述类型最终通过 ElementHandle 的私有查询实现汇入公共 API $$api/ElementHandle.ts 第 443-451 行):

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>>>>);
}

完整调用链为:element.$$QueryHandler.queryAll(返回 AwaitableIterable<ElementHandle<Node>>)→ AsyncIterableUtil.collect 将其物化为 ElementHandle[]。开发者在用户代码中拿到的数组,本质上就是一次对 AwaitableIterable 的完整异步消费。

七、实践指导:自定义查询处理器该如何返回

对于扩展 Puppeteer 的开发者,AwaitableIterable 通过 CustomQueryHandler 暴露到注册脚本一侧。注册接口对注入浏览器的 queryAll 要求返回 Iterable<Node>(同步),因为注入代码运行在页面内,DOM 是同步可达的:

export interface CustomQueryHandler {
  queryOne?: (node: Node, selector: string) => Node | null;
  queryAll?: (node: Node, selector: string) => Iterable<Node>;
}

使用方式(与文档注释一致):

import puppeteer from 'puppeteer';

puppeteer.registerCustomQueryHandler('lit', {
  queryAll: (node, selector) => {
    // 浏览器内同步执行,返回可迭代集合即可
    return (node as HTMLElement).shadowRoot?.querySelectorAll(selector) ?? [];
  },
});

const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setContent(`<div id="root"></div>`);
const handles = await page.$$('lit/…'); // 经 AwaitableIterable 链路产出
await browser.close();

结合源码可以得出两条实践结论:

  1. 注入端优先返回同步 Iterable:浏览器内没有 IO,同步集合可直接被 transposeIterableHandle 统一包装(yield* iterable),无需承担不必要的异步开销;AwaitableIterableAsyncIterable 分支主要是为 Node 侧的远程拉取阶段服务的。
  2. Node 侧扩展一律按 AsyncIterable 对待:任何自行封装"产出 ElementHandle 序列"的逻辑,都应返回 AsyncIterable/异步 generator,并复用 AsyncIterableUtilmap/flatMap/collect/first 来保持与核心代码一致的消费语义和惰性特性。

八、小结

维度 说明
定义位置 types.ts 第 56 行,@public
签名 Iterable<T> | AsyncIterable<T>
设计动机 让"浏览器内同步查询结果"与"Node 侧跨传输的惰性句柄流"共用同一接口
关键生产者 QueryHandler.tsqueryAllElementHandle.queryAXTree(CDP/BiDi 双实现)、AriaQueryHandler.ts
关键消费者 HandleIterator.tstransposeIterableHandle(批量 20、满批翻倍、自动 dispose)、AsyncIterableUtil.tsmap/flatMap/collect/first
公共 API 落点 ElementHandle.$$ / Page.$$ 经由 AsyncIterableUtil.collectAwaitableIterable<ElementHandle<Node>> 物化为数组

AwaitableIterable 本身只是一个两词联合类型,但它是 Puppeteer 查询子系统"双世界"架构的类型契约:浏览器端按最自然的方式产出集合,Node 端按最稳妥的异步协议消费集合,而中间的批次化转置与资源回收由 transposeIterableHandle 统一兜底。理解它,就理解了 Puppeteer 中 $$、ARIA 查询与自定义查询处理器共享的同一条执行管道。

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