用橡皮擦实现Canvas局部擦除效果的技术实践与优化思路
项目背景和为什么选橡皮擦功能
最近接了个在线教育平台的项目,客户提出一个需求:在白板工具里加入橡皮擦功能。说实话,一开始我有点懵,心想不就是清除画布上的内容嘛,直接用clearRect()不就搞定了?但深入了解后发现事情没那么简单。
他们想要的是模拟真实橡皮擦的效果,不仅能擦除笔迹,还要有真实的擦除轨迹和力度反馈。而且这个功能要同时兼容鼠标和触屏设备。经过一番调研,最后决定基于Canvas来实现这个功能。
核心代码与基础实现
先说下最基础的实现方式,这部分代码比较简单:
const canvas = document.getElementById('whiteboard');
const ctx = canvas.getContext('2d');
let isErasing = false;
canvas.addEventListener('mousedown', startErase);
canvas.addEventListener('mousemove', doErase);
canvas.addEventListener('mouseup', stopErase);
function startErase(e) {
isErasing = true;
const {x, y} = getCursorPosition(e);
ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath();
ctx.arc(x, y, 10, 0, Math.PI * 2);
ctx.fill();
}
function doErase(e) {
if (!isErasing) return;
const {x, y} = getCursorPosition(e);
ctx.beginPath();
ctx.arc(x, y, 10, 0, Math.PI * 2);
ctx.fill();
}
function stopErase() {
isErasing = false;
ctx.closePath();
}
这段代码实现了最基本的橡皮擦功能,通过destination-out混合模式来清除像素。这里注意getCursorPosition()是个辅助函数,用来处理不同设备的坐标获取。
最大的坑:性能问题
刚开始觉得这功能挺简单,结果一上线就出问题了。用户反馈说在iPad上使用时特别卡顿,尤其是快速擦除的时候。我当时就懵了,在开发环境测试明明很流畅啊。
后来通过Chrome DevTools分析发现,每次调用arc()和fill()都会创建一个新的图形路径,当擦除速度快时,这些操作会迅速堆积,导致性能急剧下降。
解决这个问题花了我整整两天时间。最后采用了一个折中的方案:
let lastTimestamp = 0;
let prevPos = null;
function optimizedErase(e) {
const currentTimestamp = Date.now();
// 控制绘制频率
if (currentTimestamp - lastTimestamp < 16) return;
const {x, y} = getCursorPosition(e);
if (prevPos) {
const dist = Math.hypot(x - prevPos.x, y - prevPos.y);
const steps = Math.ceil(dist / 5); // 计算插值步数
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const ix = prevPos.x + t * (x - prevPos.x);
const iy = prevPos.y + t * (y - prevPos.y);
drawEraserCircle(ix, iy);
}
} else {
drawEraserCircle(x, y);
}
prevPos = {x, y};
lastTimestamp = currentTimestamp;
}
function drawEraserCircle(x, y) {
ctx.beginPath();
ctx.arc(x, y, 10, 0, Math.PI * 2);
ctx.fill();
}
这个优化版主要做了两件事:一是控制绘制频率,二是对快速移动的轨迹进行插值计算,确保擦除轨迹更平滑。虽然还是有一点点延迟,但比之前好多了。
另一个踩坑点:多设备兼容性
你以为这就完了吗?太天真了!上线后又收到反馈,说在某些安卓机上橡皮擦完全不起作用。调试了大半天才发现,原来是touch事件和mouse事件冲突了。
有些设备会同时触发touchstart和mousedown事件,导致擦除状态混乱。最后只能改成这样:
let isTouchDevice = false;
canvas.addEventListener('touchstart', e => {
isTouchDevice = true;
startErase(e.touches[0]);
}, {passive: false});
canvas.addEventListener('mousedown', e => {
if (!isTouchDevice) startErase(e);
});
这个解决方案也不是最完美的,但至少解决了大部分设备的兼容性问题。这里提醒下,判断设备类型一定要谨慎,我之前直接用navigator.userAgent判断,结果踩了好几个坑。
最终效果和遗留问题
经过这些调整,橡皮擦功能总算能用了。优点很明显:擦除效果自然、支持多种设备、性能也还可以。但还是有几个小问题没完全解决:
- 在某些低端设备上,快速擦除时偶尔会出现断点
- 擦除时如果突然切换到画笔模式,会有短暂的状态混乱
- 擦除区域的边缘偶尔会出现锯齿状
不过这些问题影响不大,客户也能接受。毕竟项目deadline就在那,不能无限优化下去。
回顾与反思
这个橡皮擦功能让我深刻体会到,看似简单的功能背后可能藏着不少坑。从最初的”不就是clearRect吗”到后来的各种优化,整个过程还挺有意思的。
几点经验分享给大家:
- 不要过早优化,但要有性能意识
- 多设备测试真的很重要,光靠模拟器是不够的
- 遇到性能问题,优先考虑降低绘制频率和减少重绘区域
以上是我个人对这个橡皮擦功能的完整讲解,有更优的实现方式欢迎评论区交流。后续我还会继续分享类似的技术实战经验。
