发布日期:2025年10月3日 | 预计阅读时间:30 分钟
上一篇我们讲解了分层事件系统的设计,本篇我们将在事件系统的基础上实现无限画布、标尺、网格绘制,无限画布实际上只是理论上的无限,绘制的仅仅是视口区域,通过事件触发移动和缩放,重新计算视图矩阵,更新渲染
🧑💻 写在开头
点赞 + 收藏 = 支持原创 🤣🤣🤣
无限画布是现代设计工具(如 Figma、Sketch、Miro)的核心特性,它不仅仅是一个可以缩放和平移的画布,更是一个复杂的交互系统。本文将从工程实践的角度,深入解析无限画布的完整实现思路。
本篇你将学到:
- 无限画布的整体架构设计思路
- 事件系统:如何处理复杂的用户交互
- 坐标系统:世界坐标与屏幕坐标的转换
- 视觉辅助:标尺和网格的智能绘制算法
- 性能优化:视口裁剪和按需渲染策略
🍎 系列背景 & 延续
这个系列文章主要记录了编辑器从 0 到 1 的实现细节:
- 节点树架构
- 渲染层对接 Reconciler + Canvas
- 数据层与渲染层绑定机制
- 分层事件系统的实现
- 本篇:无限画布 & 标尺 & 网格的实现细节
之前的文章:
- 01【图形编辑器架构】节点树篇 --- 从零构建你的编辑器数据中枢
- 02【渲染层篇】React Reconciler 对接 Canvas 的完整实现
- 03【数据绑定篇】NodeTree → SkiaNode → CanvasElement 的绑定机制
- 04【图形编辑器架构】:编辑器的 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 线性变换的核心限制
-
原点保持不动
- 因为没有独立常数项,(0,0) 始终映射到 (0,0)。
-
变换结果 = 输入的线性组合
- 只能"扭曲",不能"搬家"。
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 API →
ctx.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重绘
↓
视觉反馈完成 ✨
📏 五、标尺系统:智能刻度的技术实现
设计理念
标尺系统需要解决的核心问题:
- 固定显示:始终固定在屏幕边缘,不随画布内容移动
- 动态刻度:根据缩放级别智能调整刻度密度
- 性能优化:只绘制可见区域的刻度
- 用户体验:提供清晰的空间定位参考
智能刻度间距算法
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(); // 🔧 确保变换状态恢复
}
}
🔲 六、网格系统:自适应显示的工程实现
核心设计思路
网格系统的精妙之处在于:
- 视口裁剪:只绘制当前可见区域的网格线
- 智能隐藏:根据缩放级别自动隐藏过密或过疏的网格
- 完美对齐:确保网格线始终对齐到世界坐标的逻辑网格点
自适应显示策略
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;
算法分解:
-
第一步 :
offsetX % scaledGridSize
- 计算偏移量对网格大小的余数
- 结果范围:
(-scaledGridSize, scaledGridSize)
-
第二步 :
+ scaledGridSize
- 处理负数情况,确保结果为正
- 结果范围:
(0, 2*scaledGridSize)
-
第三步 :
% 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);
}
}
}
🚀 下期预告
下一篇我们将深入探讨选择和拖拽系统的实现,包括:
- 碰撞检测算法的优化
- 多选框的绘制和交互
- 复杂形状的精确选择
- 拖拽操作的流畅实现
敬请期待!
如果这篇文章对你有帮助,请不要忘记点赞和收藏哦!你的支持是我持续创作的动力 💪