【图形编辑器架构】:无限画布标尺与网格系统实现解析

发布日期:2025年10月3日 | 预计阅读时间:30 分钟
上一篇我们讲解了分层事件系统的设计,本篇我们将在事件系统的基础上实现无限画布、标尺、网格绘制,无限画布实际上只是理论上的无限,绘制的仅仅是视口区域,通过事件触发移动和缩放,重新计算视图矩阵,更新渲染


🧑‍💻 写在开头

点赞 + 收藏 = 支持原创 🤣🤣🤣

无限画布是现代设计工具(如 Figma、Sketch、Miro)的核心特性,它不仅仅是一个可以缩放和平移的画布,更是一个复杂的交互系统。本文将从工程实践的角度,深入解析无限画布的完整实现思路。

本篇你将学到:

  • 无限画布的整体架构设计思路
  • 事件系统:如何处理复杂的用户交互
  • 坐标系统:世界坐标与屏幕坐标的转换
  • 视觉辅助:标尺和网格的智能绘制算法
  • 性能优化:视口裁剪和按需渲染策略

🍎 系列背景 & 延续

这个系列文章主要记录了编辑器从 0 到 1 的实现细节:

  • 节点树架构
  • 渲染层对接 Reconciler + Canvas
  • 数据层与渲染层绑定机制
  • 分层事件系统的实现
  • 本篇:无限画布 & 标尺 & 网格的实现细节

之前的文章:

今天我们聊第 4 篇:无限画布的完整实现

🎬 实现效果展示

核心功能

  • ✅ 平滑的平移和缩放交互
  • ✅ 智能的标尺系统(多级刻度)
  • ✅ 自适应的网格显示
  • ✅ 高性能的视口裁剪

🏗️ 一、整体架构:五层分离设计

无限画布系统采用了清晰的分层架构,每一层都有明确的职责边界:

css 复制代码
┌─────────────────┐
│   业务逻辑层    │ ← 具体交互处理(Pan, Zoom, Selection)
├─────────────────┤
│   事件系统层    │ ← 统一事件管理和分发
├─────────────────┤
│   坐标系统层    │ ← 世界坐标 ↔ 屏幕坐标转换
├─────────────────┤
│   渲染引擎层    │ ← Canvas API 封装和优化
├─────────────────┤
│   视觉辅助层    │ ← 标尺、网格、选择框等 UI 元素
└─────────────────┘

数据流向

css 复制代码
用户交互 → DOM事件 → 事件系统 → 交互处理器 → 坐标管理器 → 渲染引擎 → Canvas更新
    ↓         ↓        ↓         ↓          ↓         ↓         ↓
鼠标/键盘   标准化    责任链     业务逻辑    矩阵变换   视口裁剪   视觉反馈

🎮 二、事件系统:类 Figma 的交互架构

详细实现

为什么需要独立的事件系统?

传统的 React 事件处理在面对复杂 Canvas 交互时存在明显局限:

typescript 复制代码
// ❌ 传统方案的问题
const Canvas = () => {
  const handleMouseDown = (e: React.MouseEvent) => {
    // 问题1: 与React渲染周期耦合,性能受限
    // 问题2: 事件对象被合成,丢失原生特性
    // 问题3: 复杂交互状态管理困难
    // 问题4: 缺乏优先级和中间件机制
  };
  
  return <canvas onMouseDown={handleMouseDown} />;
};

独立事件系统的设计

采用完全独立于框架的事件系统

typescript 复制代码
// ✅ 独立事件系统
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 };
  }
}

统一事件对象

定义标准化的事件接口:

typescript 复制代码
// 基础事件类型
export interface BaseEvent {
  type: string;
  timestamp: number;
  preventDefault: () => void;
  stopPropagation: () => void;
  canceled: boolean;
  propagationStopped: boolean;
  nativeEvent?: Event;
}

