图形编辑器架构入坑笔记

30次阅读
没有评论

共计 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 Eventspointerdown/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 文档)
  • 若需跨上下文对齐,可比较 timeStampperformance.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 实施步骤(落地顺序)

  1. 确定事件模型(Pointer+Keyboard),写 EventFactory。(MDN Web 文档)
  2. 搭好 EventSystem(状态机 + 优先级 + 中间件 + 短路),产出 requestRender
  3. 先落地 PanHandler(你已完成)→ 再加 Zoom/Select/Draw。
  4. 渲染通信:EventEmitter→requestAnimationFrame 批渲染。(MDN Web 文档)
  5. 性能开关wheel 非被动、Pointer Capture、合并事件 /OffscreenCanvas 按需启用。(MDN Web 文档)

正文完
 0
一诺
版权声明:本站原创文章,由 一诺 于2025-10-08发表,共计9441字。
转载说明:除特殊说明外本站文章皆由CC-4.0协议发布,转载请注明出处。
评论(没有评论)
验证码