主题
无限画布导览
Figma、Excalidraw、Miro、tldraw 这类产品共享同一套技术骨架:一张可以无限平移缩放的画布 + 一堆矢量对象与 Frame + 多人实时协作。本板块用二十二页把这套骨架拆开讲清楚——从"视口的那两个数字"(offset 与 zoom)一直讲到协作同步与文档导出。
这一页是路线图与约定:先定义"无限画布"到底是什么、它和传统固定画布网页差在哪里,再给出分层架构与渲染方案选型,然后是二十二页的板块地图与 Figma / MasterGo 功能面对照表,最后落在一个最小可运行骨架和三条性能第一性原则。
1. 什么是"无限画布"
"无限"不是真的没有边界,而是用户感受不到边界:视口可以一直平移、一直缩放,画布内容按可见范围动态渲染,画布外的世界既不进内存也不占帧预算。
| 特征 | 固定画布网页 | 无限画布平台 |
|---|---|---|
| 坐标 | 元素坐标即页面坐标 | 世界坐标与屏幕坐标分离,中间隔一层视口 |
| 尺寸 | 画布尺寸本身就是设计约束 | 世界没有固定尺寸,只有内容包围盒 |
| 渲染 | 布局一次,浏览器负责重绘 | 每帧按可见 AABB 剔除后自绘 |
| 交互 | 点击、滚动、表单输入 | 平移、缩放、框选、变换、吸附、测量 |
| 真相来源 | DOM 树就是数据 | 自建文档模型是真相,DOM 只是渲染目标之一 |
三条判断标准可以帮你决定要不要上这套架构:
- 内容会超出屏幕:如果用户终将需要"缩小看全局、放大抠细节",视口就必不可少;
- 对象需要程序化操作:批量改属性、生成副本、按规则排列——用文档模型做一次遍历,比在 DOM 里翻节点简单得多;
- 需要协作与撤销:要合并两个人的编辑,就必须有一份可序列化、可 diff 的文档,而不是一堆 DOM 节点的当前状态。
反过来,如果只是"一张固定尺寸的插画,用户点几下弹个提示",Canvas 交互与命中检测 的那套最小场景图就够了,不必引入视口与文档模型。
1.1 三类典型产品与它们的侧重
同一个架构骨架,在不同产品里的重心完全不同。先想清楚自己属于哪一类,能省掉大量无用功:
| 产品类型 | 代表 | 技术重心 | 可以偷懒的地方 |
|---|---|---|---|
| 设计工具 | Figma、Sketch | 精确几何、多层 Frame、组件与约束、像素级吸附 | 自由绘制(笔迹)可以简化 |
| 白板 / 协作板 | Miro、Excalidraw、tldraw | 低延迟协作、手绘笔迹、无限贴纸 | 不需要像素级吸附与矢量布尔运算 |
| 图表 / 节点编辑器 | 流程图、思维导图、节点编排 | 自动布局、连线路由、结构合法性校验 | 自由变换可以退化为对齐到网格 |
一个常见误区是"既然都是无限画布,就都按设计工具的标准做"。实际上白板产品的用户对手绘抖动是接受的,但对协作延迟零容忍;设计工具用户能忍受 100 ms 的同步延迟,却无法接受控制点吸附差 1 像素。先确定重心,再决定在哪一层投入复杂度。
1.2 "无限"在工程上意味着什么
把"无限"翻译成工程语言,是三条必须始终成立的约束:
- 视口必须能在任意位置、任意缩放下工作:不能用"假设画布宽 10000"这类隐含上限,缩放范围要给到 0.02× 到 40× 这种量级;
- 渲染开销与文档总量解耦:文档里有一万个对象时,视口里只有两百个,那么这一帧的成本应当只与两百相关——靠的是视口剔除与空间索引;
- 坐标精度不能随平移距离劣化:世界坐标漂到几十万之后,
float64做减法仍会丢有效位,需要"世界原点随视口漂移"这类手段,见视口与坐标系统。
三条约束里,第一条是设计约束,第二条是性能约束,第三条是数值约束。它们各自对应本板块的不同页面,也是判断一个画布实现是否"真的无限"的试金石。
2. 一个最小可运行骨架
整套体系的最小内核只有两个数。世界里的内容永远不动,动的只是视口:
ts
interface Vec2 { x: number; y: number }
/** 世界点 (0, 0) 落在屏幕 offset 处,世界被放大 zoom 倍 */
interface Viewport { offset: Vec2; zoom: number }
/** 正向:世界 → 屏幕(CSS 像素) */
const worldToScreen = (vp: Viewport, w: Vec2): Vec2 =>
({ x: w.x * vp.zoom + vp.offset.x, y: w.y * vp.zoom + vp.offset.y })
/** 逆向:屏幕 → 世界。命中检测、拖拽位移全靠它 */
const screenToWorld = (vp: Viewport, s: Vec2): Vec2 =>
({ x: (s.x - vp.offset.x) / vp.zoom, y: (s.y - vp.offset.y) / vp.zoom })
/** 渲染循环:事件只改 vp 与 dirty,画的动作统一放在这里 */
let vp: Viewport = { offset: { x: 0, y: 0 }, zoom: 1 }
let dirty = true
function frame(): void {
if (dirty) {
dirty = false
draw(vp) // draw 内部:setTransform(dpr · zoom, …, dpr · offset) 后直接用世界坐标画
}
requestAnimationFrame(frame)
}
requestAnimationFrame(frame)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
真实项目当然会长出几百个模块,但所有几何问题的入口都能追溯到这两行公式。后面每一页的复杂度,基本都可以理解成"为了让这两行公式在更多场景下依然成立"。
2.1 视口之外,还需要什么
只有视口还画不出东西。一个能跑的最小版本需要四样东西,它们的关系是固定的:
| 组成 | 数据结构 | 生命周期 |
|---|---|---|
| 视口 | { offset: Vec2; zoom: number } | 每帧都可能变,不进入历史 |
| 文档 | 节点树 / 扁平表,节点带世界坐标与世界包围盒 | 随编辑变化,进入历史,可序列化 |
| 空间索引 | 四叉树 / 网格 / R 树,按世界包围盒建桶 | 随文档变化重建或增量更新 |
| 渲染器 | 一帧的绘制过程:清屏 → 算可见 AABB → 查询索引 → 逐个绘制 | 每帧执行,无状态(状态都在 ctx 里,需显式归位) |
注意视口不进历史。用户平移缩放了几十次,撤销时不应该一格一格地把视口倒回去——绝大多数工具的做法是把视口变更当作"视图状态",只在协作时同步给别人,不参与文档的撤销栈。
3. 七层架构
把无限画布拆成七层,每层只回答一个问题。分层的好处是:替换其中一层(比如把渲染从 Canvas 2D 换成 WebGL)时,其它六层不需要知道。
| 层 | 一句话职责 | 常见实现选择 |
|---|---|---|
| 渲染层 | 把当前视口内的内容画到屏幕 | Canvas 2D / WebGL / WebGPU / SVG / DOM,或混合 |
| 视口与坐标层 | 维护 offset 与 zoom,提供四层坐标互转 | 2×3 仿射矩阵;本板块视口与坐标系统 |
| 场景模型层 | 描述"文档里有什么":节点、Frame、分组、z 序 | 文档树 + 扁平索引表;文档模型 |
| 交互层 | 把指针事件翻译成语义操作:命中、选择、变换、吸附 | 状态机 + 逆变换命中;选择与变换、吸附与对齐线 |
| 历史层 | 记录可撤销的操作,支持撤销 / 重做 / 协作合并 | 命令模式 + 逆操作;历史记录 |
| 协作层 | 把本地操作以低延迟同步给其他人 | CRDT / OT + WebSocket;实时协作 |
| 持久化层 | 落盘、加载、导出、版本迁移 | JSON + 增量快照 / IndexedDB / 服务端存储 |
有一条经验值得现在就记住:层次之间只靠数据流通信,不要互相调用内部状态。交互层产出的是"把节点 A 移动 Δ",而不是"我改了某个对象的字段";这样历史层可以直接把它变成一条撤销记录,协作层可以直接把它变成一个可广播的操作。
先定数据流方向,再写第一行代码
推荐的单向链路是:指针事件 → 交互状态机 → 操作(Operation)→ 文档模型 → 渲染。撤销、协作、持久化都挂在"操作"这条腰带上;如果一开始就让事件处理器直接改渲染对象,后面想加撤销功能往往要重写半个交互层。
3.1 跟着一次拖拽穿过七层
假设用户按住一个矩形向右拖了 40 像素。这 40 像素会依次经过:
- 交互层收到
pointermove,把clientX换算成画布 CSS 坐标,减去按下时的坐标,得到屏幕位移(40, 0); - 视口与坐标层提供逆变换:世界位移 =
(40 / zoom, 0)。注意不是直接相减——缩放为 0.5× 时,屏幕走 40 像素意味着世界走了 80 个世界单位; - 交互层结合命中结果,产出一个语义操作:
moveNodes([id], delta),并把它交给文档模型,而不是直接改节点的x; - 场景模型层应用操作,更新被移动节点及其后代的包围盒;
- 历史层把这条操作压入撤销栈(连续的同目标拖拽可以合并成一条),协作层把它编码后广播出去;
- 渲染层在下一个
requestAnimationFrame里重绘,被移动的节点按新的世界坐标画到新的屏幕位置; - 持久化层在空闲时(或节流后)把文档增量落盘。
这条链路上有三个容易忽略的细节:
- 第 2 步的除以
zoom是无限画布与普通拖拽最本质的区别。漏掉它,缩放后拖拽速度就会与指针不一致,而且缩放越小偏差越明显; - 第 5 步的操作必须是可序列化且可逆的(记录旧值或直接记录逆操作),否则撤销与协作都会卡住;
- 第 6 步必须与"每帧最多一次"绑定。
pointermove在 120 Hz 设备上可能一秒触发 120 次以上,绘制必须交给 RAF 去合并。
3.2 哪些状态该放在哪一层
分层最容易犯的错误是把同一份状态存两遍。判断原则是真相只存一份,其余都是派生:
| 状态 | 唯一真相放在 | 派生出来的东西 |
|---|---|---|
| 视口 | 视口层(一个对象) | 可见世界 AABB、网格步长、标尺刻度、命中阈值 |
| 节点位置 | 文档模型(世界坐标) | 屏幕坐标、包围盒、控制点位置、空间索引桶 |
| 选中集合 | 交互层(一组 id) | 选择框、控制点、属性面板内容 |
| 撤销栈 | 历史层 | 菜单项的可用状态 |
| 协作者光标 | 协作层(远端输入) | 屏幕上的光标位置(每帧随视口重算) |
关键在于派生值不要反写回真相:不要因为"这一帧算过可见 AABB"就把它存进文档;不要因为"选中了三个节点"就把 selected: true 写进节点字段。真相单一,撤销与协作的边界才清晰。
4. 渲染方案选型
渲染后端的选择没有标准答案,关键看对象规模与文本排版这两个硬指标。
| 方案 | 舒适的元素规模 | 文本排版 | 开发成本 |
|---|---|---|---|
| DOM / CSS | 数百,超出后布局与重排变慢 | 最好:直接复用浏览器排版、选择、输入法 | 最低,但视口与变换要自己兜 |
| SVG | 数千 | 好:原生 text 与字体排版 | 低,节点多时 DOM 开销明显 |
| Canvas 2D | 数万(配合剔除与分层) | 中等:手写换行、无输入法、无选中 | 低到中等,最常用 |
| WebGL / WebGPU | 十万以上(合批 + 实例化) | 差:文本要预烘成纹理或 SDF 图集 | 高:需要自己的批处理与文字系统 |
再加一张"非性能"维度的对照表,这两张表合起来才是完整决策依据:
| 方案 | 命中测试 | 可访问性 | 典型用途 |
|---|---|---|---|
| DOM / CSS | 浏览器代劳 | 天然最好(语义、焦点、读屏) | 表单密集的编辑器、小规模白板 |
| SVG | 内置 pointer-events | 好:元素可聚焦 | 图表、图标、少量交互图形 |
| Canvas 2D | 自己写逆变换 + 几何判断 | 需额外维护一层无障碍镜像 | 主流无限画布(Figma、Excalidraw) |
| WebGL / WebGPU | 自己写,或用 ID 缓冲回读 | 差,必须自建镜像层 | 十万级对象、3D 场景、特效 |
混合方案是最常见的落地点:DOM 覆盖层 + Canvas 画布层。
- Canvas 层负责所有内容绘制与视口变换,享受"一次剔除、一次重绘"的性能;
- DOM 覆盖层浮在画布上方,负责文本编辑框、右键菜单、工具提示、协作光标、滚动条、标尺与参考线的标签;
- 两层共用同一份
Viewport,DOM 元素的位置用transform: translate(x, y)定位(只改transform,不要改left/top,否则每帧触发重排); - 无障碍上再补一层"不可见但可聚焦"的 DOM 镜像,把画布里的对象映射成可读屏的按钮列表。
DOM 覆盖层的定位代码只有一行,但写法有讲究:
ts
/** 世界坐标 → 覆盖层元素的 transform。只改 transform,避免每帧重排 */
function placeOverlay(el: HTMLElement, vp: Viewport, world: Vec2): void {
const s = worldToScreen(vp, world)
el.style.transform = `translate(${s.x}px, ${s.y}px)` // 不是 left / top
}
/** 覆盖层元素要跟着缩放变大小时,把 zoom 也写进 transform */
function placeBadge(el: HTMLElement, vp: Viewport, world: Vec2): void {
const s = worldToScreen(vp, world)
el.style.transform = `translate(${s.x}px, ${s.y}px) scale(${1 / vp.zoom})` // 反向缩放,让角标恒定大小
}1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
1 / zoom 这个反向缩放非常常用:协作光标、选中角标、Frame 名称标签都希望"位置跟着世界走,但自身大小不变"。
别让两层在不同帧读取不同的视口
Canvas 层与 DOM 覆盖层如果各自缓存视口,缩放时就会出现"文字跟着框走、但慢半帧"的撕裂。做法是:每帧只把视口快照一份,两层都用这一份;并且 DOM 层的更新也放进同一个 requestAnimationFrame 回调,不要直接在 wheel 事件里写样式。
4.1 什么时候该从 Canvas 2D 换到 WebGL
不要因为"WebGL 更快"就提前换。下面几条是真正值得切换的信号:
- 单帧绘制元素稳定超过 2 万且剔除后仍有上万;
- 需要大量重复图元(点、线、图标),可以用实例化一次提交几千个;
- 需要自定义混合与后处理(发光、模糊、颜色校正)而不想逐像素操作
ImageData; - 需要把变换交给 GPU(每个元素的矩阵在顶点着色器里乘),CPU 只上传数据。
反过来,如果元素规模在几千以内、文本排版占大头,Canvas 2D 通常更快:文本渲染、虚线、圆角矩形、阴影这些在 2D 上下文里都是原生能力,在 WebGL 里每一项都要自己实现。可以在 Canvas 性能优化 里看到 2D 上下文本身还有哪些优化空间。
换渲染后端不该动交互层
如果发现"换后端要改一半交互代码",说明架构漏了一层:命中测试与坐标换算必须走文档模型 + 数学,而不是走渲染产物。用 ctx.isPointInPath 做命中是很好的起步方案,但要把它包在一个 hitTest(worldPoint) 接口后面,将来换成空间索引 + 几何判断时,调用方不需要知道。
5. 先跑一个示例
下面这个示例是整套架构的"最小可运行版本":一块 720×420 的画布、39 个确定性生成的世界矩形、一个只有 offset 与 zoom 的视口。所有视口数学都是不碰 DOM 的纯函数,可以被 Node 直接 import 断言。
视口:平移、缩放与世界 ↔ 屏幕换算空格或中键拖拽平移、滚轮以指针为锚点缩放、0/1/2 切换视图;面板给出 zoom、offset、可见 AABB、绘制与剔除数量,指针处还有一条往返误差
examples/infinite-canvas/viewport-zoom.jsts
/**
* 【无限画布 · 示例 1】视口:平移、缩放与世界 ↔ 屏幕换算
* ------------------------------------------------------------------
* 一块"无限"画布的全部状态只有两个数:
* interface Viewport { offset: Vec2; zoom: number }
* screen = world · zoom + offset world = (screen − offset) / zoom
* 世界内容(矩形 / 文字框)永远不动,动的只是这两个数。演示:
* ① 空格 + 左键、中键拖拽 → 平移 ② 滚轮 / 触控板 → 以指针为锚点缩放
* ③ 快捷键 0 / 1 / 2 → 重置 / 100% / 适应内容 ④ 双击 → 放大
* 指针处画十字并标出往返误差;视口数学都是不碰 DOM 的纯函数,可被 Node 直接 import 断言。
*/
import { create2D, rafLoop, cleanupAll } from '../shared/runtime.js'
import { createPanel } from '../shared/ui.js'
/* ========================= 纯函数:视口数学 ========================= */
export interface Vec2 { x: number; y: number }
export interface Rect { x: number; y: number; w: number; h: number }
export interface AABB { minX: number; minY: number; maxX: number; maxY: number }
/** 视口状态:世界点 (0, 0) 落在屏幕 offset 处,放大 zoom 倍 */
export interface Viewport { offset: Vec2; zoom: number }
/** screen = world · zoom + offset */
export function worldToScreen(vp: Viewport, wx: number, wy: number): Vec2 {
return { x: wx * vp.zoom + vp.offset.x, y: wy * vp.zoom + vp.offset.y }
}
/** world = (screen − offset) / zoom */
export function screenToWorld(vp: Viewport, sx: number, sy: number): Vec2 {
return { x: (sx - vp.offset.x) / vp.zoom, y: (sy - vp.offset.y) / vp.zoom }
}
/** 以屏幕点 (sx, sy) 为锚点缩放:offset' = 锚点屏幕坐标 − 锚点世界坐标 · zoom' */
export function zoomAt(vp: Viewport, sx: number, sy: number, next: number, min: number, max: number): Viewport {
const zoom = next < min ? min : next > max ? max : next
const a = screenToWorld(vp, sx, sy)
return { zoom, offset: { x: sx - a.x * zoom, y: sy - a.y * zoom } }
}
/** 可见世界 AABB:屏幕矩形的两个角换算回世界坐标 */
export function visibleWorldAABB(vp: Viewport, screenW: number, screenH: number): AABB {
const a = screenToWorld(vp, 0, 0)
const b = screenToWorld(vp, screenW, screenH)
return { minX: Math.min(a.x, b.x), minY: Math.min(a.y, b.y), maxX: Math.max(a.x, b.x), maxY: Math.max(a.y, b.y) }
}
/** 两个 AABB 是否相交:四个不等式,没有除法也没有开方 */
export function intersects(a: AABB, b: AABB): boolean {
return a.minX <= b.maxX && b.minX <= a.maxX && a.minY <= b.maxY && b.minY <= a.maxY
}
/** 视口裁剪:只保留与可见 AABB 相交的元素(暴力遍历,上千个元素再上空间索引) */
export function cullRects<T extends Rect>(rects: readonly T[], view: AABB): T[] {
const out: T[] = []
for (const r of rects) if (intersects({ minX: r.x, minY: r.y, maxX: r.x + r.w, maxY: r.y + r.h }, view)) out.push(r)
return out
}
/** 栅格步长:屏幕上格距接近 target 像素,步长只取 1 / 2 / 5 × 10ⁿ */
export function gridStep(zoom: number, target = 28): number {
const raw = target / zoom
const pow = 10 ** Math.floor(Math.log10(raw))
const m = raw / pow
return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10) * pow
}
export interface SceneItem extends Rect { label: string; color: string }
const COLORS = ['#7aa2ff', '#7ee7ce', '#ffd166', '#f791c6', '#9f8cff']
/** 确定性的世界内容:三个"文字框" + 36 个按公式排布的矩形(每次运行都一样,便于断言) */
export function createScene(): SceneItem[] {
const items: SceneItem[] = []
const labels = ['Frame · 首页', '卡片', '标题文本']
for (let i = 0; i < 39; i++) {
const col = i % 9, row = (i / 9) | 0
items.push({ x: -430 + col * 100 + ((i * 37) % 41), y: -230 + row * 92 + ((i * 53) % 37), w: 34 + ((i * 17) % 46), h: 30 + ((i * 29) % 40), label: i < labels.length ? labels[i] : `#${i + 1}`, color: COLORS[i % COLORS.length] })
}
return items
}
/** 所有元素的世界包围盒("适应内容"与缩略图都用它) */
export function contentBounds(items: readonly Rect[]): AABB {
const b: AABB = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }
for (const r of items) { b.minX = Math.min(b.minX, r.x); b.minY = Math.min(b.minY, r.y); b.maxX = Math.max(b.maxX, r.x + r.w); b.maxY = Math.max(b.maxY, r.y + r.h) }
return items.length ? b : { minX: 0, minY: 0, maxX: 0, maxY: 0 }
}
/* ============================== 交互示例 ============================== */
export default function (container: HTMLElement): () => void {
const view = create2D(container, { width: 720, height: 420, background: '#0b1220' })
const { ctx, canvas } = view
const items = createScene()
const bounds = contentBounds(items)
let vp: Viewport = { offset: { x: 0, y: 0 }, zoom: 1 }
let minZoom = 0.1, maxZoom = 8, lastSync = Number.NaN
let showGrid = true, showMap = false, showCounts = true, wheelPans = false
let spaceDown = false, dirty = true, didInit = false
let pointer: Vec2 | null = null
let pan: { id: number; last: Vec2 } | null = null
let drawn = 0, culled = 0
/** 事件坐标(视口 CSS 像素)→ 画布内 CSS 像素:乘比例,不要直接相减 */
function toCanvas(e: { clientX: number; clientY: number }): Vec2 {
const rect = canvas.getBoundingClientRect()
return { x: (e.clientX - rect.left) * (view.width / rect.width), y: (e.clientY - rect.top) * (view.height / rect.height) }
}
function setZoom(next: number, anchor: Vec2 | null): void {
const a = anchor ?? { x: view.width / 2, y: view.height / 2 }
vp = zoomAt(vp, a.x, a.y, next, minZoom, maxZoom)
dirty = true
}
/** 0:zoom = 1,世界原点回到视口中央(约定 100% 时 1 世界单位 = 1 CSS 像素) */
function resetView(): void {
vp = { zoom: 1, offset: { x: view.width / 2, y: view.height / 2 } }
dirty = true
}
/** 2:把全部内容装进视口,四周留 56px 边距 */
function fitContent(): void {
const w = Math.max(1, bounds.maxX - bounds.minX), h = Math.max(1, bounds.maxY - bounds.minY)
const zoom = Math.max(minZoom, Math.min(maxZoom, Math.min((view.width - 112) / w, (view.height - 112) / h)))
vp = { zoom, offset: { x: 0, y: 0 } }
vp.offset = { x: view.width / 2 - ((bounds.minX + bounds.maxX) / 2) * zoom, y: view.height / 2 - ((bounds.minY + bounds.maxY) / 2) * zoom }
dirty = true
}
/** 世界层:dpr × zoom 与 offset 一次性并进 CTM,之后直接用世界坐标写 */
function drawWorldLayer(world: AABB): SceneItem[] {
const z = vp.zoom
const visible = cullRects(items, world)
drawn = visible.length
culled = items.length - visible.length
ctx.setTransform(view.dpr * z, 0, 0, view.dpr * z, view.dpr * vp.offset.x, view.dpr * vp.offset.y)
if (showGrid) {
const step = gridStep(z)
const x0 = Math.floor(world.minX / step) * step, y0 = Math.floor(world.minY / step) * step
ctx.lineWidth = 1 / z // 线宽除以 zoom,屏幕上恒为 1px
ctx.strokeStyle = 'rgba(122, 162, 255, 0.16)'
ctx.beginPath()
for (let x = x0; x <= world.maxX; x += step) { ctx.moveTo(x, world.minY); ctx.lineTo(x, world.maxY) }
for (let y = y0; y <= world.maxY; y += step) { ctx.moveTo(world.minX, y); ctx.lineTo(world.maxX, y) }
ctx.stroke()
}
for (const it of visible) {
ctx.fillStyle = it.color
ctx.globalAlpha = 0.85
ctx.fillRect(it.x, it.y, it.w, it.h)
ctx.globalAlpha = 1
ctx.lineWidth = 1 / z
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)'
ctx.strokeRect(it.x, it.y, it.w, it.h)
}
return visible
}
/** 右上角缩略图:世界内容 + 可见区域 AABB,一眼看出这一帧裁掉了多少 */
function drawMinimap(world: AABB): void {
const mw = 150, mh = 92, mx = view.width - mw - 14, my = 62
const s = Math.min((mw - 12) / Math.max(1, bounds.maxX - bounds.minX), (mh - 12) / Math.max(1, bounds.maxY - bounds.minY))
const ox = mx + mw / 2 - ((bounds.minX + bounds.maxX) / 2) * s, oy = my + mh / 2 - ((bounds.minY + bounds.maxY) / 2) * s
ctx.fillStyle = 'rgba(15, 23, 42, 0.88)'; ctx.fillRect(mx, my, mw, mh)
ctx.fillStyle = 'rgba(126, 231, 206, 0.4)'
for (const it of items) ctx.fillRect(it.x * s + ox, it.y * s + oy, Math.max(1, it.w * s), Math.max(1, it.h * s))
ctx.strokeStyle = '#ffd166'; ctx.lineWidth = 1.5
ctx.strokeRect(world.minX * s + ox, world.minY * s + oy, (world.maxX - world.minX) * s, (world.maxY - world.minY) * s)
}
/** 一帧:世界层用世界坐标画,文字与 HUD 换回屏幕坐标画(不随 zoom 缩放,永远清晰) */
function draw(): AABB {
const { dpr, width: w, height: h } = view
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.fillStyle = '#0b1220'
ctx.fillRect(0, 0, w, h)
const world = visibleWorldAABB(vp, w, h)
const visible = drawWorldLayer(world)
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.font = '11px ui-monospace, monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillStyle = 'rgba(11, 18, 32, 0.9)'
for (const it of visible) {
if (it.w * vp.zoom < 30 || it.h * vp.zoom < 18) continue // 太小的元素不画标签
const c = worldToScreen(vp, it.x + it.w / 2, it.y + it.h / 2)
ctx.fillText(it.label, c.x, c.y)
}
ctx.textAlign = 'left'
ctx.textBaseline = 'alphabetic'
ctx.font = '12px ui-monospace, monospace'
ctx.fillStyle = 'rgba(230, 237, 247, 0.8)'
ctx.fillText(`zoom ${vp.zoom.toFixed(3)}× offset (${vp.offset.x.toFixed(1)}, ${vp.offset.y.toFixed(1)}) dpr ${dpr}`, 12, 20)
if (showCounts) { ctx.fillStyle = '#7ee7ce'; ctx.fillText(`本帧绘制 ${drawn} · 视口剔除 ${culled} · 共 ${items.length}`, 12, 38) }
const o = worldToScreen(vp, 0, 0)
ctx.fillStyle = 'rgba(255, 209, 102, 0.9)'
ctx.fillRect(o.x - 3, o.y - 3, 6, 6)
ctx.fillText('世界原点 (0, 0)', o.x + 7, o.y - 7)
if (showMap) drawMinimap(world)
if (pointer) {
const p = pointer
const wp = screenToWorld(vp, p.x, p.y)
const back = worldToScreen(vp, wp.x, wp.y)
ctx.setLineDash([4, 4])
ctx.strokeStyle = 'rgba(255, 209, 102, 0.7)'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(p.x, 0); ctx.lineTo(p.x, h)
ctx.moveTo(0, p.y); ctx.lineTo(w, p.y)
ctx.stroke()
ctx.setLineDash([])
ctx.strokeStyle = '#7ee7ce' // 往返回来的点应当与指针重合,画出来是一个套住指针的圆
ctx.beginPath()
ctx.arc(back.x, back.y, 5, 0, Math.PI * 2)
ctx.stroke()
const tx = p.x + 220 > w ? p.x - 206 : p.x + 14, ty = p.y + 60 > h ? p.y - 54 : p.y + 18
ctx.fillStyle = 'rgba(11, 18, 32, 0.82)'
ctx.fillRect(tx - 6, ty - 13, 206, 50)
ctx.fillStyle = '#ffd166'
ctx.fillText(`screen (${p.x.toFixed(1)}, ${p.y.toFixed(1)})`, tx, ty)
ctx.fillText(`world (${wp.x.toFixed(2)}, ${wp.y.toFixed(2)})`, tx, ty + 14)
ctx.fillStyle = 'rgba(230, 237, 247, 0.6)'
ctx.fillText(`往返误差 ${Math.hypot(back.x - p.x, back.y - p.y).toExponential(1)} px`, tx, ty + 28)
}
return world
}
function syncPanel(world: AABB): void {
if (vp.zoom !== lastSync) {
zoomSlider.set(Math.log(vp.zoom))
lastSync = vp.zoom
}
readView.set(`zoom ${vp.zoom.toFixed(3)}× · offset (${vp.offset.x.toFixed(1)}, ${vp.offset.y.toFixed(1)}) · 可见 AABB (${world.minX.toFixed(0)}, ${world.minY.toFixed(0)}) – (${world.maxX.toFixed(0)}, ${world.maxY.toFixed(0)})`)
readCounts.set(`绘制 ${drawn} · 剔除 ${culled} · 共 ${items.length} · AABB 面积 ${((world.maxX - world.minX) * (world.maxY - world.minY)).toFixed(0)}`)
if (!pointer) return readPointer.set('指针 world —(把指针移到画布上)')
const wp = screenToWorld(vp, pointer.x, pointer.y)
const back = worldToScreen(vp, wp.x, wp.y)
const err = Math.hypot(back.x - pointer.x, back.y - pointer.y).toExponential(1)
readPointer.set(`world (${wp.x.toFixed(3)}, ${wp.y.toFixed(3)}) → screen (${back.x.toFixed(3)}, ${back.y.toFixed(3)}) · 误差 ${err} px`)
}
/* ------------------- 面板:zoom 滑块、min / max、开关、读数 ------------------- */
const panel = createPanel(container, { title: '视口状态' })
const zoomSlider = panel.slider({
label: 'zoom(对数刻度)', min: Math.log(0.05), max: Math.log(20), step: 0.001, value: 0,
format: (v) => `${Math.exp(v).toFixed(2)}×`, onInput: (v) => setZoom(Math.exp(v), null)
})
panel.slider({ label: 'min zoom', min: 0.02, max: 1, step: 0.01, value: minZoom, format: (v) => `${v.toFixed(2)}×`, onInput: (v) => { minZoom = v; if (vp.zoom < v) setZoom(v, null); dirty = true } })
panel.slider({ label: 'max zoom', min: 1, max: 40, step: 0.5, value: maxZoom, format: (v) => `${v.toFixed(1)}×`, onInput: (v) => { maxZoom = v; if (vp.zoom > v) setZoom(v, null); dirty = true } })
panel.checkbox({ label: '显示世界栅格', value: showGrid, onChange: (v) => { showGrid = v; dirty = true } })
panel.checkbox({ label: '显示可见区域 AABB(缩略图)', value: showMap, onChange: (v) => { showMap = v; dirty = true } })
panel.checkbox({ label: '显示每帧绘制 / 剔除元素数', value: showCounts, onChange: (v) => { showCounts = v; dirty = true } })
panel.checkbox({ label: '滚轮平移(Ctrl / ⌘ + 滚轮才缩放)', value: wheelPans, onChange: (v) => { wheelPans = v } })
panel.buttons([{ label: '重置视图 (0)', group: false, onClick: resetView }, { label: '100% (1)', group: false, onClick: () => setZoom(1, pointer) }, { label: '适应内容 (2)', group: false, onClick: fitContent }])
const readView = panel.readout(), readPointer = panel.readout(), readCounts = panel.readout()
panel.note('空格 + 拖拽 / 中键拖拽平移;滚轮以指针为锚点缩放;0 / 1 / 2 切换视图;双击放大。先点一下画布再按键盘。')
/* ------------------------------- 事件 ------------------------------- */
const ac = new AbortController()
const opts: AddEventListenerOptions = { signal: ac.signal }
canvas.tabIndex = 0
canvas.style.cursor = 'grab'
canvas.setAttribute('aria-label', '无限画布视口演示:空格或中键拖拽平移,滚轮缩放,数字键 0、1、2 切换视图')
canvas.addEventListener('pointerdown', (e) => {
canvas.focus()
const p = toCanvas(e)
pointer = p
if (e.button !== 1 && !(e.button === 0 && spaceDown)) return void (dirty = true) // 中键是平移手势,左键要先按空格
e.preventDefault() // 阻止中键的自动滚动
pan = { id: e.pointerId, last: p }
canvas.setPointerCapture(e.pointerId) // 指针移出画布后仍收得到事件
canvas.style.cursor = 'grabbing'
dirty = true
}, opts)
canvas.addEventListener('pointermove', (e) => {
const p = toCanvas(e)
pointer = p
// 平移只改 offset:屏幕位移直接加到 offset 上,世界坐标一个都不用动
if (pan && pan.id === e.pointerId) {
vp = { zoom: vp.zoom, offset: { x: vp.offset.x + (p.x - pan.last.x), y: vp.offset.y + (p.y - pan.last.y) } }
pan.last = p
}
dirty = true
}, opts)
function endPan(e: PointerEvent): void {
if (pan && pan.id === e.pointerId) {
if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId)
pan = null
canvas.style.cursor = 'grab'
}
dirty = true
}
canvas.addEventListener('pointerup', endPan, opts)
canvas.addEventListener('pointercancel', endPan, opts) // 被系统打断也要解开状态
canvas.addEventListener('lostpointercapture', () => { pan = null }, opts)
canvas.addEventListener('pointerleave', () => { if (!pan) { pointer = null; dirty = true } }, opts)
canvas.addEventListener('wheel', (e) => {
e.preventDefault() // 必须配 { passive: false },否则浏览器忽略
const p = toCanvas(e)
pointer = p
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? view.height : 1 // deltaMode 先归一到像素
const dy = e.deltaY * unit
if (e.ctrlKey || e.metaKey || !wheelPans) {
// 触控板捏合在浏览器里就是 ctrl + wheel;k = exp(−dy · 0.0015) 手感接近 Figma
setZoom(vp.zoom * Math.exp(-dy * 0.0015), p)
} else {
vp = { zoom: vp.zoom, offset: { x: vp.offset.x - (e.shiftKey ? dy : e.deltaX * unit), y: vp.offset.y - (e.shiftKey ? 0 : dy) } }
dirty = true
}
}, { signal: ac.signal, passive: false })
canvas.addEventListener('dblclick', (e) => setZoom(vp.zoom * 1.6, toCanvas(e)), opts)
canvas.addEventListener('keydown', (e) => {
if (e.key === ' ') { spaceDown = true; e.preventDefault() } // 空格默认会滚动页面
else if (e.key === '0') resetView()
else if (e.key === '1') setZoom(1, pointer)
else if (e.key === '2') fitContent()
else if (e.key === '+' || e.key === '=') setZoom(vp.zoom * 1.25, null)
else if (e.key === '-' || e.key === '_') setZoom(vp.zoom / 1.25, null)
}, opts)
canvas.addEventListener('keyup', (e) => { if (e.key === ' ') spaceDown = false }, opts)
window.addEventListener('blur', () => { spaceDown = false; pan = null; canvas.style.cursor = 'grab' }, opts)
const offResize = view.onResize(() => { // 第一次拿到真实布局尺寸时再定初值,之后只是重绘
if (didInit) dirty = true
else { didInit = true; resetView() }
})
/* ------------------------------ 主循环 ------------------------------ */
resetView()
const loop = rafLoop(() => {
if (!dirty) return // 事件只改状态,这里按需重绘:连续 pointermove 只会产生一帧
dirty = false
syncPanel(draw())
})
return cleanupAll(loop, view, panel, offResize, () => ac.abort())
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
值得动手试的四个操作:
- 把指针停在某个矩形上再滚轮缩放:矩形应当"钉"在指针下方不动——这是以指针为锚点缩放的直接体现,公式推导见视口与坐标系统;
- 打开"显示可见区域 AABB(缩略图)":右上角小窗里的黄色方框就是视口的可见世界范围,一眼看出这一帧剔除了多少元素;
- 打开"显示每帧绘制 / 剔除元素数":缩小到 0.2× 时 39 个元素全部在视口内,放大到 8× 时往往只剩几个——这就是视口剔除的收益;
- 按
2适应内容:视口自动算出 zoom 与 offset,把内容包围盒装进画布并留出边距,这是"适应选择 / 适应全部"的通用算法。
6. 本板块地图
二十二页按四层展开。第 1 层是地基(视口、坐标、手势、选择),第 2 层是编辑体验(矢量、文本、图层、组件、自动布局、网格吸附、测量),第 3 层是产品化能力(原型、交付、版本、协作、评论),第 4 层是工程化与扩展(架构、文档模型、性能、插件):
| 层 | 页面 | 回答什么问题 |
|---|---|---|
| 导览 | 无限画布导览 | 整体架构、渲染选型、性能第一性原则(本页) |
| 1 地基 | 视口与坐标系统 | 四层坐标怎么换算、缩放锚点怎么推导、可见 AABB 怎么反算 |
| 1 地基 | 平移、缩放与 Frame 交互 | 手势怎么归一化、Frame 的父子坐标链、交互状态机怎么设计 |
| 1 地基 | 选择与变换 | 单选 / 多选 / 框选、包围盒与控制点、旋转与等比缩放 |
| 2 编辑 | 矢量编辑 | 路径节点与贝塞尔手柄、布尔运算、描边与圆角 |
| 2 编辑 | 文本系统 | 文本布局与换行、字体回退、编辑态与富文本区间 |
| 2 编辑 | 图层与面板 | 图层树、层级与 z 序、属性面板与检查器怎么分工 |
| 2 编辑 | 组件与样式 | 主组件与实例、覆盖与解绑、共享样式与令牌 |
| 2 编辑 | 自动布局 | 约束与自动布局算法、尺寸语义、内容自适应 |
| 2 编辑 | 标尺、网格与参考线 | 标尺刻度怎么随缩放换档、网格步长怎么选、参考线怎么存 |
| 2 编辑 | 吸附与对齐线 | 吸附候选怎么收集、阈值怎么定、对齐线怎么画 |
| 2 编辑 | 测量与标注 | 距离 / 角度 / 面积测量、标注跟随视口、单位换算 |
| 3 产品 | 原型与交互 | 热区与连线、转场与缓动、预览与分享 |
| 3 产品 | 交付与导出 | 切图与倍数、代码导出、设计令牌与交接 |
| 3 产品 | 版本、分支与审核 | 快照与操作日志、字段级 diff、三方合并与审核 |
| 3 产品 | 实时协作 | 多人光标、CRDT / OT、离线编辑与冲突消解 |
| 3 产品 | 评论与权限 | 评论锚点、@提及与通知、角色与访问控制 |
| 4 工程 | 历史记录与撤销重做 | 命令模式、逆操作、合并连续编辑、与协作的冲突 |
| 4 工程 | 编辑器架构 | 分层与模块边界、状态归属、插件宿主怎么设计 |
| 4 工程 | 文档模型、持久化与导出 | 文档树设计、序列化、版本迁移、导出 SVG / PNG |
| 4 工程 | 无限画布的性能 | 剔除、空间索引、分层渲染、脏矩形、帧预算分配 |
| 4 工程 | 插件与扩展 | 扩展点与清单、权限校验、异常隔离与版本兼容 |
建议顺序:先读本页建立全局观,再用第 1 层把视口与手势写对(它是所有交互的地基);第 2 层必读——矢量编辑、文本、图层、组件、自动布局这五页决定你的产品"像不像 Figma";第 3 层按上线节奏挑读,其中版本与协作两页决定了多人场景下能不能不丢数据;第 4 层的无限画布的性能与编辑器架构建议在上线前一定读一遍——无限画布的多数"越用越卡"与"越改越乱"都出在这两页讲的几个点上。
6.1 Figma / MasterGo 功能面对照
下表把这类产品的公开功能面逐条映射到本板块页面,并如实标注覆盖程度。"原理 + 可运行示例"表示示例里能跑通核心算法,但工程完备度(服务端、真实字体、真实沙箱)不在示例范围内:
| 功能面 | 产品里的入口 | 本教程对应页面 | 覆盖程度 |
|---|---|---|---|
| 无限画布与视口 | 滚轮缩放、空格拖拽平移、Frame | 视口与坐标系统 / 平移缩放与 Frame | 原理 + 可运行示例 |
| 选择与变换 | 点选 / 框选、缩放手柄、旋转 | 选择与变换 | 原理 + 可运行示例 |
| 矢量绘制与编辑 | 钢笔工具、节点编辑、布尔运算 | 矢量编辑 | 原理 + 可运行示例 |
| 文本 | 文本工具、字体与行高、富文本区间 | 文本系统 | 原理 + 可运行示例 |
| 图层管理 | 左侧图层面板、拖拽排序、编组 | 图层与面板 | 原理 + 可运行示例 |
| 组件与实例 | 创建组件、实例、变体 | 组件与样式 | 主组件 / 实例 / 覆盖已实现,变体列为扩展方向 |
| 样式与设计令牌 | 颜色 / 文字 / 效果样式、变量模式 | 组件与样式 | 共享样式已覆盖,变量模式列为扩展方向 |
| 自动布局与约束 | Auto Layout、约束、Hug 与 Fill | 自动布局 | 原理 + 可运行示例 |
| 标尺、网格、参考线 | 视图菜单、从标尺拖出参考线 | 标尺、网格与参考线 | 原理 + 可运行示例 |
| 智能吸附与对齐线 | 拖拽时出现的对齐线 | 吸附与对齐线 | 原理 + 可运行示例 |
| 测量与标注 | 选中后查看间距与尺寸 | 测量与标注 | 原理 + 可运行示例 |
| 原型与转场 | 原型面板、连线、演示预览 | 原型与交互 | 原理 + 可运行示例 |
| 交付与切图 | 导出面板、切图、代码片段 | 交付与导出 | 原理 + 可运行示例 |
| 版本历史与分支合并 | 版本历史、分支、合并 | 版本、分支与审核 | 快照 / 日志 / 字段级 diff / 三方合并已实现,服务端持久化为扩展方向 |
| 多人实时协作 | 多人光标、跟随视图、协同编辑 | 实时协作 | CRDT / OT 原理 + 简化实现,已如实记录并发改不同字段可能不收敛 |
| 评论与权限 | 评论工具、@提及、分享与角色 | 评论与权限 | 原理 + 可运行示例 |
| 撤销重做 | 撤销 / 重做快捷键 | 历史记录与撤销重做 | 命令模式 + 可运行示例 |
| 插件与扩展 | 插件市场、开发模式插件 | 插件与扩展 | 扩展点与权限校验已实现,真实沙箱(iframe / Worker)列为扩展方向 |
| 性能优化 | 大文件不卡、缩放流畅 | 无限画布的性能 | 原理 + 可运行示例 |
| 文件格式与持久化 | 云端文件、离线副本 | 文档模型、持久化与导出 | 原理 + 简化实现,格式迁移列为扩展方向 |
| 白板与自由画布 | 便签、思维导图、表格、投票 | 暂无专页 | 未覆盖,属白板类产品的功能差异 |
7. 与其它板块的关系
无限画布是本教程里少数"横向吃掉多个板块"的主题:它自己几乎不发明新算法,而是把其它板块的结论组装成一个产品。
| 需要的能力 | 来自哪个板块 | 在无限画布里扮演什么 |
|---|---|---|
| 坐标变换、矩阵、曲线 | 坐标系与空间、矩阵与变换 | 视口投影、元素本地矩阵、控制点与旋转 |
| 命中与吸附的几何判定 | 几何检测 | 点选、框选相交、吸附候选与对齐线 |
| 空间索引、持久化结构、历史 | 线段树、跳表与持久化结构、树与二叉搜索树 | 四叉树 / R 树剔除、版本化文档、可回滚历史 |
| 裁剪、求交、排序 | 算法板块 | 视口剔除、绘制顺序、合并区间 |
| 渲染 API 与合批 | Canvas 2D、WebGL、WebGPU | 实际把像素画出来 |
| 命令模式、观察者、状态机 | 策略、模板方法与命令、观察者与发布订阅 | 可撤销操作、文档变更通知、交互状态机 |
换句话说:数学提供坐标,几何提供判断,数据结构提供查找,渲染提供像素,设计模式提供接缝。无限画布这一板块的价值,正是展示这些零件如何在一个真实约束下(输入延迟、帧预算、多人并发)被拼装起来。
8. 性能与体验的第一性原则
先给结论:用户体验的上限由输入延迟决定,而不是由帧率决定。 一台稳定 60 FPS 但每次拖拽要等两帧才响应的画布,手感明显差于一台偶有掉帧但零延迟跟手的画布。
| 原则 | 做法 | 反面教材 |
|---|---|---|
| 输入延迟优先于帧率上限 | 指针事件只改状态;必要时用 getCoalescedEvents() 补齐高频采样 | 在 pointermove 里同步做重布局 |
| 坐标变换只算一次 | 每帧把 dpr、zoom、offset 一次并进 ctx.setTransform;命中阈值用 threshold / zoom 换算 | 在循环里对每个元素重复乘除 |
| 避免每帧分配 | 复用对象与 TypedArray,不要把临时 { x, y } 丢进热路径 | 每帧 map / filter 生成几千个新对象 |
| 视口剔除优先于一切优化 | 先按可见 AABB 剔除,再谈合批与缓存 | 先优化绘制代码,却仍遍历十万个不可见元素 |
| 让渲染频率跟得上输入 | 平移与缩放期间可以降质(隐藏文字、简化描边),停下后再补细节 | 拖拽时仍坚持渲染全量文本与阴影 |
一个实用的帧预算切分(按 16.7 ms 一帧算):
- 输入处理:不超过 2 ms,只做状态更新与坐标换算;
- 剔除与空间索引查询:1~3 ms;
- 绘制提交:6~10 ms,超了就降质或分帧;
- 面板与 DOM 覆盖层同步:不超过 1 ms,并且只在渲染帧里做,别在事件回调里做。
"每帧重画全部"不是罪,"每帧重算全部"才是
重画一帧的成本与元素数量成正比,而重算包含布局、文本测量、命中测试、包围盒聚合——后者往往贵一个数量级。缓存那些只在文档变化时才失效的东西(元素包围盒、文本排版结果、空间索引),并给它们明确的失效时机,是无限画布最重要的性能设计。
常见坑
| 现象 | 原因 | 处理 |
|---|---|---|
| 缩放时鼠标下的对象"跑掉" | 缩放锚点用了画布中心而不是指针 | 用 zoomAt():先求指针世界坐标,再反解 offset |
| 指针越靠边拖动越偏 | 用 clientX 直接当画布坐标 | 减去 getBoundingClientRect(),再乘 CSS 与逻辑尺寸之比 |
| 线宽与文字被一起放大 | 把 zoom 叠进了多层 ctx.scale | 只把 dpr · zoom 一次并进 setTransform,线宽写 1 / zoom |
| 拖到画布外就"粘住" | 没有 setPointerCapture,也没监听 pointercancel | 按下时捕获指针,pointerup / pointercancel / lostpointercapture 三处都清状态 |
| 滚轮缩放不生效 | wheel 默认是 passive,preventDefault() 被忽略 | 监听时显式传 { passive: false } |
| 越用越卡直到卡死 | 每帧新建对象 / 每帧重建索引 | 复用对象池,索引只在文档变化时重建 |
| 多人协作后布局偶尔错乱 | 本地直接改对象,操作没有统一入口 | 所有修改走"操作"通道,再交给历史与协作层 |
下一步
- 视口与坐标系统 —— 把本页那两个数字的换算与推导彻底写对
- 平移、缩放与 Frame 交互 —— 手势归一化、Frame 父子链与交互状态机
- 选择与变换 —— 从"看得见"到"能编辑"
- 无限画布的性能 —— 元素上万之后的剔除、索引与分层
- 文档模型、持久化与导出 —— 让画布内容真正存得住、带得走
- 版本、分支与审核 —— 团队协作之前,先把版本、diff 与合并想清楚
- 插件与扩展 —— 把编辑器做成平台:扩展点、权限与稳定性边界
- TypeScript 示例约定 —— 本板块示例的编写与检查规则