// 鼠标事件
export interface MouseEvent extends BaseEvent {
  type: "mouse.down" | "mouse.move" | "mouse.up" | "mouse.wheel";
  mousePoint: { x: number; y: number };
  nativeEvent?: globalThis.MouseEvent | WheelEvent;
}

事件处理器接口

typescript 复制代码
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>; // 附加数据
}

🌐 三、坐标系统:双坐标系的精妙设计

矩阵在二维图形变换中的系统性运用

适用场景:Canvas / SVG / WebGL / 图形编辑器 / 游戏引擎


1. 矩阵的本质与直觉

1.1 线性变换的定义

在二维空间中,一个点 P = (x, y) 通常用列向量表示:

通过矩阵变换:

其中 M是一个 2×2 矩阵:

1.2 直觉理解:矩阵两列 = 新坐标轴
  • 第一列向量 (a, c)表示变换后 x 轴的方向与长度
  • 第二列向量 (b, d) 表示变换后 y 轴的方向与长度

换句话说: 矩阵的作用就是重新定义坐标系。

你可以把矩阵看作「两根变形的坐标轴」,任何点都会被投影到这两根新坐标轴上。


1.3 线性变换的核心限制
  1. 原点保持不动

    • 因为没有独立常数项,(0,0) 始终映射到 (0,0)。
  2. 变换结果 = 输入的线性组合

    • 只能"扭曲",不能"搬家"。

2. 2×2 线性变换

我们来看看 2×2 矩阵能表达的四类核心操作。

2.1 旋转(Rotation)
概览
推导

旋转可以说是又一个十分重要的变换矩阵了,如下图,我们希望用一个变换矩阵表示将向量a旋转到向量b的位置

我们可做如下推导得到该矩阵,记向量长度为r,则不难得到

进一步我们可以将旋转之后的向量b的坐标x,y用如下表示

旋转矩阵
2.2 缩放(Scaling)
概览
推导

不用推导了,直接看出来了,两个坐标的关系,直接对应

缩放矩阵
2.3 倾斜(Shear / Skew)
概览
推导
倾斜矩阵
2.4 翻转(Flip / Reflection)
概览
推导
翻转矩阵
2.5 四类变换的统一理解

本质:矩阵两列决定新的坐标系。


3. 为什么 2×2 无法表示平移

平移公式:

问题:

  • 2×2 矩阵输出必须是「输入变量的乘法 + 加法」组合。
  • 无法凭空生成独立的常数 tx, ty

证明:

假设 2×2 矩阵想做平移:

如果 x=0, y=0,则 x'=0, y'=0。 但是平移要求 x'=tx, y'=ty,显然矛盾

结论:平移不是线性变换,无法通过 2×2 矩阵实现。


4. 齐次坐标与 3×3 矩阵

4.1 思路

我们为每个二维点增加一个「固定为 1」的第三维,称为齐次坐标

目标是通过 3×3 矩阵实现所有二维变换:

4.2 平移矩阵
概览

构造平移矩阵:

计算:

通过第三维乘以固定的 1,实现了「加常数」。

推导
平移矩阵
4.3 统一所有变换

3×3 矩阵可以同时表示:

  • 旋转、缩放、倾斜、翻转(在左上角 2×2)
  • 平移(在第三列)
  • 甚至透视投影(改变第三行)

示例:先旋转再平移

5. 矩阵组合与顺序

矩阵乘法是非交换的,顺序非常重要!

5.1 向右乘 = 后应用

含义:

  • 先执行 M1,再执行 M2。

5.2 示例:先缩放后平移 vs 先平移后缩放

6. 工程实践:前端图形编辑器中的矩阵

6.1 常见场景
  • Canvas 2D APIctx.transform(a, b, c, d, e, f) 本质就是 3×3 矩阵。
  • SVG transform 属性 → 直接传入矩阵。
  • WebGL / Three.js → 统一使用 4×4 矩阵(3D 场景)。

6.2 图形编辑器中的矩阵堆栈

每个图层都维护一个自己的局部变换矩阵,最终通过矩阵连乘得到绝对变换:

