Puppeteer CustomQueryHandler:注册自定义查询选择器扩展 page.$() 的完整解析
【免费下载链接】puppeteerJavaScript API for Chrome and Firefox项目地址: https://gitcode.com/GitHub_Trending/puppeteer1/puppeteer
本文以 Puppeteer 官方 API 文档CustomQueryHandler接口为骨架,结合仓库源码与测试用例,完整讲解该接口的两个可选属性queryOne/queryAll、注册规则与命名限制、处理器如何被序列化注入浏览器页面、选择器前缀的解析优先级,以及registerCustomQueryHandler、unregisterCustomQueryHandler、customQueryHandlerNames、clearCustomQueryHandlers四个配套 API。读完本文,你将能够为 Puppeteer 编写自定义选择器引擎(如按 id、按业务属性、按 Web Component 属性查询),并理解其在 CSS/P 选择器体系中的位置与底层注入机制。
一、CustomQueryHandler 接口:两个可选的查询方法
CustomQueryHandler是 Puppeteer 公开(@public)的 TypeScript 接口,定义在 CustomQueryHandler.ts 中:
export interface CustomQueryHandler { queryOne?: (node: Node, selector: string) => Node | null; queryAll?: (node: Node, selector: string) => Iterable<Node>; }接口仅包含两个属性,均为optional,但注册时至少需要实现其中一个(源码中有显式断言,见下文):
| 属性 | 修饰符 | 类型 | 说明 |
|---|---|---|---|
queryOne | optional | (node: Node, selector: string) => Node \| null | 从node出发,按给定selector搜索匹配的单个 DOMNode,无匹配时返回null |
queryAll | optional | (node: Node, selector: string) => Iterable<Node> | 从node出发,按给定selector搜索匹配的多个Node,返回可迭代对象 |
两个方法的参数约定与浏览器原生的Node.querySelector/Node.querySelectorAll一致:
- 第一个参数
node是查询的根节点。多数场景下它就是document,但注意处理器也会被用在子元素查询上(如ElementHandle.$会以该元素为根),因此实现中不要默认node就是document,除非你的选择器语义确实如此; - 第二个参数
selector是用户写在选择器前缀后面的那部分字符串,例如注册名getById、使用page.$('getById/foo')时,queryOne收到的selector就是'foo'。
只实现一个方法时,注入侧的注册器会自动做双向补齐:在注入页面环境的 CustomQuerySelector.ts(CustomQuerySelectorRegistry.register)中,若只提供了queryAll,会用queryAll的第一个结果实现queryOne;若只提供了queryOne,会将其包装成只含 0 或 1 个元素的queryAll:
// 摘自 packages/puppeteer-core/src/injected/CustomQuerySelector.ts if (!handler.queryOne && handler.queryAll) { const querySelectorAll = handler.queryAll; handler.queryOne = (node, selector) => { for (const result of querySelectorAll(node, selector)) { return result; } return null; }; } else if (handler.queryOne && !handler.queryAll) { const querySelector = handler.queryOne; handler.queryAll = (node, selector) => { const result = querySelector(node, selector); return result ? [result] : []; }; }也就是说:只写queryOne,page.$$也能用(最多返回一个元素);只写queryAll,page.$也能用(取第一个)。
二、注册:Puppeteer.registerCustomQueryHandler 与命名规则
接口本身只是“处理器定义”,真正对外暴露的操作挂在Puppeteer类的静态方法上,见 Puppeteer.ts:
static registerCustomQueryHandler( name: string, queryHandler: CustomQueryHandler, ): void { return this.customQueryHandlers.register(name, queryHandler); }注册后即可在任何接受选择器的位置使用name/selector(或name=selector)前缀,官方注释给出的示例为:
import {Puppeteer} from 'puppeteer'; Puppeteer.registerCustomQueryHandler('text', { /* … */ }); const aHandle = await page.$('text/…');注册逻辑位于 CustomQueryHandler.ts 的CustomQueryHandlerRegistry.register(约 L74–L126),包含三条硬校验,违反即抛错:
- 不允许覆盖已存在的注册名:
Cannot register over existing handler: ${name}。重复注册同名处理器必须先unregister或clear; - 命名只允许大小写拉丁字母:断言
/^[a-zA-Z]+$/.test(name),错误信息为Custom query handler names may only contain [a-zA-Z]。不允许数字、-、/、=等特殊字符——这也是为什么 测试用例 中注册'1/2/3'会精确抛错; - 至少实现一个查询方法:
At least one query method must be implemented.
前缀分隔符与解析优先级
选择器如何路由到自定义处理器,由 GetQueryHandler.ts 中的getQueryHandlerAndSelector决定。其要点:
- 前缀分隔符有两类:
QUERY_SEPARATORS = ['=', '/']。即注册名getById后既可写getById/foo也可写getById=foo; - 解析顺序是先自定义处理器、后内置处理器。内置查询处理器仅
aria、pierce、xpath、text四种(见BUILTIN_QUERY_HANDLERS),它们与自定义处理器互不冲突——只要你的注册名不与之重名即可; - 命中前缀后,前缀会被剥离,剩余部分作为
selector传入queryOne/queryAll;同时决定轮询策略:自定义处理器与纯 CSS 一样默认使用PollingOptions.MUTATION(MutationObserver 轮询),ARIA 类使用PollingOptions.RAF; - 若选择器不带任何已注册前缀,则进入 P 选择器解析(
::-p-xxx语法)或回落到 CSS。
从源码结构看,自定义处理器被置于最高优先级,这意味着你可以用Puppeteer.registerCustomQueryHandler('text', …)之类的方式“遮蔽”内置名——但注册阶段并不会阻止使用内置名,遮蔽的实际效果发生在选择器解析阶段,建议避免这种做法。
三、底层机制:处理器如何被注入到浏览器页面
这是CustomQueryHandler最容易误解的一点:queryOne/queryAll函数运行在浏览器页面里,而不是 Node 侧。注册时 Puppeteer 会做两件事(见register方法后半段):
- 用
stringifyFunction把queryAll/queryOne序列化为函数字符串,生成一段形如(PuppeteerUtil) => { PuppeteerUtil.customQuerySelectors.register("getById", { queryAll: …, queryOne: … }); }的注册脚本; - 将该脚本交给全局单例 scriptInjector(
scriptInjector.append(registerScript))。
ScriptInjector维护一个“补丁语句”集合,在每次构造注入脚本时,将 Puppeteer 内置的注入代码与所有自定义处理器注册脚本拼接在一起(#get()方法):
// 摘自 packages/puppeteer-core/src/common/ScriptInjector.ts #get(): string { return `(() => { const module = {}; ${injectedSource} ${[...this.#amendments] .map(statement => { return `(${statement})(module.exports.default);`; }) .join('')} return module.exports.default; })()`; }之后QueryHandler在页面内执行查询时,走的是页面环境中的PuppeteerUtil.customQuerySelectors.get(name)(见 CustomQueryHandler.ts 中生成的querySelector/querySelectorAll静态方法),再调用注册时的queryOne/queryAll。
由这个机制可以推断出两条实践约束:
- 你的
queryOne/queryOne实现里不能引用 Node 侧变量、外部导入或闭包外的复杂对象,它会被toString()化后在浏览器中求值,只能依赖 DOM API 与自身参数; - 注销处理器时,
scriptInjector.pop(registerScript)会移除对应注册脚本,保证后续构造的注入脚本不再包含该处理器(已打开页面的存量注入不受影响,新导航/新上下文才会生效)。
四、配套管理 API
Puppeteer类与CustomQueryHandler相关的静态方法共四个(定义见 Puppeteer.ts,API 文档分别为 registerCustomQueryHandler、unregisterCustomQueryHandler、customQueryHandlerNames、clearCustomQueryHandlers):
| 方法 | 行为 |
|---|---|
Puppeteer.registerCustomQueryHandler(name, handler) | 注册处理器,校验命名与重复注册 |
Puppeteer.unregisterCustomQueryHandler(name) | 按名注销;名称不存在时抛Cannot unregister unknown handler: ${name} |
Puppeteer.customQueryHandlerNames() | 返回当前所有已注册处理器的名称数组 |
Puppeteer.clearCustomQueryHandlers() | 注销全部自定义处理器(逐个scriptInjector.pop后清空 Map) |
注册、注销、命名校验的行为均有对应测试覆盖,位于 elementhandle.test.ts 的Custom queries描述块(约 L938 起),其中每个用例后都调用Puppeteer.clearCustomQueryHandlers()防止相互污染。
五、可运行的完整示例
以下示例均取自仓库测试用例,可直接改造使用。
1. 只实现 queryOne:按 id 查询
import puppeteer from 'puppeteer'; const browser = await puppeteer.launch(); const page = await (await browser.newPage()).setContent( `<div id="not-foo"></div> <div id="foo"></div>`, ); Puppeteer.registerCustomQueryHandler('getById', { queryOne: (_element, selector) => { // 运行在浏览器页面内,selector 为前缀后的字符串,如 'foo' return document.querySelector(`[id="${selector}"]`); }, }); // page.$ 使用 name/selector 前缀 const element = (await page.$('getById/foo')) as ElementHandle<HTMLDivElement>; console.log(await page.evaluate(el => el.id, element)); // 'foo' console.log(Puppeteer.customQueryHandlerNames()); // ['getById'] // 注销后,'getById/foo' 不再命中自定义处理器(回落为普通选择器解析并报错) Puppeteer.unregisterCustomQueryHandler('getById');2. 只实现 queryAll:按 class 查询多个元素
await page.setContent( `<div id="not-foo"></div> <div class="foo">Foo1</div> <div class="foo baz">Foo2</div>`, ); Puppeteer.registerCustomQueryHandler('getByClass', { queryAll: (_element, selector) => { return [...document.querySelectorAll(`.${selector}`)]; }, }); // 只实现了 queryAll,page.$$ 直接可用; // 注入侧会自动用 queryAll 的第一个结果补齐 queryOne,page.$ 同样可用 const elements = (await page.$$('getByClass/foo')) as ElementHandle[];3. 与 P 选择器(P-selectors)组合
自定义处理器同样参与 P 选择器体系。queryhandler.test.ts(约 L527 起)中注册名为div的处理器后,可用::-p-div前缀写法,并支持参数形式:
Puppeteer.registerCustomQueryHandler('div', { queryOne(_, selector) { if (selector === 'true') { return document.querySelector('div'); } return document.querySelector('button'); }, }); const a = await page.$('::-p-div(true)'); // 命中 div const b = await page.$("::-p-div('true')"); // 字符串参数形式,同样命中 div const c = await page.$('::-p-div'); // 无参数时命中 button从测试看,P 选择器形式对参数做了引号/字面量解析,处理器收到的仍是解析后的字符串(如'true');而name/selector前缀形式则不做这种参数解析,直接把分隔符后的原文交给处理器。
4. 非法命名会精确报错
Puppeteer.registerCustomQueryHandler('1/2/3', { queryOne: () => document.querySelector('foo'), }); // 抛出:Error: Custom query handler names may only contain [a-zA-Z]与 elementhandle.test.ts 中should throw with invalid query names用例断言完全一致。
六、实践注意事项
- 作用域是全局且按名称唯一:注册表是 Node 侧单例(CustomQueryHandler.ts 末尾的
export const customQueryHandlers = new CustomQueryHandlerRegistry()),整个 Node 进程共享。测试代码普遍在afterEach中调用Puppeteer.clearCustomQueryHandlers()清理,长驻进程(如测试套件)应遵循同样习惯; - 函数体必须自包含:如第三节所述,函数会被字符串化后注入页面执行,避免依赖 Node 侧闭包变量;实现里只能操作
node参数与 DOM; - 重复注册先注销:
Cannot register over existing handler是断言错误而非警告,若需要“更新”处理器,先unregisterCustomQueryHandler再registerCustomQueryHandler; - 前缀写法二选一:
name/selector与name=selector等价(QUERY_SEPARATORS = ['=', '/']);注意选择器内部若含/(如某些 URL 型自定义语法),只能使用自定义处理器自行解析,框架不保证进一步切分; - 与内置前缀的区别:
aria/、pierce/、xpath/、text/是内置解析器前缀,解析优先级低于自定义名。注册名与内置名相同时可遮蔽内置行为,属于源码结构上允许但不建议的用法。
七、相关源码与文档索引
- 接口定义与注册表:CustomQueryHandler.ts(
CustomQueryHandlerL21–L30,register校验 L74–L126) - 页面注入侧注册器:CustomQuerySelector.ts(
register自动补齐 L25–L48) - 注入脚本拼接:ScriptInjector.ts(
#get()L40–L51) - 公共静态 API:Puppeteer.ts(L69–L95)
- 选择器路由与优先级:GetQueryHandler.ts(
QUERY_SEPARATORSL25,解析循环 L35–L55) - 注册/注销/命名校验测试:elementhandle.test.ts(
Custom queries块 L938 起) - 与 P 选择器组合的测试:queryhandler.test.ts(L527–L594)
- 配套 API 文档:registerCustomQueryHandler、unregisterCustomQueryHandler、customQueryHandlerNames、clearCustomQueryHandlers
【免费下载链接】puppeteerJavaScript API for Chrome and Firefox项目地址: https://gitcode.com/GitHub_Trending/puppeteer1/puppeteer
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考