最近在优化一个 Web 组态编辑器的性能,画布上放了 500 个组件后,鼠标移动就开始卡顿。打开 Performance 面板一看,每次 mousemove 都在全量重绘整个画布,16ms 的帧预算直接爆表到 80ms。
这是很多 Canvas 编辑器的通病:命中检测做得粗糙,脏区刷新压根没做。今天就聊聊这两个看似简单、实则大有门道的技术点。
一、命中检测:你点的到底是哪个图形?
Canvas 是位图绘图上下文,不像 DOM 那样天然支持事件冒泡。你在画布上点一下,浏览器只会告诉你点击坐标 (x, y),至于这个坐标落在哪个图形上,得你自己算。
1.1 最简单的方案:矩形包围盒(AABB)
对于矩形、图标这类规则图形,直接用轴对齐边界框(AABB)判断:
function isPointInRect(point, rect) {
return point.x >= rect.x &&
point.x <= rect.x + rect.width &&
point.y >= rect.y &&
point.y <= rect.y + rect.height;
}
优点:计算快,4 次比较搞定。 缺点:精度差,点击图形边缘的透明区域也会命中。
1.2 Canvas API:isPointInPath / isPointInStroke
Canvas 提供了原生的路径检测方法:
ctx.beginPath();
ctx.rect(100, 100, 200, 150);
ctx.fill();
// 检测点是否在路径内
if (ctx.isPointInPath(mouseX, mouseY)) {
console.log('命中了!');
}
注意事项:
- 必须在绘制后立即调用,路径会被
beginPath()重置 - 只能检测当前路径,多图形场景需要逐个重绘检测(性能杀手)
1.3 像素级精确检测:离屏 Canvas + getImageData
对于不规则图形(手绘路径、PNG 图标),可以用像素透明度判断:
// 创建离屏 Canvas
const hitCanvas = document.createElement('canvas');
const hitCtx = hitCanvas.getContext('2d');
// 绘制图形到离屏画布
hitCtx.drawImage(image, x, y);
// 读取点击位置的像素
const pixel = hitCtx.getImageData(mouseX, mouseY, 1, 1).data;
const alpha = pixel[3];
if (alpha > 0) {
console.log('命中透明区域外的像素');
}
进阶技巧:颜色键(Color Keying) 给每个图形分配唯一的 RGB 颜色,在专用的 hit Canvas 上用纯色绘制所有图形。点击时读取像素颜色,直接反查到对应图形 ID。
const colorMap = new Map();
shapes.forEach((shape, index) => {
const color = `rgb(${index & 0xFF}, ${(index >> 8) & 0xFF}, ${(index >> 16) & 0xFF})`;
colorMap.set(color, shape);
hitCtx.fillStyle = color;
hitCtx.fillRect(shape.x, shape.y, shape.width, shape.height);
});
// 点击时
const pixel = hitCtx.getImageData(mouseX, mouseY, 1, 1).data;
const color = `rgb(${pixel[0]}, ${pixel[1]}, ${pixel[2]})`;
const hitShape = colorMap.get(color);
这个方案在 Fabric.js、Konva.js 等成熟库中都有应用,Meta2d.js 也采用了类似的思路来处理复杂图形的精确命中。
1.4 实战选择
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 矩形/圆形组件 | AABB | 够用且快 |
| 多边形/路径 | isPointInPath | 原生支持,精度高 |
| 图片/不规则图形 | 像素检测 | 唯一能处理透明区域的方案 |
| 大量图形(>1000) | 空间分区 + AABB | 先用四叉树过滤,再精确检测 |
二、脏区刷新:别动不动就 clearRect(0, 0, width, height)
全量重绘是性能杀手。假设画布 1920x1080,每次 clearRect + 重绘所有图形,即使只移动了一个小图标,也要处理 200 万像素。
2.1 什么是脏区(Dirty Region)?
脏区就是"需要重绘的最小矩形区域"。比如一个图标从 A 移动到 B,脏区应该是:
脏区 = A 的旧位置矩形 ∪ B 的新位置矩形
2.2 基础实现
class DirtyRegionManager {
constructor() {
this.dirtyRects = [];
}
// 标记脏区
markDirty(x, y, width, height) {
this.dirtyRects.push({ x, y, width, height });
}
// 合并脏区(避免过多小矩形)
merge() {
if (this.dirtyRects.length === 0) return null;
let minX = Infinity, minY = Infinity;
let maxX = -Infinity, maxY = -Infinity;
this.dirtyRects.forEach(rect => {
minX = Math.min(minX, rect.x);
minY = Math.min(minY, rect.y);
maxX = Math.max(maxX, rect.x + rect.width);
maxY = Math.max(maxY, rect.y + rect.height);
});
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY
};
}
// 重绘脏区
render(ctx, shapes) {
const dirtyRect = this.merge();
if (!dirtyRect) return;
// 只清除脏区
ctx.clearRect(dirtyRect.x, dirtyRect.y, dirtyRect.width, dirtyRect.height);
// 只重绘与脏区相交的图形
ctx.save();
ctx.beginPath();
ctx.rect(dirtyRect.x, dirtyRect.y, dirtyRect.width, dirtyRect.height);
ctx.clip(); // 裁剪区域,避免绘制溢出
shapes.forEach(shape => {
if (this.intersects(shape.bounds, dirtyRect)) {
shape.draw(ctx);
}
});
ctx.restore();
this.dirtyRects = [];
}
intersects(rect1, rect2) {
return !(rect1.x + rect1.width < rect2.x ||
rect2.x + rect2.width < rect1.x ||
rect1.y + rect1.height < rect2.y ||
rect2.y + rect2.height < rect1.y);
}
}
2.3 进阶优化:离屏缓存 + 分层渲染
对于静态背景(网格、参考线)和动态内容(可拖拽组件),可以分层处理:
// 静态层:只绘制一次
const bgCanvas = document.createElement('canvas');
const bgCtx = bgCanvas.getContext('2d');
drawGrid(bgCtx); // 绘制网格
// 动态层:每帧更新
function render() {
// 先绘制静态背景
ctx.drawImage(bgCanvas, 0, 0);
// 再绘制动态内容的脏区
dirtyManager.render(ctx, dynamicShapes);
}
2.4 什么时候不该用脏区刷新?
- 脏区占画布 >50% :直接全量重绘更快
- 图形有复杂混合模式:
globalCompositeOperation会影响周围像素,脏区难以精确计算 - 画布尺寸很小(<500x500):优化收益不明显
三、实战案例:拖拽 500 个组件不卡顿
结合命中检测和脏区刷新,实现一个高性能的拖拽交互:
let dragTarget = null;
let lastPos = null;
canvas.addEventListener('mousedown', (e) => {
const pos = getMousePos(e);
// 命中检测:从上到下遍历
for (let i = shapes.length - 1; i >= 0; i--) {
if (isPointInRect(pos, shapes[i].bounds)) {
dragTarget = shapes[i];
lastPos = pos;
break;
}
}
});
canvas.addEventListener('mousemove', (e) => {
if (!dragTarget) return;
const pos = getMousePos(e);
const dx = pos.x - lastPos.x;
const dy = pos.y - lastPos.y;
// 标记旧位置为脏区
dirtyManager.markDirty(
dragTarget.x,
dragTarget.y,
dragTarget.width,
dragTarget.height
);
// 更新位置
dragTarget.x += dx;
dragTarget.y += dy;
// 标记新位置为脏区
dirtyManager.markDirty(
dragTarget.x,
dragTarget.y,
dragTarget.width,
dragTarget.height
);
lastPos = pos;
// 只重绘脏区
dirtyManager.render(ctx, shapes);
});
canvas.addEventListener('mouseup', () => {
dragTarget = null;
});
性能对比(500 个矩形组件):
- 全量重绘:80ms/帧,12 FPS
- 脏区刷新:5ms/帧,60 FPS
四、开源方案怎么做的?
看了几个主流的 Canvas 引擎实现:
Fabric.js:默认全量重绘,提供 renderOnAddRemove: false 手动控制刷新时机。命中检测用的是颜色键方案。
Konva.js:自动脏区管理,每个 Layer 独立维护脏区。支持 batchDraw() 批量更新。
Meta2d.js:国产开源引擎,采用分层渲染 + 脏区刷新。对于工业组态场景(大量静态管道 + 少量动态数据点)优化得很好,实测 10000 节点也能保持流畅。
如果你在做类似的项目,可以参考这些库的思路,或者直接基于它们二次开发。
五、写在最后
命中检测和脏区刷新是 Canvas 编辑器的基本功,但很多项目为了快速上线,直接用最简单的全量重绘 + AABB 检测,等到性能问题爆发再来优化,成本就高了。
建议在项目初期就做好技术选型:
- 图形 <100:怎么简单怎么来
- 图形 100-1000:必须做脏区刷新
- 图形 >1000:加上空间分区(四叉树/网格)
性能优化没有银弹,但这两个技术点是 Canvas 应用的必修课。希望这篇文章能帮你少踩点坑。