这样可以:

  • 方便嵌套组管理
  • 支持撤销/重做
  • 将复杂变换统一为矩阵乘法

7. 总结思维导图

css 复制代码
矩阵在图形中的作用
│
├── 2×2 矩阵:线性变换
│   ├─ 旋转:坐标轴旋转
│   ├─ 缩放:坐标轴长度变化
│   ├─ 倾斜:坐标轴不再垂直
│   └─ 翻转:轴方向反转
│
├── 3×3 矩阵:齐次坐标
│   ├─ 解决平移问题
│   ├─ 平移矩阵第三列表示位移
│   └─ 可统一表示所有 2D 变换
│
├── 矩阵组合
│   ├─ 乘法顺序决定应用顺序
│   └─ 先缩放后平移 ≠ 先平移后缩放
│
└── 工程实践
    ├─ Canvas transform
    ├─ SVG transform
    └─ 图形编辑器层级矩阵堆栈

8. 一句话总结

2×2 矩阵通过重新定义坐标轴,能表达所有原点不动的线性变换(旋转、缩放、倾斜、翻转); 平移是非线性变换,需引入齐次坐标扩展为 3×3 矩阵,将「加常数」变为「乘以固定的 1」, 从而让二维图形系统中所有变换都能统一为矩阵乘法,并通过矩阵连乘管理复杂场景。

坐标系统概念
scss 复制代码
世界坐标系 (World Coordinates)  ←→  屏幕坐标系 (Screen Coordinates)
     ↓                                    ↓
  逻辑计算和数据存储                    UI元素绘制和显示
  • 世界坐标系:用于逻辑计算,代表画布中的实际位置
  • 屏幕坐标系:用于 UI 绘制,固定在浏览器窗口中
核心转换公式
typescript 复制代码
// 坐标转换的数学基础
屏幕坐标 = 世界坐标 × 缩放比例 + 偏移量
世界坐标 = (屏幕坐标 - 偏移量) / 缩放比例

// TypeScript 实现
export class CoordinateSystemManager {
  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] };
  }
  
  worldToScreen(worldX: number, worldY: number): { x: number; y: number } {
    const view = this.getViewState();
    
    const point = vec2.fromValues(worldX, worldY);
    vec2.transformMat3(point, point, view.matrix);
    
    return { x: point[0], y: point[1] };
  }
}
视图变换管理
typescript 复制代码
export class ViewManager {
  // 平移变换
  updateTranslation(view: ViewInfo, deltaX: number, deltaY: number): ViewInfo {
    const newMatrix = mat3.clone(view.matrix);
    mat3.translate(newMatrix, newMatrix, [deltaX, deltaY]);
    return { matrix: newMatrix };
  }
  
  // 缩放变换(以指定点为中心)
  updateScale(view: ViewInfo, scale: number, centerX?: number, centerY?: number): ViewInfo {
    const newMatrix = mat3.clone(view.matrix);
    
    if (centerX !== undefined && centerY !== undefined) {
      // 先平移到中心点
      mat3.translate(newMatrix, newMatrix, [centerX, centerY]);
      // 应用缩放
      mat3.scale(newMatrix, newMatrix, [scale / currentScale, scale / currentScale]);
      // 平移回原位置
      mat3.translate(newMatrix, newMatrix, [-centerX, -centerY]);
    }
    
    return { matrix: newMatrix };
  }
}

坐标系统模块

坐标转换

我们只需要将前面代码中的 scrollX 变成 (scrollX + offsetX),scrollY 变成 (scrollY + offsetY),其他不变。

假设我们的视口的原点,离场景原点的坐标水平和垂直距离分别为 scrollX 和 scrollY。

先 不考虑缩放,假设我们在视口坐标上的某个地方点击了一下,这个坐标是 (x, y)。这个坐标在场景坐标系中,就是:

js 复制代码
const sceneX = scrollX + x;
const sceneY = scrollY + y;
视口坐标转换为场景坐标

下面我们引入画布缩放,即画布可以缩小和放大,对应的一个比例值 zoom。

