首页
/ Puppeteer TouchError 深度解析:触摸输入状态守卫机制与 CDP/BiDi 双协议实现

Puppeteer TouchError 深度解析:触摸输入状态守卫机制与 CDP/BiDi 双协议实现

2026-09-07 14:50:54作者:秋泉律Samson

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.nameError.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.waitForSelectorlaunch
TouchError 触摸状态机处于非法状态 touchStarttouchMove/touchEnd、对同一触摸句柄重复 start
ProtocolError 底层协议返回错误(带 codeoriginalMessage CDP/BiDi 消息失败
UnsupportedOperation 当前协议不支持该操作 例如 BiDi 模式下调用鼠标 drag 系列方法

TouchErrorProtocolError 的区别值得强调:前者是 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 数组作为“当前激活触摸”的注册表,其语义要点有三:

  1. touchMove 只作用于 touches[0]:即数组中第一个触摸。多指手势场景下,touchMove 总是移动最早开始的那一个触摸;
  2. touchEnd 采用 FIFO 出队this.touches.shift() 取出最先进入的触摸并结束它,触摸句柄按开始顺序被逐一释放;
  3. 空表即抛错:两个方法都在注册表为空时抛出 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.5id 来自递增生成器以保证多指触摸 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'moveend 则分别发送 type: 'touchMove'type: 'touchEnd'Input.dispatchTouchEvent 命令,且都会携带当前键盘 modifiers(即当前按下的修饰键状态),与 CDP 的 touchstart/touchmove/touchend 三阶段模型一一对应。end() 执行后还会调用 #touchScreen.removeHandle(this) 将该句柄从 touches 注册表中移除,从而让基类的 touches.length 语义保持正确。

BiDi 实现:同样的守卫,不同的传输

WebDriver BiDi 路径的实现位于 bidi/Input.tsBidiTouchHandle 的结构与 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 发送一组指针动作——pointerTypeTouchPointerMove + 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.tstouchMove/touchEnd touches 注册表为空时尝试移动/结束触摸,即“操作了一个不存在的触摸”
Touch has already started cdp/Input.tsbidi/Input.tsTouchHandle.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

  1. 优先使用原子 API:单击、点按类操作直接用 page.touchscreen.tap(x, y)ElementHandle.taptap 内部自动完成 touchStart + touch.end(),不存在状态错配空间;
  2. 手动编排时严格遵循 start → move* → end 顺序touchscreen.touchMove / touchscreen.touchEnd 操作的是“第一个激活触摸”,没有激活触摸时必然抛 TouchError;若脚本异常中断导致触摸“悬挂”,后续脚本应重建页面或显式 touchEnd 收尾;
  3. 不要把 TouchHandle 当作可重入对象:每个句柄只能 start 一次(重复即 Touch has already started),且其 end() 会把句柄从注册表移除,结束后的句柄不应再被 move/end
  4. 捕获时按类型而非消息字符串判断instanceof TouchError 是稳定契约,错误消息文案则可能随实现演进;
  5. 区分“无事件”与“有错误”touchMove 可能因浏览器节流而不产生 touchmove 事件,这属于正常行为,不应与 TouchError 混为一谈。

延伸阅读

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

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
916
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388