区域绘制技术实战:从原理到项目落地的完整指南
项目初期的技术选型
上个月做了一个数据可视化后台,客户要求能“在地图上圈出任意区域,然后筛选出该区域内的设备”。听起来简单,但实际一上手才发现坑不少。一开始我甚至想用现成的库,比如 Leaflet 或者 Mapbox,但后来发现——根本不需要地图底图,客户只是要一个空白画布,用户自己画多边形就行。于是决定:用原生 Canvas + 鼠标/触控事件,自己撸。
为啥不用 SVG?因为后期可能要支持上千个点,Canvas 性能更稳。而且我们只需要绘制和交互,不需要 DOM 结构,Canvas 更轻量。事实证明这个选择是对的,虽然中间折腾得够呛。
核心代码就这几行
先说基础逻辑:用户按下鼠标(或 touchstart),开始记录点;移动时实时画线预览;松开时闭合路径,生成最终区域。关键在于如何高效地记录坐标、绘制路径,并且支持撤销、重做。
下面是最小可运行版本的核心代码(省略了样式和边界处理):
class AreaDrawer {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.points = [];
this.isDrawing = false;
this.canvas.addEventListener('mousedown', this.handleStart.bind(this));
this.canvas.addEventListener('mousemove', this.handleMove.bind(this));
this.canvas.addEventListener('mouseup', this.handleEnd.bind(this));
}
handleStart(e) {
const { x, y } = this.getRelativePos(e);
this.points = [{ x, y }];
this.isDrawing = true;
this.redraw();
}
handleMove(e) {
if (!this.isDrawing) return;
const { x, y } = this.getRelativePos(e);
// 实时预览:只保留最后一个点用于拖动
const tempPoints = [...this.points, { x, y }];
this.redraw(tempPoints);
}
handleEnd() {
if (this.points.length < 3) {
this.points = [];
this.isDrawing = false;
this.redraw();
return;
}
this.isDrawing = false;
// 闭合路径
this.redraw(this.points, true);
}
redraw(points = this.points, isClosed = false) {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
if (points.length === 0) return;
this.ctx.beginPath();
this.ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
this.ctx.lineTo(points[i].x, points[i].y);
}
if (isClosed) {
this.ctx.closePath();
this.ctx.fillStyle = 'rgba(0, 123, 255, 0.2)';
this.ctx.fill();
}
this.ctx.strokeStyle = '#007bff';
this.ctx.lineWidth = 2;
this.ctx.stroke();
}
getRelativePos(e) {
const rect = this.canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
}
}
这段代码跑起来基本能用,但离生产环境还差得远。真正的问题,全在细节里。
最大的坑:性能问题
测试阶段一切正常,直到 QA 同事在一台老款 MacBook 上试了下——卡成 PPT。仔细一看,原来每次 mousemove 都会触发 redraw,而 redraw 里又调用了 clearRect + 整个路径重绘。高频事件下,这简直是性能杀手。
我一开始想加防抖,但防抖会导致绘制不流畅,用户会觉得“鼠标跟不上手”。后来改成节流(throttle),设了 16ms(≈60fps),效果好多了,但仍有轻微卡顿。
最后灵机一动:为什么非要把所有绘制都放在 Canvas 上?其实可以分两层:底层 Canvas 保存最终区域,上层 Canvas 只负责临时预览线。这样每次 mousemove 只需要清空上层并重绘一条线,性能瞬间提升。
改完后结构变成这样:
<div style="position: relative;">
<canvas id="final-canvas" style="position: absolute; top: 0; left: 0;"></canvas>
<canvas id="temp-canvas" style="position: absolute; top: 0; left: 0;"></canvas>
</div>
JS 里也拆成两个 ctx,临时绘制只操作 tempCtx,确认完成后再把路径画到 finalCtx 并清空临时层。亲测有效,连 iPad 上都流畅了。
又踩坑了,touchmove滚动失效
移动端适配时,发现手指一滑页面就跟着滚,根本画不了。查了下才知道,移动端的 touchmove 默认会触发页面滚动,必须手动阻止。
加了这行才搞定:
this.canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
}, { passive: false });
注意 passive: false,不然某些浏览器会忽略 preventDefault()。这个坑我踩过好几次,每次都要重新翻文档。
边界情况:用户乱点怎么办?
真实用户可不会按你设想的流程走。有人双击、有人快速点击十几次、还有人画完又点一下空白处——结果整个区域消失了。
我们加了几条规则:
- 点数少于 3 个时不闭合(三角形是最小多边形)
- 连续两次点击距离太近(<5px)视为重复点击,忽略
- 点击已绘制区域时,不新建区域,而是进入“编辑模式”(这个功能后来砍了,太复杂)
最头疼的是撤销功能。最初用数组 push/pop,但用户中途改主意,撤到一半又想重画,状态管理一团糟。最后干脆用命令模式,每一步操作存为一个对象,支持 undo/redo。虽然代码量多了,但逻辑清晰多了。
最终的解决方案
综合下来,我们的最终方案是:
- 双 Canvas 分层:底层存最终结果,上层做临时预览
- 事件统一处理:mouse 和 touch 事件分别绑定,但内部调用同一套逻辑函数
- 点过滤:去重、去噪、最小点数校验
- 导出为 GeoJSON 格式,方便后端处理
导出部分很简单,就是把 points 数组转成标准格式:
exportToGeoJSON() {
if (this.points.length < 3) return null;
const coords = this.points.map(p => [p.x, p.y]);
coords.push(coords[0]); // 闭合
return {
type: 'Polygon',
coordinates: [coords]
};
}
后端直接拿这个去查数据库里的设备点是否在多边形内,用的是 PostGIS 的 ST_Contains,很稳。
回顾与反思
整体效果还不错,客户验收一次通过。性能、兼容性、交互都达到了预期。但有几个小问题一直没动:
- 用户画完后,不能微调顶点(拖拽修改)。本来计划做,但工期紧,先砍了
- 在高 DPI 屏幕上,Canvas 会模糊。加了
devicePixelRatio适配,但部分安卓机还是有点糊
说实话,第二个问题影响不大,肉眼看不出区别,所以就没深究。有时候“够用就行”比“完美”更重要,尤其是在 deadline 压着的时候。
另外,如果重来一次,我会考虑用 OffscreenCanvas 做更彻底的性能隔离,不过目前方案已经扛住了 200+ 并发用户的压力测试,没必要过度优化。
以上是我个人对区域绘制的完整实战总结,有更优的实现方式欢迎评论区交流。这个技巧的拓展用法还有很多——比如结合 Web Worker 做复杂区域判断,或者用 WebGL 加速大规模绘制——后续会继续分享这类博客。希望这篇踩坑记录对你有帮助。

暂无评论