视口中的某个坐标 (x, y) 在场景坐标系,则是 :

js 复制代码
function viewportCoordsToSceneCoords(x, y, scrollX, scrollY, zoom) {
  return {
  x: scrollX + x / zoom,
  y: scrollY + y / zoom
  }
}

scrollX,scrollY是场景坐标系中的真实值,没有缩放的

之所以要用 x 除以 zoom,是因为此时视口中展示的是缩放后的图形,里面的坐标都是缩放后的值。所以需要先转换为 zoom 值为 1 对应的真实值。

场景坐标转换为视口坐标

做你逆运算就行了 然后我们反过来,如何从场景坐标 (x, y) 转换为视口坐标?将前面的公式做等式变换即可:

js 复制代码
function sceneCoordsToViewportCoords(x, y, scrollX, scrollY, zoom) {
  return {
  x: (x - scrollX) * zoom,
  y: (y - scrollY) * zoom
  };
}

我们通常是使用按键加滚轮的方式让画布以光标为中心进行缩放,或按按钮进行缩放,

为了让缩放后的场景还能对上缩放前光标的位置,我们需要计算缩放后的 scrollX 和 scrollY,进行校准。

核心思路是 保持缩放前点到视口左上角距离(视口坐标系)相同。

js 复制代码
function calScrollVal(cx, cy, prevZoom, zoom, scrollX, scrollY) {
  // 先计算目标点的场景坐标(这里 cx 和 cy 是基于视口坐标系的)
  const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(cx, cy, prevZoom, scrollX, scrollY);
  // 缩放后画布缩放比变成了 zoom,距离视口左上角的距离变成了 cx / zoom
  // 减去这个距离,就是新的 scrollX 了。
  const newScrollX = sceneX - cx / zoom;
  const newScrollY = sceneY - cy / zoom;

  return {
    x: newScrollX,
    y: newScrollY
  };
}

可能会有这么一种情况,就是实际的视口区域的原点坐标有一些偏移,偏移了 offsetX 和 offsetY,见下图。

我们只需要将前面代码中的 scrollX 变成 (scrollX + offsetX),scrollY 变成 (scrollY + offsetY),其他不变。

矩阵变换

视图变换矩阵

定义一个视图变换矩阵,用户存储当前page的偏移值和缩放值

通常编辑器都会缓存page的视图变换矩阵,下次初始化的时候会重新应用之前保存的矩阵

js 复制代码
/**
 * 视图类型 - 使用矩阵表示视图变换
 * 矩阵格式:[scaleX, skewY, translateX, skewX, scaleY, translateY, 0, 0, 1]
 */
export type ViewMatrix = {
  /** 视图变换矩阵 */
  matrix: mat3;
};
CoordinateSystemManager坐标系统管理器
  • 管理更新视图矩阵
  • 提供坐标转换方法
    • 视口转场景
    • 场景转视口
js 复制代码
/**
 * 坐标系统管理器
 * 统一管理视图坐标、变换矩阵、缩放等功能
 */
export class CoordinateSystemManager {
  private static instance: CoordinateSystemManager;

  private constructor() {}

  /**
   * 获取单例实例
   */
  static getInstance(): CoordinateSystemManager {
    if (!CoordinateSystemManager.instance) {
      CoordinateSystemManager.instance = new CoordinateSystemManager();
    }
    return CoordinateSystemManager.instance;
  }

  /**
   * 获取当前视图状态
   */
  getViewState(): ViewInfo {
    return viewManager.getViewInfo();
  }

  /**
   * 设置视图状态
   */
  setViewState(view: ViewInfo): void {
    viewManager.setViewInfo(view);
  }

  /**
   * 更新视图位置
   */
  updateViewPosition(deltaX: number, deltaY: number): void {
    const currentView = this.getViewState();

    const updatedView = viewManager.updateTranslation(
      currentView,
      deltaX,
      deltaY
    );

    this.setViewState(updatedView);
  }

