橡皮擦工具的技术实现原理与常见问题解决思路
Canvas橡皮擦又把我整懵了
前两天搞个在线画板功能,本来以为橡皮擦就是个简单的事儿,结果被折磨得够呛。一开始想着用globalCompositeOperation搞个source-atop,结果发现效果不对劲。后来试了各种方法,踩了一堆坑,最后总算搞定了。
最初的错误方案
最开始我直接用globalCompositeOperation = ‘destination-out’,以为这样就能擦除内容了。代码大概是这样的:
ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath();
ctx.arc(mouseX, mouseY, 20, 0, Math.PI * 2);
ctx.fill();
结果发现,这么做的问题是:一旦设置了destination-out,后面的所有绘制都会变成”擦除”模式,根本没法恢复正常绘画了。这里我踩了个坑,折腾了半天才发现需要在每次擦除后再切回正常的compositeOperation。
真正的坑点来了
切换compositeOperation听起来简单,但实际操作起来问题一堆。比如我在mousedown事件里设置成destination-out,在mouseup里切回来,结果发现如果鼠标移动太快,中间会有断点。因为mousemove频率跟不上,某些像素点没被擦到。
后来试了下把橡皮擦当作路径来处理,先记录鼠标移动轨迹,然后沿着轨迹擦除。但是发现这样擦出来的痕迹不够圆润,而且速度控制也是个问题。
最终的解决方案
折腾了半天发现,还是要用路径绘制的方式来处理橡皮擦。关键是不能简单地用fill(),而是要用stroke()来模拟路径擦除。完整的代码如下:
class CanvasEraser {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.isDrawing = false;
this.lastPoint = null;
// 保存原始状态
this.originalCompositeOperation = this.ctx.globalCompositeOperation;
this.initEvents();
}
initEvents() {
this.canvas.addEventListener('mousedown', (e) => {
this.startErasing(e);
});
this.canvas.addEventListener('mousemove', (e) => {
if (this.isDrawing) {
this.drawEraser(e);
}
});
this.canvas.addEventListener('mouseup', () => {
this.stopErasing();
});
this.canvas.addEventListener('mouseout', () => {
this.stopErasing();
});
}
startErasing(e) {
this.isDrawing = true;
this.lastPoint = this.getMousePos(e);
// 设置橡皮擦模式
this.ctx.globalCompositeOperation = 'destination-out';
this.ctx.lineWidth = 30; // 橡皮擦粗细
this.ctx.lineCap = 'round';
this.ctx.lineJoin = 'round';
// 在起点也进行擦除,防止遗漏
this.ctx.beginPath();
this.ctx.moveTo(this.lastPoint.x, this.lastPoint.y);
}
drawEraser(e) {
const currentPoint = this.getMousePos(e);
this.ctx.lineTo(currentPoint.x, currentPoint.y);
this.ctx.stroke(); // 用stroke而不是fill
this.lastPoint = currentPoint;
}
stopErasing() {
if (this.isDrawing) {
this.ctx.closePath();
this.isDrawing = false;
this.lastPoint = null;
// 恢复原始模式
this.ctx.globalCompositeOperation = this.originalCompositeOperation;
}
}
getMousePos(e) {
const rect = this.canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
}
// 清除整个画布
clearAll() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
}
这里有个关键点需要注意:lineWidth决定了橡皮擦的大小。如果设置得太小,擦除效果不明显;太大了又会影响精确度。我自己测试下来30px左右比较合适,具体可以根据实际需求调整。
移动端兼容问题
写完PC端的代码,本来觉得差不多了,结果测试移动端的时候发现touch事件没处理。这里又要加一套事件监听:
// 在initEvents方法里加上touch事件
this.canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
this.startErasing(e.touches[0]);
});
this.canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
if (this.isDrawing) {
this.drawEraser(e.touches[0]);
}
});
this.canvas.addEventListener('touchend', () => {
this.stopErasing();
});
注意要preventDefault(),不然页面会跟着滑动。这块也踩了个坑,Chrome mobile调试的时候发现橡皮擦会闪,后来发现是触摸事件默认行为导致的。
性能优化考虑
橡皮擦功能虽然看起来简单,但如果用户长时间操作,canvas的路径可能会变得很复杂。我在这里做了个简单的防抖处理:
drawEraser(e) {
// 防抖:只有移动距离超过一定阈值才绘制
const currentPoint = this.getMousePos(e);
const distance = Math.sqrt(
Math.pow(currentPoint.x - this.lastPoint.x, 2) +
Math.pow(currentPoint.y - this.lastPoint.y, 2)
);
if (distance > 2) { // 距离阈值
this.ctx.lineTo(currentPoint.x, currentPoint.y);
this.ctx.stroke();
this.lastPoint = currentPoint;
}
}
这样可以减少不必要的绘制操作,提高性能。不过阈值设置太大会影响平滑度,我自己测试3-5比较合适。
还有一个小问题
目前这个方案还有个小bug,就是如果快速移动鼠标,偶尔还会留下一些痕迹。理论上来说应该用贝塞尔曲线来平滑路径,但我这边为了简单就没做这个优化。实际使用中影响不大,真要追求完美的话可以研究下CanvasRenderingContext2D的quadraticCurveTo或者bezierCurveTo方法。
另外,如果你的画布上有多个图层,可能还需要考虑橡皮擦只作用于特定图层的问题。我的场景比较简单,所以没考虑分层,但如果是专业的绘图工具,这块还要深入处理。
总结一下
以上是我踩坑后的总结,Canvas橡皮擦看似简单,实际实现起来坑还真不少。最关键的几个点:
- globalCompositeOperation要记得恢复
- 用stroke()而不是fill()来处理连续擦除
- 移动端事件兼容别忘了
- 性能优化要考虑距离阈值
如果你有更好的方案,特别是关于路径平滑处理的,欢迎评论区交流。

暂无评论