發布日期:2025年10月3日 | 預計閱讀時間:25 分鐘
最近在重構編輯器 demo 的時候,我重新梳理了事件層的實現。在節點層 → 渲染層之後,本篇重點切換到互動事件系統,也就是 Canvas 如何處理複雜互動,如何設計一個類似 Figma 的獨立事件架構。
點讚 + 收藏 = 支持原創 🤣🤣🤣
上一篇文章我們聊了 數據層與渲染層的綁定機制,今天繼續推進 —— 把視角放到事件層。你會看到一個清晰的五層架構設計,從 DOM 原生事件到業務邏輯處理器,再到渲染層通訊,完整展示現代 Canvas 應用的事件流轉機制。
本篇你將學到:
這個系列文章主要記錄了編輯器從 0 到 1 的實現細節:
之前的文章:
今天我們聊第 4 篇:事件系統設計。
在構建複雜的 Canvas 應用(如 Figma、Sketch 等設計工具)時,傳統的 React 事件或者原生的 DOM 事件面臨著嚴峻的挑戰。隨著應用複雜度的增加,我們需要處理更精細的用戶互動、更複雜的狀態管理,以及更高效的渲染性能。
比如 mousedown 事件,可能處理點擊創建、鉛筆繪製、拖拽事件、畫布平移等,每種型態的事件可能會處理各種的業務邏輯,如何分發,如果處理不好,很容易就會混亂。
// 傳統 React 事件處理的問題
const Canvas = () => {
const handleMouseDown = (e: React.MouseEvent) => {
// ❌ 問題1: 與 React 渲染週期耦合,性能受限
// ❌ 問題2: 事件物件被合成,丟失原生特性
// ❌ 問題3: 複雜互動狀態管理困難
// ❌ 問題4: 缺乏優先級和中間件機制
};
return <canvas onMouseDown={handleMouseDown} />;
};
核心問題分析:
採用了完全獨立於框架的事件系統,實現了統一事件物件,方便內部邏輯統一處理:
事件系統的核心目標是將瀏覽器原生事件解耦、統一並高效分發,讓 Canvas 互動邏輯清晰且可擴展。整個實現流程可分為五大層:
用戶互動 → DOM 原生事件 → 事件工廠層 → 核心管理層 → 中間件層 → 處理器層 → 渲染通信 → Canvas 渲染
具體流程:
DOM 事件層
事件工廠層
核心管理層(EventSystem)
中間件層
處理器層(EventHandler)
渲染通信層
用戶拖曳鼠標
↓
DOM mousedown事件 → EventSystem.handleDOMEvent()
↓
EventFactory.createMouseEvent() → 標準化事件物件
↓
EventSystem.processEvent() → 中間件處理
↓
CanvasPanHandler.canHandle() → 檢查是否可處理
↓
CanvasPanHandler.handleMouseDown() → 開始平移狀態
↓
返回 { handled: true, newState: "panning" }
↓
EventSystem 更新互動狀態
↓
用戶移動鼠標...
↓
DOM mousemove事件 → CanvasPanHandler.handleMouseMove()
↓
計算位移增量 (deltaX, deltaY)
↓
coordinateSystemManager.updateViewPosition()
↓
viewManager.updateTranslation() → 更新變換矩陣
↓
返回 { handled: true, requestRender: true }
↓
eventEmitter.emit("render:request")
↓
SkiaLikeRenderer.performRender() → Canvas重繪
↓
視覺反饋完成 ✨
這個事件系統採用了清晰的分層架構,每一層都有明確的職責邊界:
┌─────────────────┐
│ 處理器層 │ ← 具體業務邏輯(CanvasPanHandler, SelectionHandler等)
├─────────────────┤
│ 中間件層 │ ← 橫切關注點(日誌、權限、快取等)
├─────────────────┤
│ 核心管理層 │ ← 事件分發調度(EventSystem)
├─────────────────┤
│ 事件工廠層 │ ← 標準化轉換(EventFactory)
├─────────────────┤
│ DOM 事件層 │ ← 原生事件監聽
└─────────────────┘
// 基礎事件
export interface BaseEvent {
type: string;
timestamp: number;
preventDefault: () => void;
stopPropagation: () => void;
canceled: boolean;
propagationStopped: boolean;
}
// 鼠標事件
export interface MouseEvent extends BaseEvent {
type: "mouse.down" | "mouse.move" | "mouse.up" | "mouse.wheel";
mousePoint: { x: number; y: number };
}
// 鍵盤事件
export interface KeyboardEvent extends BaseEvent {
type: "key.down" | "key.up";
key: string;
code: string;
}
設計目標:將原生 DOM 事件轉換為應用層統一的事件物件
class EventFactory {
static createMouseEvent(nativeEvent: MouseEvent): CustomMouseEvent {
const point = {
x: nativeEvent.clientX,
y: nativeEvent.clientY,
};
return {
type: this.getMouseEventType(nativeEvent.type),
timestamp: Date.now(), // 精確時間戳
mousePoint: point,
canceled: false,
propagationStopped: false,
// 🎯 保留原生事件的控制能力
preventDefault: () => nativeEvent.preventDefault(),
stopPropagation: () => nativeEvent.stopPropagation(),
};
}
}
關鍵特性:
在前面我們討論了事件系統的統一和指針抽象,但所有的互動最終都來自 瀏覽器原生事件。因此,需要一個 DOM → 事件系統的橋樑,將 Canvas 上的鼠標、觸摸、鍵盤事件統一接入我們設計的事件體系。
/**
* 綁定 DOM 事件到 Canvas
*/
private bindDOMEvents(canvas: HTMLCanvasElement): void {
const listeners = new Map<string, EventListener>();
// 1️⃣ 鼠標事件
const mouseEvents = ["mousedown", "mousemove", "mouseup", "wheel"];
mouseEvents.forEach((eventType) => {
const listener = (e: Event) => this.handleDOMEvent(e as MouseEvent);
canvas.addEventListener(eventType, listener, { passive: false });
listeners.set(eventType, listener);
});
// 2️⃣ 阻止右鍵選單
const contextMenuListener = (e: Event) => {
e.preventDefault();
};
canvas.addEventListener("contextmenu", contextMenuListener);
listeners.set("contextmenu", contextMenuListener);
// 3️⃣ 鍵盤事件(綁定到 window)
const keyboardEvents = ["keydown", "keyup"];
keyboardEvents.forEach((eventType) => {
const listener = (e: Event) => this.handleDOMEvent(e as KeyboardEvent);
window.addEventListener(eventType, listener);
// 使用特殊前綴標記這些是 window 事件
listeners.set(`window:${eventType}`, listener);
});
this.eventListeners.set(canvas, listeners);
}
/**
* 處理 DOM 事件
*/
private async handleDOMEvent(
nativeEvent: MouseEvent | KeyboardEvent
): Promise<void> {
if (!this.context || !this.isActive) return;
let event: BaseEvent;
// 轉換為標準化事件
if (nativeEvent instanceof MouseEvent) {
event = EventFactory.createMouseEvent(nativeEvent);
} else {
event = EventFactory.createKeyboardEvent(nativeEvent);
}
// 處理事件
await this.processEvent(event);
}
統一存儲監聽器
使用 Map<string, EventListener>
記錄每個 Canvas 的監聽器,方便後續解绑或熱更新。
事件統一處理
所有原生事件都會傳給 handleDOMEvent
,在這裡完成指針抽象或狀態機分發,例如把 mousedown
轉成 pointerDown
。
防止預設行為
對 wheel
、contextmenu
等事件進行 preventDefault()
,確保畫布互動不受瀏覽器預設操作干擾。
鍵盤事件綁定到 window
鍵盤事件與畫布尺寸無關,需要全局捕獲,因此綁定到 window
,同時使用前綴標記,便於管理。
這一步實際上是 事件系統落地的關鍵環節:從瀏覽器原生事件進入我們統一的事件體系,為 Canvas 的互動(如平移、縮放、拖拽)提供可靠輸入源。
設計目標:提供統一的事件管理和分發機制
export class EventSystem {
private static instance: EventSystem | null = null;
private handlers: EventHandler[] = [];
private middlewares: EventMiddleware[] = [];
private interactionState: InteractionState = "idle";
// 🔄 責任鏈模式:按優先級處理事件
private async processCoreEvent(event: BaseEvent): Promise<EventResult> {
const availableHandlers = this.handlers
.filter((handler) => handler.canHandle(event, this.interactionState))
.sort((a, b) => b.priority - a.priority); // 優先級排序
for (const handler of availableHandlers) {
const result = await handler.handle(event, this.context);
if (result.handled) return result; // 短路機制
}
return { handled: false };
}
}
核心設計模式:
設計目標:提供橫切關注點的處理能力
interface EventMiddleware {
name: string;
process(
event: BaseEvent,
context: EventContext,
next: () => Promise<EventResult> // 類似 Express 的 next 函數
): Promise<EventResult>;
}
// 洋蔥模型的中間件處理
private async processMiddlewares(event: BaseEvent, index: number): Promise<EventResult> {
if (index >= this.middlewares.length) {
return this.processCoreEvent(event); // 執行核心邏輯
}
const middleware = this.middlewares[index];
const next = () => this.processMiddlewares(event, index + 1);
return middleware.process(event, this.context!, next);
}
設計優勢:
設計目標:封裝具體的業務處理邏輯
interface EventHandler {
name: string; // 處理器識別
priority: number; // 處理優先級
canHandle(event: BaseEvent, state: InteractionState): boolean; // 過濾條件
handle(event: BaseEvent, context: EventContext): Promise<EventResult>; // 處理邏輯
}
interface EventResult {
handled: boolean; // 是否處理成功
newState?: InteractionState; // 新的互動狀態
requestRender?: boolean; // 是否需要重新渲染
data?: Record<string, unknown>; // 附加數據
}
設計目標:實現事件系統與渲染系統的解耦通信
// 事件系統發布渲染請求
this.eventEmitter.emit("render:request");
// 渲染系統監聽並響應
eventSystem.getEventEmitter().on("render:request", renderCallback);
┌─────────────┐ render:request ┌─────────────┐ ViewInfo ┌─────────────┐
│ EventSystem│ ──────────────────→ │CoordinateM │ ────────────→ │SkiaRenderer │
│ │ │anager │ │ │
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
event:processed updateViewPosition Canvas API
│ │ │
↓ ↓ ↓
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│UI Components│ │ ViewManager │ │ Canvas │
└─────────────┘ └─────────────┘ └─────────────┘
private async processEvent(event: BaseEvent): Promise<void> {
try {
const result = await this.processMiddlewares(event, 0);
// 🎯 更新互動狀態
if (result.newState && result.newState !== this.interactionState) {
this.setInteractionState(result.newState);
}
// 🚀 重要:通過 EventEmitter 解耦通信
if (result.requestRender) {
this.eventEmitter.emit("render:request");
}
// 發布事件處理結果,供其他模組使用
this.eventEmitter.emit("event:processed", {
event,
result,
state: this.interactionState,
});
} catch (error) {
console.error("❌ 事件處理失敗:", error);
}
}
// CanvasContainer 組件監聽渲染請求
useEffect(() => {
const eventSystem = eventSystemInitializer.getEventSystem();
// 🔧 監聽渲染請求事件
eventSystem.getEventEmitter().on("render:request", renderSkiaLikeUI);
return () => {
eventSystem.getEventEmitter().off("render:request", renderSkiaLikeUI);
};
}, []);
const renderSkiaLikeUI = useCallback(() => {
if (rendererRef.current) {
// 觸發 Skia 風格渲染
rendererRef.current.render(
<>
<canvas-grid />
<canvas-ruler />
<canvas-page />
</>
);
}
}, []);
export class CoordinateSystemManager {
// 🎯 事件處理器更新視圖狀態
updateViewPosition(deltaX: number, deltaY: number): void {
const currentView = this.getViewState();
const updatedView = viewManager.updateTranslation(currentView, deltaX, deltaY);
this.setViewState(updatedView);
}
// 🔧 提供坐標轉換能力
screenToWorld(screenX: number, screenY: number): { x: number; y: number } {
const view = this.getViewState();
const inverseMatrix = mat3.invert(mat3.create(), view.matrix);
const point = vec2.fromValues(screenX, screenY);
vec2.transformMat3(point, point, inverseMatrix);
return { x: point[0], y: point[1] };
}
}
本節我們實現一下畫布平移功能的完整實現來理解整個系統的工作原理。
主要是根據鼠標計算出移動的距離,然後更新視圖矩陣,再重新渲染,因為記錄了視圖的偏移,我們實際每次繪製的只有螢幕範圍內的圖像,效果就好像一個可以無限移動的畫布。
用戶互動 → CanvasPanHandler → CoordinateSystemManager → ViewManager → SkiaLikeRenderer
↓ ↓ ↓ ↓ ↓
鼠標/鍵盤事件 事件處理邏輯 坐標變換管理 視圖狀態更新 Canvas渲染
export class CanvasPanHandler implements EventHandler {
name = "canvas-pan";
priority = 110; // 🎯 比選擇工具優先級高,確保平移優先處理
private isPanning = false;
private lastPanPoint: { x: number; y: number } | null = null;
private isTemporaryPanMode = false; // 空格鍵臨時模式
canHandle(event: BaseEvent, state: InteractionState): boolean {
// 🎯 只有手動工具激活時才處理平移事件
if (toolStore.getCurrentTool() !== "hand") {
return false;
}
return true;
}
}
// 鼠標按下 - 開始平移
private handleMouseDown(event: MouseEvent, context: EventContext): EventResult {
this.isPanning = true;
this.lastPanPoint = { ...event.mousePoint };
return {
handled: true,
newState: "panning", // 🔄 切換到平移狀態
requestRender: false, // 僅狀態改變,無需重繪
};
}
// 鼠標移動 - 計算偏移並更新視圖
private handleMouseMove(event: MouseEvent, context: EventContext): EventResult {
if (!this.isPanning || !this.lastPanPoint) {
return { handled: true, requestRender: false, newState: "idle" };
}
// 🎯 計算鼠標移動距離
const deltaX = event.mousePoint.x - this.lastPanPoint.x;
const deltaY = event.mousePoint.y - this.lastPanPoint.y;
// 🔧 應用平移偏移量到坐標系統
coordinateSystemManager.updateViewPosition(deltaX, deltaY);
// 🎯 更新記錄點,為下次計算做準備
this.lastPanPoint = { ...event.mousePoint };
return {
handled: true,
newState: "panning",
requestRender: true, // 🚀 重要:請求重新渲染
};
}
// 鼠標釋放 - 結束平移
private handleMouseUp(event: MouseEvent, context: EventContext): EventResult {
this.isPanning = false;
this.lastPanPoint = null;
return { handled: true, newState: "idle", requestRender: false };
}
// 空格鍵按下 - 進入臨時平移模式
private handleKeyDown(event: BaseEvent, context: EventContext): EventResult {
const keyEvent = event as unknown as KeyboardEvent;
if (keyEvent.key === " " || keyEvent.code === "Space") {
if (!this.isTemporaryPanMode && toolStore.getCurrentTool() !== "hand") {
this.isTemporaryPanMode = true;
keyEvent.preventDefault(); // 阻止預設捲動行為
return { handled: true, requestRender: false };
}
}
return { handled: false };
}
// 空格鍵釋放 - 退出臨時平移模式
private handleKeyUp(event: BaseEvent, context: EventContext): EventResult {
const keyEvent = event as unknown as KeyboardEvent;
if (keyEvent.key === " " || keyEvent.code === "Space") {
if (this.isTemporaryPanMode) {
this.isTemporaryPanMode = false;
this.isPanning = false;
this.lastPanPoint = null;
return { handled: true, requestRender: false };
}
}
return { handled: false };
}
export class CoordinateSystemManager {
// 🎯 更新視圖位置(平移變換)
updateViewPosition(deltaX: number, deltaY: number): void {
const currentView = this.getViewState();
// 🔧 通過 ViewManager 應用變換
const updatedView = viewManager.updateTranslation(currentView, deltaX, deltaY);
this.setViewState(updatedView); // 同步狀態更新
}
// 🎯 坐標轉換:螢幕坐標 ↔ 世界坐標
screenToWorld(screenX: number, screenY: number): { x: number; y: number } {
const view = this.getViewState();
const inverseMatrix = mat3.invert(mat3.create(), view.matrix);
const point = vec2.fromValues(screenX, screenY);
vec2.transformMat3(point, point, inverseMatrix);
return { x: point[0], y: point[1] };
}
}
export class SkiaLikeRenderer {
performRender(): void {
// 🎯 獲取最新的視圖變換矩陣
const viewState = coordinateSystemManager.getViewState();
// 🔧 應用變換到 Canvas 上下文
this.renderApi.setTransform(
viewState.matrix[0] * this.pixelRatio, // scaleX * pixelRatio
viewState.matrix[1] * this.pixelRatio, // skewY * pixelRatio
viewState.matrix[3] * this.pixelRatio, // skewX * pixelRatio
viewState.matrix[4] * this.pixelRatio, // scaleY * pixelRatio
viewState.matrix[6] * this.pixelRatio, // translateX * pixelRatio
viewState.matrix[7] * this.pixelRatio // translateY * pixelRatio
);
// 🎨 重新繪製所有元素
this.rootContainer.render(renderContext);
}
}
用戶拖曳鼠標
↓
DOM mousedown事件 → EventSystem.handleDOMEvent()
↓
EventFactory.createMouseEvent() → 標準化事件物件
↓
EventSystem.processEvent() → 中間件處理
↓
CanvasPanHandler.canHandle() → 檢查是否可處理
↓
CanvasPanHandler.handleMouseDown() → 開始平移狀態
↓
返回 { handled: true, newState: "panning" }
↓
EventSystem 更新互動狀態
↓
用戶移動鼠標...
↓
DOM mousemove事件 → CanvasPanHandler.handleMouseMove()
↓
計算位移增量 (deltaX, deltaY)
↓
coordinateSystemManager.updateViewPosition()
↓
viewManager.updateTranslation() → 更新變換矩陣
↓
返回 { handled: true, requestRender: true }
↓
eventEmitter.emit("render:request")
↓
SkiaLikeRenderer.performRender() → Canvas重繪
↓
視覺反饋完成 ✨
import {
EventHandler,
EventResult,
EventContext,
BaseEvent,
MouseEvent,
KeyboardEvent,
InteractionState,
} from "../types";
import { toolStore } from "../../store/ToolStore";
import { coordinateSystemManager } from "../../manage/CoordinateSystemManager";
/**
* 畫布移動處理器
* 處理手動工具的畫布拖拽移動功能
*/
export class CanvasPanHandler implements EventHandler {
name = "canvas-pan";
priority = 110; // 比選擇工具優先級高
private isPanning = false;
private lastPanPoint: { x: number; y: number } | null = null;
// 臨時平移模式(按住空格鍵時啟用)
private isTemporaryPanMode = false;
canHandle(event: BaseEvent, state: InteractionState): boolean {
if (toolStore.getCurrentTool() !== "hand") {
return false;
}
return true;
}
async handle(event: BaseEvent, context: EventContext): Promise<EventResult> {
const mouseEvent = event as MouseEvent;
switch (event.type) {
case "mouse.down":
return this.handleMouseDown(mouseEvent, context);
case "mouse.move":
return this.handleMouseMove(mouseEvent, context);
case "mouse.up":
return this.handleMouseUp(mouseEvent, context);
case "key.down":
return this.handleKeyDown(event, context);
case "key.up":
return this.handleKeyUp(event, context);
default:
return { handled: false };
}
}
private handleMouseDown(
event: MouseEvent,
context: EventContext
): EventResult {
this.isPanning = true;
this.lastPanPoint = { ...event.mousePoint };
return {
handled: true,
newState: "panning",
requestRender: false,
};
}
private handleMouseMove(
event: MouseEvent,
context: EventContext
): EventResult {
if (!this.isPanning || !this.lastPanPoint) {
return { handled: true, requestRender: false, newState: "idle" };
}
const deltaX = event.mousePoint.x - this.lastPanPoint.x;
const deltaY = event.mousePoint.y - this.lastPanPoint.y;
coordinateSystemManager.updateViewPosition(deltaX, deltaY);
this.lastPanPoint = { ...event.mousePoint };
return {
handled: true,
newState: "panning",
requestRender: true,
};
}
private handleMouseUp(
event: MouseEvent,
context: EventContext
): EventResult {
this.isPanning = false;
this.lastPanPoint = null;
return { handled: true, newState: "idle", requestRender: false };
}
private handleKeyDown(
event: BaseEvent,
context: EventContext
): EventResult {
const keyEvent = event as unknown as KeyboardEvent;
if (keyEvent.key === " " || keyEvent.code === "Space") {
if (!this.isTemporaryPanMode && toolStore.getCurrentTool() !== "hand") {
this.isTemporaryPanMode = true;
keyEvent.preventDefault();
return { handled: true, requestRender: false };
}
}
return { handled: false };
}
private handleKeyUp(
event: BaseEvent,
context: EventContext
): EventResult {
const keyEvent = event as unknown as KeyboardEvent;
if (keyEvent.key === " " || keyEvent.code === "Space") {
if (this.isTemporaryPanMode) {
this.isTemporaryPanMode = false;
this.isPanning = false;
this.lastPanPoint = null;
return { handled: true, requestRender: false };
}
}
return { handled: false };
}
}
下一篇我會整理下標尺和網格的繪製邏輯。