课程进度 课程大纲 已发布 18/18 课
第一部分:落笔之前先选画布——产品、像素与坐标
第二部分:给像素世界装上大脑——模型、调度、输入与工具
第三部分:从“能拖动”到“值得信赖”——交互、文字、资产与恢复
第四部分:大师级决策——性能、Worker、GPU、SDK、协作与 AI
从一个五岁孩子也能理解的游戏开始
本章唯一的真理:产品工具不应直接消费原始浏览器事件,而应消费规范化的输入事件。
拿来一个玩具鼠标、一支蜡笔和一根手指。在桌上放一个红色按钮,请一位“翻译员”坐在旁边。规则只有一条:无论孩子用什么触碰按钮,翻译员只报告“编号、位置、力度,以及动作是开始了还是结束了”。
先预测一下会发生什么。如果孩子按住蜡笔,把它移出桌面,然后松开,按钮还能知道动作“结束”了吗?如果第二根手指突然落下,我们如何区分它们?如果翻译员只能报告“鼠标移动了”,手指和笔还能参与吗?
- 不同的来客触碰、按压、滑动
- 一位翻译员编号、位置、压力
- 一种消息开始、移动、结束、取消
- 工具只读取消息它们不会猜测来客是谁
真实操作中的对应关系很具体:pointerId 就像一张带编号的票;Pointer Capture 就像翻译员暂时保管这张票;pointercancel 就像老师宣布游戏已被中断;而 lostpointercapture 表示这张票确实已被收走。工具收到的是格式一致的消息,因此选择工具不需要分别学习如何处理鼠标、手指和笔。
将玩具映射到 Canvas
| 玩具世界 | Canvas 实验室 | 保证 |
|---|---|---|
| 鼠标、手指、蜡笔 | pointerType 的值 mouse、touch 和 pen | 来源不同,协议相同 |
| 带编号的票 | pointerId | 同时追踪多根手指 |
| 按住按钮 | buttons、pressure | 记录操作状态和压力 |
| 倾斜蜡笔 | tiltX/tiltY、altitudeAngle/azimuthAngle | 支持画笔姿态,并在不可用时提供后备方案 |
| 翻译员保管票据 | setPointerCapture(pointerId) | 指针离开元素后继续接收事件 |
| 老师叫停游戏 | pointercancel、焦点丢失 | 绝不把未完成的操作误当作提交 |
| 一叠密集的微小消息 | 合并事件 | 自由绘制可以保留浏览器合并事件之前的采样点 |
| 猜测下一条消息 | 预测事件 | 仅用于瞬态预览,绝不能写入文档 |
| 手势规则卡 | touch-action | 告诉浏览器哪些直接操作属于页面,哪些属于应用 |
类比到这里就不再适用了:带编号的票不是用户身份,捕获也不会“锁住物理鼠标”。浏览器仍可能因为系统手势、窗口切换、设备断开或元素被移除而取消操作。预测事件也不是未来的事实;真实采样到达后,必须立即替换预测线条。键盘不是二维指针:它拥有独立的焦点、按键重复和修饰键生命周期。
先消灭错误直觉
- “分别监听鼠标、触摸和笔的事件族最安全。” 三个事件族会产生兼容性鼠标事件、重复提交以及三套边界情况。Pointer Events 已经是与硬件无关的统一入口。
- “只监听
pointerup就足以完成一次操作。” 系统滚动、来电和设备切换都可能产生pointercancel;捕获丢失时也必须回滚。 - “
clientX/clientY是世界坐标。” 它们只是视口中的 CSS 像素。你必须减去元素边界,并应用上一章中相机矩阵的逆矩阵。 - “
wheel.deltaY总是以像素为单位。”deltaMode可能表示像素、行或页,而触控板会发送大量带惯性的微小增量。 - “预测点看起来更平滑,所以直接保存它们。” 预测之后会被修正。持久化预测点会让重放依赖浏览器的猜测。
- “拖动开始时读取一次 Shift 就够了。” 修饰键可能在拖动过程中发生变化。每条规范化消息都必须携带该时刻的状态快照。
生产环境工具包
前置契约
Canvas 实验室已经具备第 4 章中的 Camera.screenToWorld(),以及第 6 章中拥有对称 start()/dispose() 生命周期方法的引擎。输入层调用相机,而不是重复实现矩阵代码;它发出事件,但不修改文档。Canvas 元素可以接收焦点,而 DOM 覆盖层仍可接管文本输入。
形式化知识
Pointer Events 统一了 pointerdown/move/up/cancel。pointerId 只在当前页面会话和活动指针的生命周期内区分各个指针;绝不能将其持久化。使用 pointerType 选择能力,例如笔压,但不要因此创建产品层面的分支。buttons 是按钮位掩码。pressure、tiltX/Y、altitudeAngle 和 azimuthAngle 描述设备报告的笔数据;当设备不具备相应能力时,应使用中性值和能力标志,而不是凭空捏造精度。
在 pointerdown 之后捕获主指针,然后在 pointerup、pointercancel 和 lostpointercapture 上执行相同的清理。当第二个指针加入时,手势识别器决定是继续绘制、取消单指针工具,还是进入捏合手势。两个工具绝不能同时拥有同一输入。getCoalescedEvents() 可以向自由绘制提供细粒度采样;getPredictedEvents() 仅属于瞬态预览;只有当测量结果表明确实需要降低延迟且浏览器支持时,才启用 pointerrawupdate。普通选择工具应继续使用 pointermove,以避免不必要的事件洪流。
在应用滚动、平移或缩放策略之前,应根据 deltaMode 规范化 Wheel 值。浏览器没有提供一个始终能可靠证明输入来自“鼠标滚轮还是触控板”的字段,因此不要凭空捏造设备真相;应针对增量序列应用产品规则。捏合手势根据两个活动指针之间的距离和中心点创建 GestureChanged。CSS touch-action: none 会阻止元素内部的浏览器原生平移和缩放,因此只应将它应用于确实拥有这些手势的交互表面;不要扩散到页面导航区域。
键盘控制器追踪 keydown/keyup 和 alt/ctrl/meta/shift 的快照,同时忽略正在编辑文本的覆盖层。在 window.blur 或 visibilitychange 上,清除卡住的按键并取消正在进行的交互。以屏幕 CSS 像素衡量拖动阈值,这样缩放就不会改变操作手感。通过阈值、时间窗口和目标身份共同协调双击。使用可取消的计时器生成长按,并在移动超出阈值、第二个指针加入、焦点丢失或操作被取消时始终清除该计时器。
证据与兼容性(验证于 2026-08-29)
- MDN 指针事件介绍了统一事件、指针捕获、隐式触摸捕获以及与硬件无关的模型。
- MDN
setPointerCapture()和lostpointercapture定义了捕获及捕获丢失通知;你仍然必须处理pointercancel。 - 浏览器对 MDN
getCoalescedEvents()、getPredictedEvents()和pointerrawupdate的支持各不相同,因此生产代码需要进行特性检测。 - MDN WheelEvent、
deltaMode和touch-action是归一化和手势归属的基础。
本章的工程增量
起点: 引擎将 DOM 事件直接交给工具,指针离开画布时偶尔会卡住,而且缩放后无法信任世界坐标点。终点: InputController 是唯一的浏览器输入入口,并发出可辨识的 NormalizedInput;工具只消费这些事件。
添加以下文件和职责:
src/engine/input/InputController.ts:监听、捕获、归一化并清理;src/engine/input/types.ts:统一事件与能力;src/engine/input/GestureRecognizer.ts:双指针捏合、长按和双击;src/engine/input/__tests__/InputController.test.ts:取消、坐标、修饰键和清理;tests/browser/input-capture.spec.ts:在真实浏览器中离开元素后释放。
下面的核心实现可直接按原样运行。唯一省略的是产品订阅容器;取消和坐标逻辑均已完整实现:
export type Modifiers = Readonly<{
alt: boolean;
ctrl: boolean;
meta: boolean;
shift: boolean;
}>;
export type PointerSample = Readonly<{
pointerId: number;
pointerType: string;
screen: DOMPointReadOnly;
world: DOMPointReadOnly;
buttons: number;
modifiers: Modifiers;
timestamp: number;
pressure: number;
tilt: readonly [number, number];
altitude: number | null;
azimuth: number | null;
captured: boolean;
predicted: boolean;
}>;
export type NormalizedInput =
| (PointerSample & { kind: 'PointerStarted' | 'PointerMoved' | 'PointerEnded' })
| (PointerSample & { kind: 'PointerCanceled'; reason: 'browser' | 'capture-lost' | 'focus-lost' })
| {
kind: 'WheelZoomed';
screen: DOMPointReadOnly;
world: DOMPointReadOnly;
deltaPixels: number;
modifiers: Modifiers;
timestamp: number;
}
| { kind: 'KeyChanged'; code: string; down: boolean; modifiers: Modifiers; timestamp: number }
| {
kind: 'GestureChanged';
gesture: 'pinch' | 'long-press' | 'double-click';
phase: 'start' | 'change' | 'end' | 'cancel';
screen: DOMPointReadOnly;
world: DOMPointReadOnly;
scale: number;
rotation: number;
timestamp: number;
};
export interface CameraPort {
screenToWorld(point: DOMPointReadOnly): DOMPointReadOnly;
}
const mods = (event: MouseEvent | KeyboardEvent): Modifiers => ({
alt: event.altKey,
ctrl: event.ctrlKey,
meta: event.metaKey,
shift: event.shiftKey,
});
export class InputController {
private readonly active = new Set<number>();
private readonly lastPointers = new Map<number, PointerSample>();
private readonly pressedKeys = new Set<string>();
private readonly previousTouchAction: string;
private readonly previousTabIndex: string | null;
private disposed = false;
constructor(
private readonly element: HTMLElement,
private readonly camera: CameraPort,
private readonly emit: (event: NormalizedInput) => void,
) {
this.previousTouchAction = element.style.touchAction;
this.previousTabIndex = element.getAttribute('tabindex');
element.style.touchAction = 'none';
if (this.previousTabIndex === null) element.tabIndex = 0;
element.addEventListener('pointerdown', this.onDown);
element.addEventListener('pointermove', this.onMove);
element.addEventListener('pointerup', this.onUp);
element.addEventListener('pointercancel', this.onCancel);
element.addEventListener('lostpointercapture', this.onLostCapture);
element.addEventListener('wheel', this.onWheel, { passive: false });
element.addEventListener('keydown', this.onKeyDown);
element.addEventListener('keyup', this.onKeyUp);
window.addEventListener('blur', this.onBlur);
document.addEventListener('visibilitychange', this.onVisibilityChange);
}
private points(event: PointerEvent) {
const rect = this.element.getBoundingClientRect();
const screen = new DOMPointReadOnly(event.clientX - rect.left, event.clientY - rect.top);
return { screen, world: this.camera.screenToWorld(screen) };
}
private pointerSample(event: PointerEvent, predicted = false): PointerSample {
const { screen, world } = this.points(event);
const altitude = 'altitudeAngle' in event ? event.altitudeAngle : null;
const azimuth = 'azimuthAngle' in event ? event.azimuthAngle : null;
return {
pointerId: event.pointerId,
pointerType: event.pointerType,
screen,
world,
buttons: event.buttons,
modifiers: mods(event),
timestamp: event.timeStamp,
pressure: event.pressure,
tilt: [event.tiltX, event.tiltY],
altitude,
azimuth,
captured: this.element.hasPointerCapture(event.pointerId),
predicted,
};
}
private emitPointer(
event: PointerEvent,
kind: 'PointerStarted' | 'PointerMoved' | 'PointerEnded',
predicted = false,
) {
const sample = this.pointerSample(event, predicted);
if (!predicted) this.lastPointers.set(event.pointerId, sample);
this.emit({ ...sample, kind });
}
private onDown = (event: PointerEvent) => {
this.active.add(event.pointerId);
try {
this.element.setPointerCapture(event.pointerId);
} catch {
// Still emit Started if the element was removed or the UA rejected Capture;
// captured:false lets the upper layer choose a fallback strategy.
}
this.emitPointer(event, 'PointerStarted');
};
private onMove = (event: PointerEvent) => {
if (!this.active.has(event.pointerId)) return;
const coalesced =
typeof event.getCoalescedEvents === 'function' ? event.getCoalescedEvents() : [event];
const samples = coalesced.length ? coalesced : [event];
for (const sample of samples) this.emitPointer(sample, 'PointerMoved');
if (typeof event.getPredictedEvents === 'function') {
for (const sample of event.getPredictedEvents())
this.emitPointer(sample, 'PointerMoved', true);
}
};
private onUp = (event: PointerEvent) => {
if (!this.active.delete(event.pointerId)) return;
this.emitPointer(event, 'PointerEnded');
if (this.element.hasPointerCapture(event.pointerId))
this.element.releasePointerCapture(event.pointerId);
this.lastPointers.delete(event.pointerId);
};
private cancel(
pointerId: number,
reason: 'browser' | 'capture-lost' | 'focus-lost',
event?: PointerEvent,
) {
if (!this.active.delete(pointerId)) return;
const previous = event ? this.pointerSample(event) : this.lastPointers.get(pointerId);
if (previous)
this.emit({
...previous,
captured: this.element.hasPointerCapture(pointerId),
predicted: false,
kind: 'PointerCanceled',
reason,
});
this.lastPointers.delete(pointerId);
}
private onCancel = (event: PointerEvent) => this.cancel(event.pointerId, 'browser', event);
private onLostCapture = (event: PointerEvent) =>
this.cancel(event.pointerId, 'capture-lost', event);
private onKeyDown = (event: KeyboardEvent) => {
this.pressedKeys.add(event.code);
this.emit({
kind: 'KeyChanged',
code: event.code,
down: true,
modifiers: mods(event),
timestamp: event.timeStamp,
});
};
private onKeyUp = (event: KeyboardEvent) => {
this.pressedKeys.delete(event.code);
this.emit({
kind: 'KeyChanged',
code: event.code,
down: false,
modifiers: mods(event),
timestamp: event.timeStamp,
});
};
private onBlur = () => {
for (const id of [...this.active]) this.cancel(id, 'focus-lost');
for (const code of this.pressedKeys)
this.emit({
kind: 'KeyChanged',
code,
down: false,
modifiers: { alt: false, ctrl: false, meta: false, shift: false },
timestamp: performance.now(),
});
this.pressedKeys.clear();
};
private onVisibilityChange = () => {
if (document.visibilityState === 'hidden') this.onBlur();
};
private onWheel = (event: WheelEvent) => {
event.preventDefault();
const unit =
event.deltaMode === WheelEvent.DOM_DELTA_LINE
? 16
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE
? Math.max(1, this.element.clientHeight)
: 1;
const rect = this.element.getBoundingClientRect();
const screen = new DOMPointReadOnly(event.clientX - rect.left, event.clientY - rect.top);
this.emit({
kind: 'WheelZoomed',
screen,
world: this.camera.screenToWorld(screen),
deltaPixels: event.deltaY * unit,
modifiers: mods(event),
timestamp: event.timeStamp,
});
};
dispose() {
if (this.disposed) return;
this.disposed = true;
this.onBlur();
this.element.removeEventListener('pointerdown', this.onDown);
this.element.removeEventListener('pointermove', this.onMove);
this.element.removeEventListener('pointerup', this.onUp);
this.element.removeEventListener('pointercancel', this.onCancel);
this.element.removeEventListener('lostpointercapture', this.onLostCapture);
this.element.removeEventListener('wheel', this.onWheel);
this.element.removeEventListener('keydown', this.onKeyDown);
this.element.removeEventListener('keyup', this.onKeyUp);
window.removeEventListener('blur', this.onBlur);
document.removeEventListener('visibilitychange', this.onVisibilityChange);
this.element.style.touchAction = this.previousTouchAction;
if (this.previousTabIndex === null) this.element.removeAttribute('tabindex');
else this.element.setAttribute('tabindex', this.previousTabIndex);
}
}
GestureRecognizer 订阅这些真实样本,而不再重复监听 DOM。它按照 pointerId 存储两个活动指针的初始中心、距离和角度。当两个指针都存在时,它会发出 phase:'start';后续样本将缩放比例报告为“当前距离 ÷ 初始距离”,并将旋转报告为角度差;任一指针结束或取消时,它都会恰好发出一次 end/cancel 并清除会话。当移动距离越过拖动阈值、第二个指针加入、焦点丢失或操作被取消时,应清除长按计时器。双击必须同时满足时间窗口、屏幕距离和目标 ID 条件。真实的合并点可以进入自由绘制,而预测点只能用于可替换的预览。pointerrawupdate 可以作为经过特性检测的可选样本源,但不得重复对应 pointermove 中的点。此识别器的序列测试和上述浏览器适配器测试缺一不可。
单元测试将浏览器外壳精简到最低限度,并验证世界坐标转换、使用最后一个完整样本执行取消,以及幂等清理:
import { describe, expect, it, vi } from 'vitest';
import { InputController } from '../InputController';
describe('InputController', () => {
it('converts screen points to world points and cancels only once when capture is lost', () => {
const el = document.createElement('canvas');
Object.defineProperty(el, 'getBoundingClientRect', {
value: () => ({ left: 10, top: 20, width: 300, height: 200 }),
});
el.setPointerCapture = vi.fn();
el.hasPointerCapture = vi.fn(() => true);
const events: unknown[] = [];
const input = new InputController(
el,
{
screenToWorld: (p) => new DOMPointReadOnly(p.x / 2, p.y / 2),
},
(e) => events.push(e),
);
el.dispatchEvent(
new PointerEvent('pointerdown', {
pointerId: 7,
clientX: 50,
clientY: 60,
buttons: 1,
pointerType: 'pen',
}),
);
el.dispatchEvent(new PointerEvent('lostpointercapture', { pointerId: 7 }));
el.dispatchEvent(new PointerEvent('pointercancel', { pointerId: 7 }));
expect(events).toMatchObject([
{ kind: 'PointerStarted', screen: { x: 40, y: 40 }, world: { x: 20, y: 20 } },
{
kind: 'PointerCanceled',
pointerId: 7,
reason: 'capture-lost',
screen: { x: -10, y: -20 },
world: { x: -5, y: -10 },
predicted: false,
},
]);
input.dispose();
input.dispose();
expect(events).toHaveLength(2);
});
});
运行 pnpm vitest run src/engine/input;最后一行应包含 1 passed。运行 pnpm playwright test tests/browser/input-capture.spec.ts;拖动到画布外并释放后,状态应返回 Idle,且事件日志应恰好包含一次结束或取消。最后,分别使用鼠标、触摸屏和压感笔记录一段 JSON 序列。它们的字段结构必须完全相同。
编写完代码后,回到桌面游戏的比喻。InputController 是翻译员;相机是地图,它把“桌子的哪一厘米”转换为“玩具城市的哪条街道”;工具只读取翻译后的纸条。更换一支笔不再意味着重写交通规则。
故意破坏它
| 故障注入 | 症状 | 要收集的证据 | 修复 | 回归测试 | 恢复 |
|---|---|---|---|---|---|
| 按下、离开画布,然后释放 | 永远卡在拖动状态 | 事件序列缺少结束 | 按下时进行捕获 | 使用 Playwright 拖出后释放 | 分派取消并清除临时状态 |
在开发者工具中模拟 pointercancel | 部分工作被提交 | 文档中多出一条命令 | 取消时仅回滚预览 | 断言命令数为 0 | 删除受污染的记录并重放快照 |
| 操作中途添加第二个指针 | 形状跳动并发生缩放 | 两个工具同时拥有该指针 | 手势守卫取消单指针事务 | 重放双指针序列 | 清除活动集合 |
| 拖动期间松开 Shift | 宽高比仍保持锁定 | 事件中的修饰键快照未更新 | 每次移动时读取修饰键 | 中途改变 Shift 状态的测试 | 在下一个事件中重新计算约束 |
| 触控板发送 200 个微小增量 | 缩放失控 | deltaMode 和时间戳序列 | 归一化并按帧聚合 | 固定输入流快照 | 重置累加器 |
| 构造 LINE/PAGE 滚轮事件 | 不同浏览器中的幅度不同 | 比较原始增量与像素增量 | 根据 deltaMode 进行转换 | 对全部三种模式进行参数化测试 | 恢复默认缩放 |
| 显式释放捕获 | 工具卡住 | lostpointercapture 日志 | 统一取消路径 | 捕获丢失测试 | 回滚临时事务 |
用证据证明通过
| 要证明的属性 | 自动化证据 | 手动证据 |
|---|---|---|
| 三种指针类型都进入同一协议 | 类型测试和事件快照 | 真实的鼠标、触摸和笔设备 |
| 离开元素不会丢失完成通知 | 浏览器捕获测试 | 拖动越过窗口边缘 |
| 相机转换只发生一次且保持可逆 | 固定矩阵的点断言 | 在不同缩放级别点击同一个角 |
| 取消不会写入文档 | 命令数断言 | 中断系统手势 |
| 预测点不会被持久化 | 序列化快照中没有 predicted | 慢速重放时没有幽灵线条 |
| 生命周期对称 | 调用两次 Dispose 并检查监听器数量 | 严格模式下事件不会重复 |
- 工具源代码中没有
if mouse ... else if touch ... else if pen ...。 - 每个统一指针事件都包含屏幕点、世界点、类型、按键、修饰键、时间戳、压力和捕获状态。
- 指针捕获、取消、捕获丢失和焦点丢失都进入同一条清理路径。
- 合并点、预测点和原始更新路径都具有特性检测和明确的回退方案。
- 拖动、双击和长按阈值使用屏幕像素,并且在每个缩放级别都具有一致的操作体验。
- 滚轮单位差异和高频微小增量均有可重放的测试。
给五岁小朋友讲明白
回答时不要使用“事件”“捕获”或“坐标”这些词:
- 为什么三种不同的手都需要先去找翻译员?
- 翻译员拿到编号票后,为什么手离开桌子时游戏仍然可以正常结束?
- 为什么“我们猜测的下一个笔画”只能暂时显示,而不能写进画作?
- 新问题:如果一只手正拿着积木时突然停电,你会把积木的新位置写进账本吗?