Puppeteer TouchError 深度解析:触摸输入状态守卫机制与 CDP/BiDi 双协议实现
TouchError 是 Puppeteer 触摸模拟 API(Touchscreen / TouchHandle)中专门用于标记触摸生命周期非法操作的错误类型。本文以仓库文档 puppeteer.toucherror.md 为核心,结合 puppeteer-core 输入抽象层、CDP 与 WebDriver BiDi 两套具体实现,讲清 TouchError 的类结构、触发条件、底层调用链以及编写可触摸交互脚本时的规避与捕获方法。读完本文,你将能够准确定位触摸脚本抛错的原因,并用错误类型做可靠的异常分支处理。
TouchError 类定义与异常体系定位
官方文档对 TouchError 的定义非常明确:
TouchError is thrown when an attempt is made to move or end a touch that does not exist. (当尝试移动或结束一个不存在的触摸时抛出 TouchError。)
其签名与继承关系如下:
export declare class TouchError extends PuppeteerError
在源码中,TouchError 定义于 Errors.ts,是一个非常轻量的错误类型:
/**
* TouchError is thrown when an attempt is made to move or end a touch that does
* not exist.
* @public
*/
export class TouchError extends PuppeteerError {}
它的全部能力来自基类 PuppeteerError,后者也是所有 Puppeteer 专属错误的统一基类:
export class PuppeteerError extends Error {
constructor(message?: string, options?: ErrorOptions) {
super(message, options);
this.name = this.constructor.name;
}
get [Symbol.toStringTag](): string {
return this.constructor.name;
}
}
从源码结构看,这里有两个值得注意的实现细节:
this.name = this.constructor.name:Error.name会被设置为实际构造器名,因此捕获到的错误对象name属性就是字符串"TouchError",便于按名称做字符串级判断;Symbol.toStringTag同样返回构造器名,保证String(err)输出时显示为TouchError: ...而非Error: ...,在日志与调试器中更易辨识。
该错误通过 common.ts 中的 export * from './Errors.js' 一路聚合导出,最终成为 puppeteer-core 包的公共 API,使用者可以直接从包入口 import { TouchError } from 'puppeteer-core'(或 puppeteer)。
与同文件中的其他错误类型放在一起对比,可以看清 TouchError 在异常体系中的位置:
| 错误类 | 语义 | 典型触发场景 |
|---|---|---|
| PuppeteerError | 所有 Puppeteer 专属错误的基类 | — |
TimeoutError |
操作因超时被终止 | page.waitForSelector、launch 等 |
TouchError |
触摸状态机处于非法状态 | 未 touchStart 就 touchMove/touchEnd、对同一触摸句柄重复 start |
ProtocolError |
底层协议返回错误(带 code 与 originalMessage) |
CDP/BiDi 消息失败 |
UnsupportedOperation |
当前协议不支持该操作 | 例如 BiDi 模式下调用鼠标 drag 系列方法 |
TouchError 与 ProtocolError 的区别值得强调:前者是 Puppeteer 在发送协议命令之前基于本地状态做出的守卫性抛错,后者才是浏览器协议本身返回的失败。
触发点一:抽象层对“触摸不存在”的守卫
TouchError 文档描述的“move or end a touch that does not exist”对应的是 api/Input.ts 中抽象类 Touchscreen 的两处抛错逻辑。先回顾 Touchscreen 的公共接口:
export abstract class Touchscreen {
idGenerator = createIncrementalIdGenerator();
touches: TouchHandle[] = [];
/** 依次派发 touchstart 和 touchend 事件(一次原子轻点)。 */
async tap(x: number, y: number): Promise<void> {
const touch = await this.touchStart(x, y);
await touch.end();
}
/** 派发 touchstart 事件,返回本次触摸的句柄。 */
abstract touchStart(x: number, y: number): Promise<TouchHandle>;
/** 在第一个激活的触摸上派发 touchMove 事件。 */
async touchMove(x: number, y: number): Promise<void> {
const touch = this.touches[0];
if (!touch) {
throw new TouchError('Must start a new Touch first');
}
return await touch.move(x, y);
}
/** 在第一个激活的触摸上派发 touchend 事件。 */
async touchEnd(): Promise<void> {
const touch = this.touches.shift();
if (!touch) {
throw new TouchError('Must start a new Touch first');
}
await touch.end();
}
}
从源码结构看,Touchscreen 维护了一个 touches 数组作为“当前激活触摸”的注册表,其语义要点有三:
touchMove只作用于touches[0]:即数组中第一个触摸。多指手势场景下,touchMove总是移动最早开始的那一个触摸;touchEnd采用 FIFO 出队:this.touches.shift()取出最先进入的触摸并结束它,触摸句柄按开始顺序被逐一释放;- 空表即抛错:两个方法都在注册表为空时抛出
TouchError('Must start a new Touch first')——这正是文档中“move or end a touch that does not exist”的直接实现。
另外,touchMove 的文档备注提醒了一个与错误无关但影响体验的事实:并非每次 touchMove 调用都会产生 touchmove 事件,Chrome 会对触摸移动事件做节流(throttling)。因此“没收到事件”和“抛 TouchError”是两类完全不同的问题,排错时应先区分。
触发点二:CDP 实现中的 “Touch has already started”
Touchscreen 是抽象基类,真正的触摸派发由协议特定实现完成。CDP 路径的实现位于 cdp/Input.ts。
CdpTouchscreen.touchStart 负责构造 CDP 触摸点并创建句柄:
override async touchStart(x: number, y: number): Promise<TouchHandle> {
const id = this.idGenerator();
const touchPoint: Protocol.Input.TouchPoint = {
x: Math.round(x),
y: Math.round(y),
radiusX: 0.5,
radiusY: 0.5,
force: 0.5,
id,
};
const touch = new CdpTouchHandle(this.#client, this, this.#keyboard, touchPoint);
await touch.start();
this.touches.push(touch);
return touch;
}
注意坐标会被 Math.round 取整,触摸点默认半径 0.5、按压力度 force: 0.5,id 来自递增生成器以保证多指触摸 ID 唯一。随后 CdpTouchHandle 内部通过 #started 布尔标志做二次守卫:
async start(): Promise<void> {
if (this.#started) {
throw new TouchError('Touch has already started');
}
await this.#client.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [this.#touchPoint],
modifiers: this.#keyboard._modifiers,
});
this.#started = true;
}
也就是说,CDP 实现中存在第二处 TouchError 触发点:对同一个触摸句柄重复执行 start 会抛出 'Touch has already started'。move 与 end 则分别发送 type: 'touchMove' 与 type: 'touchEnd' 的 Input.dispatchTouchEvent 命令,且都会携带当前键盘 modifiers(即当前按下的修饰键状态),与 CDP 的 touchstart/touchmove/touchend 三阶段模型一一对应。end() 执行后还会调用 #touchScreen.removeHandle(this) 将该句柄从 touches 注册表中移除,从而让基类的 touches.length 语义保持正确。
BiDi 实现:同样的守卫,不同的传输
WebDriver BiDi 路径的实现位于 bidi/Input.ts。BidiTouchHandle 的结构与 CDP 版本同构(同样有 #started 标志),守卫逻辑一致:
async start(options: BidiTouchMoveOptions = {}): Promise<void> {
if (this.#started) {
throw new TouchError('Touch has already started');
}
await this.#page.mainFrame().browsingContext.performActions([
{
type: SourceActionsType.Pointer,
id: this.#bidiId, // 形如 `finger_<id>`
parameters: { pointerType: Bidi.Input.PointerType.Touch },
actions: [
{ type: ActionType.PointerMove, x: this.#x, y: this.#y, origin: options.origin },
{ ...this.#properties, type: ActionType.PointerDown, button: 0 },
],
},
]);
this.#started = true;
}
差异在于传输语义:CDP 直接调用 Input.dispatchTouchEvent,而 BiDi 通过 performActions 发送一组指针动作——pointerType 为 Touch 的 PointerMove + PointerDown 序列表示按下,PointerMove 表示移动,PointerUp 表示抬起。BidiTouchscreen.touchStart 还会设置 width/height = 1(注释说明为 2 倍默认触摸半径)与 pressure: 0.5 等指针公共属性。这意味着无论底层协议是 CDP 还是 BiDi,使用者观察到的 TouchError 行为保持一致:这正是抽象层把守卫逻辑上提到 Touchscreen、协议层保留 start 守卫的收益。
错误消息速查与典型复现代码
结合上文三处抛错点,TouchError 目前有两种错误消息:
| 消息 | 位置 | 语义 |
|---|---|---|
Must start a new Touch first |
api/Input.ts 的 touchMove/touchEnd |
touches 注册表为空时尝试移动/结束触摸,即“操作了一个不存在的触摸” |
Touch has already started |
cdp/Input.ts 与 bidi/Input.ts 的 TouchHandle.start |
对同一触摸句柄重复执行 start |
下面是一段可直接运行的复现脚本(以 puppeteer 包为例):
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
const page = await browser.newPage();
// 场景 1:未 touchStart 就 touchMove -> "Must start a new Touch first"
try {
await page.touchscreen.touchMove(10, 10);
} catch (err) {
console.log(err.name); // TouchError
console.log(String(err)); // TouchError: Must start a new Touch first
}
// 场景 2:合法生命周期 touchStart -> touchMove -> touchEnd
const touch = await page.touchscreen.touchStart(10, 10);
await page.touchscreen.touchMove(15, 15);
await page.touchscreen.touchEnd();
// 等价于原子调用:
await page.touchscreen.tap(20, 20);
// 场景 3:重复 start 同一句柄(CDP/BiDi 实现内部)
try {
await touch.start();
} catch (err) {
console.log(String(err)); // TouchError: Touch has already started
}
await browser.close();
生产代码中推荐用 instanceof 做类型化捕获,这样对未来的错误消息调整也更稳健:
import { TouchError, PuppeteerError } from 'puppeteer';
try {
await page.touchscreen.touchEnd();
} catch (err) {
if (err instanceof TouchError) {
// 触摸状态错误:通常是调用顺序问题,重试前应先 touchStart
} else if (err instanceof PuppeteerError) {
// 其他 Puppeteer 专属错误
} else {
throw err;
}
}
测试用例中的验证方式
仓库自带的触摸测试 touchscreen.test.ts 覆盖了与 TouchError 直接相关的行为路径,例如 Touchscreen.prototype.touchMove 用例中“先 touchStart 再连续 touchMove、最后 touchEnd”的标准序列:
await page.touchscreen.touchStart(10, 10);
await page.touchscreen.touchMove(15, 15);
await page.touchscreen.touchMove(30.5, 30);
await page.touchscreen.touchMove(50, 45.4);
await page.touchscreen.touchMove(80, 50);
await page.touchscreen.touchEnd();
其中 touchMove 系列用例还验证了多触摸场景(“should work with two touches”“should work with three touches”“should work when moving touches separately”),这些用例在 TestExpectations.json 中有按浏览器平台区分的预期结果记录。这些用例与上文 touches 注册表的 FIFO 语义相互印证:touchMove 始终作用于第一个激活触摸,多指手势的行为边界也因此可测试、可预期。
实战要点:如何规避 TouchError
- 优先使用原子 API:单击、点按类操作直接用
page.touchscreen.tap(x, y)或 ElementHandle.tap,tap内部自动完成touchStart+touch.end(),不存在状态错配空间; - 手动编排时严格遵循 start → move* → end 顺序:
touchscreen.touchMove/touchscreen.touchEnd操作的是“第一个激活触摸”,没有激活触摸时必然抛TouchError;若脚本异常中断导致触摸“悬挂”,后续脚本应重建页面或显式touchEnd收尾; - 不要把
TouchHandle当作可重入对象:每个句柄只能start一次(重复即Touch has already started),且其end()会把句柄从注册表移除,结束后的句柄不应再被move/end; - 捕获时按类型而非消息字符串判断:
instanceof TouchError是稳定契约,错误消息文案则可能随实现演进; - 区分“无事件”与“有错误”:
touchMove可能因浏览器节流而不产生touchmove事件,这属于正常行为,不应与TouchError混为一谈。
延伸阅读
- TouchError 官方 API 文档
- PuppeteerError 基类文档
- Touchscreen API 文档 与 TouchHandle API 文档
- 错误定义源码:common/Errors.ts
- 抽象层输入 API:api/Input.ts
- CDP 触摸实现:cdp/Input.ts
- BiDi 触摸实现:bidi/Input.ts
- 触摸行为测试:test/src/touchscreen.test.ts
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 StartedRust0629
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