共计 9441 个字符,预计需要花费 24 分钟才能阅读完成。
笔记摘要 – 写在前面
- 为何要独立于框架的事件系统:复杂交互、性能与扩展性诉求
- Figma 式五层架构:DOM→事件工厂→核心管理→中间件→处理器→渲染通信
- 统一事件模型:优先采用 Pointer Events,一处抽象兼容鼠标 / 触控 / 笔(压力、倾斜等)(MDN Web 文档)
- 洋葱模型中间件:日志 / 权限 / 性能监测等横切关注点(类 Koa)(eggjs.org)
- 状态机 / 责任链调度:优先级、短路与可测试性
- 高性能渲染:
requestAnimationFrame批处理、按需重绘、OffscreenCanvas(可选)(MDN Web 文档) - 时间戳与采样:
event.timeStamp/performance.now()、合并事件 / 原始高频事件(绘图 / 涂抹类)(MDN Web 文档)
0. 为什么不要直接用 React 事件
React 的 SyntheticEvent 是跨浏览器封装,不与原生事件一一对应;如需原生事件可取 nativeEvent。但复杂 Canvas 场景(多输入源、优先级、中间件、指针捕获)往往更适合 直接绑定 DOM 事件 以降低开销与获得更细粒度控制。(legacy.reactjs.org)
要点:把 UI(React/Vue)当“外壳”,事件系统自成一体,通过事件总线与渲染器通信。
1. 五层架构(责任清晰,可扩展)
┌──────────────────────────┐
│ 处理器层 (EventHandlers)│ ← 业务:平移 / 选择 / 绘制 / 缩放…
├──────────────────────────┤
│ 中间件层 (Middleware) │ ← 日志 / 权限 / 性能 / 拦截 & 后置
├──────────────────────────┤
│ 核心管理层 (EventSystem)│ ← 状态机 + 优先级分发 + 短路
├──────────────────────────┤
│ 事件工厂层 (Factory) │ ← 原生→统一事件:坐标 / 时间戳 / 控制
├──────────────────────────┤
│ DOM 事件层 (DOM) │ ← 原生 Pointer/Keyboard/Wheel 等
└──────────────────────────┘
↘ EventEmitter → 渲染 / 坐标系统
- 统一采用 Pointer Events(
pointerdown/move/up/wheel等),一次抽象适配鼠标 / 触摸 / 笔,并可用setPointerCapture()保证拖拽期间事件稳定到同一元素。(MDN Web 文档) - 中间件为洋葱模型(Koa 风格):前置 / 后置逻辑天然成立。(eggjs.org)
2. 事件工厂:把原生事件“标准化”
目标 :统一时间戳、坐标、修饰键、阻止默认等;产出 应用层事件 BaseEvent/PointerEvt/KeyEvt。
// types.ts
export interface BaseEvent {
type: string;
ts: number; // DOMHighResTimeStamp
preventDefault: () => void;
stopPropagation: () => void;}
export interface PointerEvt extends BaseEvent {
type: "pointer.down" | "pointer.move" | "pointer.up" | "wheel";
pointerId: number;
pointerType: "mouse" | "pen" | "touch";
x: number; y: number;
pressure?: number; tiltX?: number; tiltY?: number;
ctrl?: boolean; alt?: boolean; shift?: boolean; meta?: boolean;
}
export interface KeyEvt extends BaseEvent {
type: "key.down" | "key.up";
key: string; code: string;
}
// EventFactory.ts
export class EventFactory {static fromPointer(e: PointerEvent | WheelEvent): PointerEvt {
const p = e as PointerEvent;
return {type: this.mapPointerType(e.type),
ts: e.timeStamp, // 使用高分辨率时间戳
x: p.clientX, y: p.clientY,
pointerId: p.pointerId ?? 0,
pointerType: (p as any).pointerType ?? "mouse",
pressure: (p as any).pressure,
tiltX: (p as any).tiltX, tiltY: (p as any).tiltY,
ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, meta: e.metaKey,
preventDefault: () => e.preventDefault(),
stopPropagation: () => e.stopPropagation(),
};
}
static fromKey(e: KeyboardEvent): KeyEvt {
return {
type: e.type === "keydown" ? "key.down" : "key.up",
ts: e.timeStamp,
key: e.key, code: e.code,
preventDefault: () => e.preventDefault(),
stopPropagation: () => e.stopPropagation(),
};
}
private static mapPointerType(t: string) {if (t === "pointerdown") return "pointer.down";
if (t === "pointermove") return "pointer.move";
if (t === "pointerup") return "pointer.up";
if (t === "wheel") return "wheel";
return t as any;
}
}
- 为什么用
event.timeStamp:DOM 高分辨率时间戳,单调递增,精度优于Date.now();做轨迹速度 / 动量计算更准确。(MDN Web 文档) - 若需跨上下文对齐,可比较
timeStamp与performance.now()(同为 HighResTimeStamp 语义)。(MDN Web 文档)
3. DOM 事件绑定:细节与陷阱
function bindDOM(canvas: HTMLCanvasElement, sys: EventSystem) {const onPtr = (e: PointerEvent) => sys.handle(EventFactory.fromPointer(e));
const onWheel = (e: WheelEvent) => sys.handle(EventFactory.fromPointer(e));
const onKey = (e: KeyboardEvent) => sys.handle(EventFactory.fromKey(e));
canvas.addEventListener("pointerdown", onPtr, { passive: true});
canvas.addEventListener("pointermove", onPtr, { passive: true});
canvas.addEventListener("pointerup", onPtr, { passive: true});
// 缩放 / 滚动:需要阻止默认滚屏 → 必须 non-passive
canvas.addEventListener("wheel", onWheel, { passive: false});
// 右键:防默认菜单
canvas.addEventListener("contextmenu", e => e.preventDefault());
// 键盘事件通常绑到 window
window.addEventListener("keydown", onKey);
window.addEventListener("keyup", onKey);
}
- wheel 监听必须
passive:false,否则preventDefault()会被忽略(浏览器将警告)。(MDN Web 文档) - 拖拽过程中可在
pointerdown时对canvas.setPointerCapture(e.pointerId),避免指针移出画布丢事件。(MDN Web 文档) - 统一用 Pointer Events,比老的 mouse/touch API 更适合多输入设备。(MDN Web 文档)
4. 核心管理:状态机 + 责任链 + 洋葱中间件
export type InteractionState = "idle" | "hover" | "dragging" | "panning" | "zooming";
export interface EventResult {
handled: boolean;
newState?: InteractionState;
requestRender?: boolean;
data?: Record<string, unknown>;
}
export interface EventHandler {
name: string;
priority: number;
canHandle(evt: BaseEvent, state: InteractionState): boolean;
handle(evt: BaseEvent, ctx: EventContext): Promise<EventResult> | EventResult;
}
export interface Middleware {
name: string;
process(evt: BaseEvent, ctx: EventContext, next: () => Promise<EventResult>): Promise<EventResult>;
}
// 责任链 + 洋葱模型
export class EventSystem {private handlers: EventHandler[] = [];
private middlewares: Middleware[] = [];
private state: InteractionState = "idle";
private bus = new (require("events").EventEmitter)(); // 或 tiny-emitter/ 自研
use(mw: Middleware) {this.middlewares.push(mw); }
register(h: EventHandler) {this.handlers.push(h); }
async handle(evt: BaseEvent) {const res = await this.runMiddlewares(evt, 0);
if (res.newState && res.newState !== this.state) this.state = res.newState;
if (res.requestRender) this.bus.emit("render:request");
this.bus.emit("event:processed", { evt, res, state: this.state});
}
private async runMiddlewares(evt: BaseEvent, i: number): Promise<EventResult> {if (i >= this.middlewares.length) return this.dispatch(evt);
const mw = this.middlewares[i];
return mw.process(evt, { /* … */}, () => this.runMiddlewares(evt, i + 1));
}
private async dispatch(evt: BaseEvent): Promise<EventResult> {
const list = this.handlers
.filter(h => h.canHandle(evt, this.state))
.sort((a, b) => b.priority - a.priority);
for (const h of list) {const r = await h.handle(evt, { /* … */});
if (r?.handled) return r; // 短路
}
return {handled: false};
}
onRenderRequest(fn: () => void) {this.bus.on("render:request", fn); }
}
- 洋葱模型:前置 / 后置处理天然支持,适合日志、性能、权限审计等横切关注点(Koa 同款模型)。(eggjs.org)
- 事件总线:
EventEmitter风格发布渲染与处理结果,解耦事件系统与渲染器。(Node.js)
5. 示例处理器:画布平移 CanvasPanHandler
你的实现已经很完整。这里给出“Pointer Events 版”的小幅增益:捕获指针、计算增量、请求渲染。
export class CanvasPanHandler implements EventHandler {
name = "canvas-pan";
priority = 110;
private panning = false;
private last?: {x: number; y: number};
canHandle(evt: BaseEvent) {
// 可结合 toolStore 或状态机判断
return evt.type.startsWith("pointer") || evt.type === "key.down" || evt.type === "key.up";
}
handle(evt: BaseEvent): EventResult {if (evt.type === "pointer.down") {
const e = evt as PointerEvt;
(e as any).target?.setPointerCapture?.(e.pointerId);
this.panning = true; this.last = {x: e.x, y: e.y};
return {handled: true, newState: "panning"};
}
if (evt.type === "pointer.move" && this.panning && this.last) {
const e = evt as PointerEvt;
const dx = e.x - this.last.x, dy = e.y - this.last.y;
coordinateSystem.updateViewPosition(dx, dy);
this.last = {x: e.x, y: e.y};
return {handled: true, newState: "panning", requestRender: true};
}
if (evt.type === "pointer.up") {
this.panning = false; this.last = undefined;
return {handled: true, newState: "idle"};
}
return {handled: false};
}
}
若需要 更高精度绘制 (如涂抹 / 画笔),可在
pointermove里读取 合并事件(coalesced)或监听pointerrawupdate获取高频原始输入。(MDN Web 文档)
6. 坐标系统与视图矩阵(gl-matrix)
- 用
mat3管理 2D 变换(平移 / 缩放 / 旋转),并提供screen↔world转换。(glmatrix.net)
import {mat3, vec2} from "gl-matrix";
export class ViewManager {constructor(private view = mat3.create()) {}
translate(dx: number, dy: number) {mat3.translate(this.view, this.view, [dx, dy]); }
get matrix() { return this.view;}
screenToWorld(x: number, y: number) {const inv = mat3.invert(mat3.create(), this.view)!;
const v = vec2.fromValues(x, y); vec2.transformMat3(v, v, inv);
return {x: v[0], y: v[1] };
}
}
7. 渲染通信 & 性能策略
(1) 渲染触发:事件→总线→渲染器
eventSystem.onRenderRequest(() => scheduleRender());
let rafId = 0;
function scheduleRender() {if (rafId) return;
rafId = requestAnimationFrame(() => {
rafId = 0;
renderer.render(); // 读取最新视图矩阵后再绘制});
}
- 用
requestAnimationFrame合并同帧多次重绘请求,避免抖动。(MDN Web 文档)
(2) OffscreenCanvas(可选)
- 复杂场景可把重绘放到 Worker 内,用 OffscreenCanvas 脱主线程,降低交互卡顿(兼容性需评估)。(MDN Web 文档)
(3) 事件监听细节
wheel非被动;其他高频如pointermove可被动监听,实际阻止默认时再改为非被动。(MDN Web 文档)- 重度绘制工具考虑
getCoalescedEvents()或pointerrawupdate。(MDN Web 文档)
8. 中间件示例(洋葱模型)
// 性能统计
eventSystem.use({
name: "perf",
async process(evt, ctx, next) {const t0 = performance.now();
const res = await next();
const t1 = performance.now();
// 上报或打点 evt.type, t1-t0 …
return res;
}
});
// 热键守卫
eventSystem.use({
name: "hotkeys",
async process(evt, ctx, next) {if (evt.type === "key.down" && (evt as KeyEvt).key === "Escape") {
// 统一清空状态 / 取消操作…
return {handled: true, newState: "idle"};
}
return next();}
});
洋葱链路的优势在于:前置进入 → 下游处理 → 回溯后置,天然支持“日志 / 计时 / 清理”等横切逻辑。(runebook.dev)
9. 与 React 的桥接(壳层)
function CanvasHost() {const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const el = ref.current!;
// 仅绑定原生事件,避免走 React 合成事件
bindDOM(el, eventSystem);
const off = eventSystem.onRenderRequest(() => renderer.render());
return () => {
// 解绑 DOM & 事件总线监听…
off?.();};
}, []);
return <canvas ref={ref} />;
}
React 的合成事件与原生事件不一一对应;你可以把 UI 当配置 / 容器,事件系统与渲染独立演进。如确需原生事件,React 也明确支持从合成事件拿
nativeEvent。(legacy.reactjs.org)
10. 常见扩展:缩放 / 选择 / 绘制
- 缩放 :
wheel(含触控板)+ 组合键(Ctrl/Meta);根据光标位置做 以点为中心的缩放(先平移至原点→缩放→平移回去),最后requestRender。 - 选择与命中:在世界坐标系中做 AABB/ 路径命中,命中结果写入“选择状态”,与渲染层共享。
- 框选 / 套索:按下记录起点,移动时绘制“临时覆盖层”或使用独立的“叠加渲染层”,松开时计算命中集,清理覆盖层并
requestRender。
(实现方式沿用本文的处理器接口与视图矩阵工具即可。)
11. 性能清单(落地版)
- 只在事件结果要求时重绘(
requestRender),用 RAF 合批。(MDN Web 文档) - Pointer Capture 防止拖拽脱靶;
- 事件监听策略:
wheel非被动,其它能被动尽量被动;必要时降频 / 合并(getCoalescedEvents)。(MDN Web 文档) - OffscreenCanvas 把大计算移到 Worker。(MDN Web 文档)
- 绘制裁剪与按需重绘:只重绘受影响区域(可维护脏矩形队列)。
- 矩阵 / 几何复用 :少创建对象;热路径尽量 就地变换(
mat3.translate(new, new, …))。(glmatrix.net)
12. 测试与可维护性
- 处理器单测:传入伪造
PointerEvt/KeyEvt,断言newState/requestRender与坐标变换。 - 中间件链测试:构造顺序 A→B→C,验证前置 / 后置执行次序符合“洋葱模型”。(理念来源于 Koa 文档与生态。)(runebook.dev)
- 端到端:真实
<canvas>上触发 pointer 事件,验证渲染回调次数与帧内耗时(performance.now())。(MDN Web 文档)
附:你文中提到的几个要点 & 官方参考
- Pointer Events(统一输入模型;捕获 / 压力 / 倾斜等):MDN/W3C。(MDN Web 文档)
- Pointer 捕获:
element.setPointerCapture(pointerId)。(MDN Web 文档) - 监听选项:
addEventListener(..., { passive})行为与注意事项。(MDN Web 文档) - RAF 批渲染:
requestAnimationFrame。(MDN Web 文档) - OffscreenCanvas:脱主线程渲染。(MDN Web 文档)
- gl-matrix mat3:矩阵 / 向量运算。(glmatrix.net)
- 合并 / 预测事件:
getCoalescedEvents/pointerrawupdate。(MDN Web 文档) - EventEmitter 模式:解耦事件与渲染通信。(Node.js)
- React SyntheticEvent:差异与
nativeEvent。(legacy.reactjs.org)
TL;DR 实施步骤(落地顺序)
- 确定事件模型(Pointer+Keyboard),写
EventFactory。(MDN Web 文档) - 搭好 EventSystem(状态机 + 优先级 + 中间件 + 短路),产出
requestRender。 - 先落地 PanHandler(你已完成)→ 再加 Zoom/Select/Draw。
- 渲染通信:EventEmitter→
requestAnimationFrame批渲染。(MDN Web 文档) - 性能开关:
wheel非被动、Pointer Capture、合并事件 /OffscreenCanvas 按需启用。(MDN Web 文档)
正文完

