用橡皮擦实现Canvas局部擦除效果的技术实践与优化思路

Top丶新杰 交互 阅读 2,696
赞 40 收藏
二维码
手机扫码查看
反馈

项目背景和为什么选橡皮擦功能

最近接了个在线教育平台的项目,客户提出一个需求:在白板工具里加入橡皮擦功能。说实话,一开始我有点懵,心想不就是清除画布上的内容嘛,直接用clearRect()不就搞定了?但深入了解后发现事情没那么简单。

用橡皮擦实现Canvas局部擦除效果的技术实践与优化思路

他们想要的是模拟真实橡皮擦的效果,不仅能擦除笔迹,还要有真实的擦除轨迹和力度反馈。而且这个功能要同时兼容鼠标和触屏设备。经过一番调研,最后决定基于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吗”到后来的各种优化,整个过程还挺有意思的。

几点经验分享给大家:

  • 不要过早优化,但要有性能意识
  • 多设备测试真的很重要,光靠模拟器是不够的
  • 遇到性能问题,优先考虑降低绘制频率和减少重绘区域

以上是我个人对这个橡皮擦功能的完整讲解,有更优的实现方式欢迎评论区交流。后续我还会继续分享类似的技术实战经验。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论
培乐
培乐 Lv1
读完这篇文章,我对自己的学习能力更有信心了,不再害怕学习新技术。
点赞
2026-03-26 18:26