低代码编辑器里的命中检测与脏区刷新

5 阅读5分钟

最近在优化一个 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 应用的必修课。希望这篇文章能帮你少踩点坑。