手把手实现区域绘制功能的前端实战技巧

シ啸垄 交互 阅读 567
赞 22 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

这项目是个在线设计工具,用户要能在画布上圈出特定区域做标注。一开始我寻思着用现成的库,比如 Fabric.js 或 Konva.js,功能是全,但引入整个库就为了个区域绘制?感觉有点杀鸡用牛刀。而且我们团队之前没人深入用过这些库,怕后期定制起来麻烦。

手把手实现区域绘制功能的前端实战技巧

后来一拍脑袋:自己搞吧。HTML Canvas 其实就够用了,加上原生事件监听,轻量又可控。技术栈是 React + TypeScript,Canvas 嵌在组件里,状态管理用 context 搞定。当时觉得方案挺优雅,直到上线前一周开始测性能……

最大的坑:touchmove滚动失效

移动端适配直接把我干懵了。PC端好好的,鼠标按下拖动画矩形,松手结束,逻辑清晰。但上了手机,问题来了:手指一按下去开始画区域,页面就不滚动了。这不是我们想要的——用户应该能自由选择是在操作画布还是在浏览页面。

查了一堆资料,发现是 touchstarttouchmove 事件默认行为被触发后,浏览器会锁定视窗滚动,尤其是当你调用了 preventDefault()。但我们不阻止的话,Canvas 绘制又容易误触……这真是两难。

我试了三种方案:

  • 第一种是只在 Canvas 上启用绘制,其他区域允许滚动。结果用户一旦手指偏移一点点,就开始滚动页面,绘制中断,体验极差。
  • 第二种是加个“编辑模式”开关,开启后才响应绘制。但这增加了操作步骤,产品经理直接否了。
  • 第三种是动态判断滑动方向:如果主要是上下滑,就当滚动处理;如果是小范围移动,才当绘制。这个思路最后成了。

核心代码就这几行

最终方案是结合位移阈值和方向判断。只有当位移超过一定像素(比如10px),并且不是明显的上下滑动作时,才进入绘制模式。

let isDrawing = false;
let startX = 0;
let startY = 0;
let moved = false;

canvas.addEventListener('touchstart', (e) => {
  const touch = e.touches[0];
  startX = touch.clientX;
  startY = touch.clientY;
  moved = false;
});

canvas.addEventListener('touchmove', (e) => {
  const touch = e.touches[0];
  const dx = Math.abs(touch.clientX - startX);
  const dy = Math.abs(touch.clientY - startY);

  // 判断是否为明显滑动(优先滚动)
  if (!isDrawing && (dy > 10 && dy > dx)) {
    return; // 不阻止,默认滚动
  }

  // 防止默认行为仅在可能绘制时阻止
  if (!moved && dx < 10 && dy < 10) {
    moved = true;
    return;
  }

  // 进入绘制模式
  if (!isDrawing && dx >= 5) {
    isDrawing = true;
    e.preventDefault(); // 此时才阻止滚动
    ctx.beginPath();
    ctx.rect(startX, startY, touch.clientX - startX, touch.clientY - startY);
    ctx.stroke();
  } else if (isDrawing) {
    e.preventDefault();
    // 清除重绘
    redraw();
    ctx.beginPath();
    ctx.rect(startX, startY, touch.clientX - startX, touch.clientY - startY);
    ctx.stroke();
  }
}, { passive: false });

canvas.addEventListener('touchend', () => {
  if (isDrawing) {
    saveRegion({ x: startX, y: startY, width: ..., height: ... });
    isDrawing = false;
  }
});

这里注意我踩过好几次坑:passive 必须设为 false,不然 preventDefault 无效。现代浏览器默认开启 passive,你不显式关掉,e.preventDefault() 就是摆设。

谁更灵活?谁更省事?

其实还考虑过用 CSS + DOM 元素模拟选区,比如一个绝对定位的 div 跟随鼠标。好处是不用管 Canvas 的重绘逻辑,坏处是跟真实图像坐标对不准,尤其页面有缩放或滚动的时候,计算太复杂,还得监听一堆事件。

Canvas 虽然底层了些,但坐标系统统一,配合 scale 和 translate 处理缩放也方便。最后还是坚持用 Canvas,虽然多写了些绘制逻辑,但整体更可控。

踩坑提醒:这三点一定注意

  • 设备像素比没处理:一开始在高DPI屏幕(比如iPhone)上画的线模糊得像毛线团。后来加上了 devicePixelRatio 缩放,重新设置 canvas.width 和 canvas.height,才恢复正常。
  • 多点触控干扰:用户两根手指同时碰屏,程序直接炸了。加了个判断,只取第一个 touch,其他的 ignore。
  • React 状态更新导致重渲染:Canvas 在 useEffect 里初始化,但每次 state 更新都会清空画布。解决办法是把 ctx 提到 useRef 里缓存,或者手动控制重绘时机。

改完后仍有一两个小问题

现在大部分场景都跑通了,但在 Android 某些浏览器上,偶尔还会出现“半截绘制”——就是手指已经抬起来了,但事件没触发 touchend。查了说是浏览器 bug,没有可靠回调。目前的 workaround 是加个超时检测:如果 touchmove 停了 300ms 没动静,就强制结束当前绘制。不算完美,但用户几乎感知不到。

还有一个问题是撤销功能没做好。现在只是存了个区域列表,删最后一个,但没法局部擦除某个区域的一部分。本来想上 history stack,但时间紧,先放着了。后续要是产品提需求再说吧。

回顾与反思

回头看,这个功能看着简单,实际涉及的细节特别多:事件流、浏览器差异、移动适配、性能优化。原本估了两天,实际花了五天半,其中三天都在调各种边缘 case。

最大的收获是:别小看“画个框”这种需求。只要是交互密集型的功能,都得提前做真机测试,特别是安卓碎片化环境。模拟器永远不如手上那台旧红米来得真实。

另外就是 passive event listener 这个坑,以后凡是有 touch 事件的地方,我都先写一行注释:// 注意!必须 { passive: false },不然 preventDefault 失效。

以上是我踩坑后的总结,希望对你有帮助

这个技巧的拓展用法还有很多,比如圆形选区、自由画笔、多边形绘制,原理都类似。如果有更优的实现方式,或者你遇到过更奇葩的兼容性问题,欢迎评论区交流。前端就是这样,天天在填坑,但也总能学到点新东西。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论