  /**
   * 更新视图缩放
   */
  updateViewScale(scale: number, centerX?: number, centerY?: number): void {
    const currentView = this.getViewState();
    const updatedView = viewManager.updateScale(
      currentView,
      scale,
      centerX,
      centerY
    );
    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],
    };
  }

  /**
   * 世界坐标转换为屏幕坐标
   */
  worldToScreen(worldX: number, worldY: number): { x: number; y: number } {
    const view = this.getViewState();

    // 应用视图变换
    const point = vec2.fromValues(worldX, worldY);
    vec2.transformMat3(point, point, view.matrix);

    return {
      x: point[0],
      y: point[1],
    };
  }

  /**
   * 创建视图变换矩阵(直接返回当前矩阵的副本)
   */
  getViewTransformMatrix(): mat3 {
    const view = this.getViewState();
    return mat3.clone(view.matrix);
  }

  /**
   * 重置视图到初始状态
   */
  resetView(): void {
    this.setViewState(viewManager.reset());
  }
}

🎮 四、实战案例:画布平移的完整实现

工作流程

css 复制代码
用户拖拽鼠标 → CanvasPanHandler → CoordinateSystemManager → ViewManager → SkiaLikeRenderer
     ↓              ↓                    ↓                ↓              ↓
  鼠标/键盘事件   事件处理逻辑        坐标变换管理        视图状态更新    Canvas渲染

CanvasPanHandler 核心实现

typescript 复制代码
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 {
    // 只有手动工具激活时才处理平移事件
    return toolStore.getCurrentTool() === "hand";
  }

  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 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, // 🚀 关键:请求重新渲染
    };
  }
}

完整的事件流程

scss 复制代码
用户拖拽鼠标
     ↓
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重绘
     ↓
视觉反馈完成 ✨

📏 五、标尺系统:智能刻度的技术实现

设计理念

标尺系统需要解决的核心问题:

  1. 固定显示:始终固定在屏幕边缘,不随画布内容移动
  2. 动态刻度:根据缩放级别智能调整刻度密度
  3. 性能优化:只绘制可见区域的刻度
  4. 用户体验:提供清晰的空间定位参考

智能刻度间距算法

typescript 复制代码
// 根据缩放级别动态调整刻度间距
let tickInterval = 100;      // 主刻度间距
let minorTickInterval = 20;  // 次刻度间距

if (scale < 0.3) {
  // 极小缩放:使用大间距避免拥挤
  tickInterval = 500;
  minorTickInterval = 100;
} else if (scale < 0.5) {
  // 小缩放:中等间距
  tickInterval = 200;
  minorTickInterval = 50;
} else if (scale > 3) {
  // 大缩放:小间距提供精度
  tickInterval = 50;
  minorTickInterval = 10;
} else if (scale > 1.5) {
  // 中大缩放:标准间距
  tickInterval = 100;
  minorTickInterval = 20;
}

视口范围计算

typescript 复制代码
// 计算当前视图中的世界坐标范围
const worldStartX = -offsetX / scale;           // 左边界
const worldEndX = (canvasWidth - offsetX) / scale;   // 右边界  
const worldStartY = -offsetY / scale;           // 上边界
const worldEndY = (canvasHeight - offsetY) / scale;  // 下边界

刻度绘制核心算法

typescript 复制代码
private drawRulerTicks(
  renderApi: RenderApi,
  canvasWidth: number,
  canvasHeight: number,
  rulerSize: number,
  textColor: string,
  viewTransform: ViewTransform
): void {
  const { scale, offsetX, offsetY } = viewTransform;
  
  // 计算对齐到刻度间距的起始和结束点
  const startTickX = Math.floor(worldStartX / minorTickInterval) * minorTickInterval;
  const endTickX = Math.ceil(worldEndX / minorTickInterval) * minorTickInterval;

  for (let worldX = startTickX; worldX <= endTickX; worldX += minorTickInterval) {
    // 世界坐标转屏幕坐标
    const screenX = worldX * scale + offsetX;
    
    // 🎯 关键:只绘制屏幕可见范围内的刻度
    if (screenX >= rulerSize && screenX <= canvasWidth) {
      const isMajorTick = worldX % tickInterval === 0;
      const tickHeight = isMajorTick ? 12 : 6;
      
      // 绘制刻度线
      renderApi.beginPath();
      renderApi.moveTo(screenX, rulerSize - tickHeight);
      renderApi.lineTo(screenX, rulerSize);
      renderApi.stroke();
      
      // 主刻度显示数值标签
      if (isMajorTick && Math.abs(worldX) >= 0.1) {
        renderApi.fillText(
          Math.round(worldX).toString(),
          screenX,
          rulerSize / 2
        );
      }
    }
  }
}

坐标系统重置

typescript 复制代码
protected onRender(context: RenderContext, _viewTransform?: ViewTransform): void {
  const { renderApi, actualWidth, actualHeight, viewTransform, pixelRatio } = context;
  
  // 🎯 关键:标尺需要在屏幕坐标系绘制,所以需要临时重置变换
  renderApi.save();
  
  // 重置变换为单位矩阵,只保留像素比缩放,使标尺始终固定在屏幕边缘
  renderApi.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
  
  try {
    // 在屏幕坐标系中绘制标尺
    this.drawRulerBackground(renderApi, actualWidth, actualHeight, rulerSize);
    this.drawRulerTicks(renderApi, actualWidth, actualHeight, rulerSize, textColor, viewTransform);
  } finally {
    renderApi.restore(); // 🔧 确保变换状态恢复
  }
}

🔲 六、网格系统:自适应显示的工程实现

核心设计思路

网格系统的精妙之处在于:

  1. 视口裁剪:只绘制当前可见区域的网格线
  2. 智能隐藏:根据缩放级别自动隐藏过密或过疏的网格
  3. 完美对齐:确保网格线始终对齐到世界坐标的逻辑网格点

自适应显示策略

typescript 复制代码
// 计算缩放后的网格大小
const scaledGridSize = gridSize * scale;

// 🎯 智能可见性控制
if (scaledGridSize < 2 || scaledGridSize > 200) {
  return; // 不绘制网格
}

显示逻辑

  • < 2px:网格太密,形成视觉噪音,隐藏
  • > 200px:网格太疏,失去参考价值,隐藏
  • 2px ~ 200px:适中范围,正常显示

网格对齐的数学算法

网格对齐是最精妙的部分,确保网格线始终对齐到世界坐标的逻辑网格点:

typescript 复制代码
// 🎯 网格对齐核心算法
const startX = ((offsetX % scaledGridSize) + scaledGridSize) % scaledGridSize;
const startY = ((offsetY % scaledGridSize) + scaledGridSize) % scaledGridSize;

算法分解

  1. 第一步offsetX % scaledGridSize

    • 计算偏移量对网格大小的余数
    • 结果范围:(-scaledGridSize, scaledGridSize)
  2. 第二步+ scaledGridSize

    • 处理负数情况,确保结果为正
    • 结果范围:(0, 2*scaledGridSize)
  3. 第三步% scaledGridSize

    • 将结果限制在有效范围内
    • 最终结果范围:[0, scaledGridSize)

高效批量绘制

typescript 复制代码
renderApi.beginPath();

// 🎯 批量绘制所有垂直线
for (let x = startX; x <= actualWidth; x += scaledGridSize) {
  renderApi.moveTo(x, 0);
  renderApi.lineTo(x, actualHeight);
}

// 批量绘制所有水平线
for (let y = startY; y <= actualHeight; y += scaledGridSize) {
  renderApi.moveTo(0, y);
  renderApi.lineTo(actualWidth, y);
}

// 🚀 一次性描边所有线条
renderApi.stroke();

性能优化要点

  • 使用单个 beginPath()stroke() 调用
  • 避免在循环中频繁调用渲染 API
  • 只绘制屏幕可见区域内的网格线

⚡ 七、性能优化:视口裁剪和按需渲染

1. 视口裁剪策略

边界计算优化

typescript 复制代码
// ✅ 高效:只计算可见范围
const worldStartX = -offsetX / scale;
const worldEndX = (canvasWidth - offsetX) / scale;

// 只绘制这个范围内的元素
for (let worldX = startTickX; worldX <= endTickX; worldX += interval) {
  if (isInViewport(worldX)) {
    drawElement(worldX);
  }
}

批量渲染

typescript 复制代码
// ❌ 低效:每个元素单独渲染
elements.forEach(element => {
  renderApi.beginPath();
  renderElement(element);
  renderApi.stroke();
});

// ✅ 高效:批量渲染
renderApi.beginPath();
elements.forEach(element => {
  addToPath(element);
});
renderApi.stroke();

2. 按需渲染机制

事件驱动的渲染请求

typescript 复制代码
// 事件处理器请求渲染
return {
  handled: true,
  requestRender: true, // 🎯 明确标识需要重新渲染
  newState: "panning"
};

// 事件系统统一处理渲染请求
if (result.requestRender) {
  this.eventEmitter.emit("render:request");
}

渲染节流和防抖

typescript 复制代码
// 使用 RAF 进行渲染节流
let rafId: number | null = null;

const requestRender = () => {
  if (rafId) return; // 防止重复请求
  
  rafId = requestAnimationFrame(() => {
    performRender();
    rafId = null;
  });
};

3. 内存管理

对象池模式

typescript 复制代码
class EventObjectPool {
  private pool: BaseEvent[] = [];
  
  acquire(): BaseEvent {
    return this.pool.pop() || this.createNew();
  }
  
  release(event: BaseEvent): void {
    // 重置对象状态
    this.resetEvent(event);
    this.pool.push(event);
  }
}

监听器管理

typescript 复制代码
class EventSystem {
  private eventListeners = new Map<HTMLCanvasElement, Map<string, EventListener>>();
  
  dispose(canvas: HTMLCanvasElement): void {
    const listeners = this.eventListeners.get(canvas);
    if (listeners) {
      // 🧹 清理所有事件监听器
      listeners.forEach((listener, eventType) => {
        if (eventType.startsWith('window:')) {
          window.removeEventListener(eventType.slice(7), listener);
        } else {
          canvas.removeEventListener(eventType, listener);
        }
      });
      this.eventListeners.delete(canvas);
    }
  }
}

🚀 下期预告

下一篇我们将深入探讨选择和拖拽系统的实现,包括:

  • 碰撞检测算法的优化
  • 多选框的绘制和交互
  • 复杂形状的精确选择
  • 拖拽操作的流畅实现

敬请期待!

如果这篇文章对你有帮助,请不要忘记点赞和收藏哦!你的支持是我持续创作的动力 💪

相关推荐
uhakadotcom2 小时前
execjs有哪些常用的api,如何逆向分析网站的加签机制
前端·javascript·面试
你的电影很有趣3 小时前
lesson71:Node.js与npm基础全攻略:2025年最新特性与实战指南
前端·npm·node.js
闲蛋小超人笑嘻嘻3 小时前
find数组方法详解||Vue3 + uni-app + Wot Design(wd-picker)使用自定义插槽内容写一个下拉选择器
前端·javascript·uni-app
小牛itbull3 小时前
初始化electron项目运行后报错 electron uninstall 解决方法
前端·javascript·electron
闲蛋小超人笑嘻嘻4 小时前
前端面试十四之webpack和vite有什么区别
前端·webpack·node.js
rggrgerj4 小时前
Vue3 组件完全指南代码
前端·javascript·vue.js
golang学习记6 小时前
从0死磕全栈之Next.js App Router动态路由详解:从入门到实战
前端
huangql5206 小时前
基于前端+Node.js 的 Markdown 笔记 PDF 导出系统完整实战
前端·笔记·node.js
在逃的吗喽6 小时前
Vue3新变化
前端·javascript·vue.js