04 地图图层封装:像管理 Photoshop 图层一样管理地图元素
我们要做什么
一个地图页面上可能同时有:底图、禁飞区(红色区域)、航线(橙色线)、无人机位置(动画图标)、行政边界......几十种元素。
如果每加一个元素都随手写一堆代码,很快就会变成灾难:
- 同一个"禁飞区"在 10 个页面有 10 种写法
- 页面关掉了,某个动画定时器还在后台跑(内存泄漏)
- 想加一种新元素,要改一堆老代码
这一篇我们做一个图层管理器 :像 Photoshop 的图层面板一样,每种元素是一个"图层",统一添加、统一删除、统一销毁,而且图层长什么样由后端配置决定,前端不用改代码。
先懂几个概念
概念 1:图层就是"会自己画自己"的对象
每个图层只需提供两个能力:
render()------ 把自己画到画布上destroy()------ 把自己用的资源(定时器、图片、监听器)清理干净
管理器不关心你是红方块还是蓝圆圈,只按顺序调用 render,删除时调用 destroy。
概念 2:注册表------类型和做法分离
"网格图层怎么画"和"什么时候加一个网格图层"是两件事。把"怎么画"提前登记到一个表里(注册),用的时候报名字就行:
css
注册: 'grid' → 画网格的方法
使用: addLayer({ type: 'grid' }) → 管理器查表,找到画法,创建实例
好处:新增一种图层 = 新注册一项,老代码一行不改(开闭原则)。
概念 3:配置驱动
"页面上要显示哪些图层"不硬编码在前端,而是后端接口下发一份 JSON。好处:不同客户看到的地图不一样,改数据库就行,不用发版。
整体流程图
bash
【Node 后端】
GET /api/layers → 返回图层配置清单:
[
{ id:'grid', type:'grid', zIndex:1, options:{...} },
{ id:'zone', type:'polygon', zIndex:2, options:{...} },
{ id:'route', type:'flowline', zIndex:3, options:{...} }
]
【前端】
1. 创建图层管理器,注册三种类型的画法
2. 拉取配置 → 逐条 addLayer
3. 管理器内部:查注册表 → 创建实例 → 按 zIndex 排序 → 逐个 render
4. 页面关闭 → 管理器 destroy → 每个图层的 destroy 被调用
(动画定时器被清掉,没有泄漏)
动手做
第 1 步:Node 后端(只负责下发配置)
新建 demos/04-layer,npm init -y && npm i express cors,新建 server.js:
js
const express = require('express');
const cors = require('cors');
const app = express();
app.use(cors());
// 图层配置:真实项目里这存在数据库,不同客户不同配置
app.get('/api/layers', (req, res) => {
res.json([
{ id: 'bg-grid', type: 'grid', zIndex: 1, options: { step: 50 } },
{ id: 'no-fly-zone', type: 'polygon', zIndex: 2, options: {
points: [[100, 100], [300, 80], [350, 250], [150, 300]],
color: 'rgba(255,0,0,0.3)' } },
{ id: 'airline', type: 'flowline', zIndex: 3, options: {
points: [[50, 400], [200, 350], [400, 380], [600, 300]],
color: '#ff8800' } },
]);
});
app.listen(3000, () => console.log('http://localhost:3000'));
第 2 步:前端图层管理器
新建 LayerManager.js(本课核心,仔细看注释):
js
// 图层管理器:统一添加/删除/排序/销毁
export class LayerManager {
constructor(canvas) {
this.ctx = canvas.getContext('2d');
this.canvas = canvas;
this.registry = new Map(); // 类型名 → 创建方法(注册表)
this.layers = new Map(); // id → 图层实例
}
// 注册一种图层的画法。factory 必须返回 { render, destroy }
registerType(type, factory) {
this.registry.set(type, factory);
}
// 添加图层(同一个 id 重复添加会先删旧再加新,防重复)
addLayer(cfg) {
if (this.layers.has(cfg.id)) this.removeLayer(cfg.id);
const factory = this.registry.get(cfg.type);
if (!factory) throw new Error('没注册过的图层类型: ' + cfg.type);
const instance = factory(this.ctx, cfg.options || {});
this.layers.set(cfg.id, { ...cfg, instance });
this.render();
}
// 删除图层:先调 destroy 清理资源,再移除引用
removeLayer(id) {
const layer = this.layers.get(id);
if (!layer) return;
layer.instance.destroy?.(); // ?. = 允许某些图层没有东西要清
this.layers.delete(id);
this.render();
}
// 重绘整个画布:按 zIndex 从低到高画(低的在底下)
render() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
[...this.layers.values()]
.sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0))
.forEach(l => l.instance.render());
}
// 页面关闭时整体销毁
destroy() {
[...this.layers.keys()].forEach(id => this.removeLayer(id));
}
}
第 3 步:注册画法 + 使用
新建 index.html:
html
<!DOCTYPE html>
<html>
<body>
<canvas id="map" width="800" height="500" style="border:1px solid #ccc"></canvas>
<button id="remove">移除航线图层</button>
<script type="module">
import { LayerManager } from './LayerManager.js';
const manager = new LayerManager(document.getElementById('map'));
// 注册画法 1:网格(静态,无需清理资源)
manager.registerType('grid', (ctx, opt) => ({
render() {
ctx.strokeStyle = '#eee';
for (let x = 0; x < 800; x += opt.step) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, 500); ctx.stroke();
}
for (let y = 0; y < 500; y += opt.step) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(800, y); ctx.stroke();
}
},
destroy() {}, // 没有资源要清
}));
// 注册画法 2:多边形区域
manager.registerType('polygon', (ctx, opt) => ({
render() {
ctx.fillStyle = opt.color;
ctx.beginPath();
opt.points.forEach((p, i) => i ? ctx.lineTo(...p) : ctx.moveTo(...p));
ctx.closePath(); ctx.fill();
},
destroy() {},
}));
// 注册画法 3:流动虚线(有动画定时器,destroy 必须清理!)
manager.registerType('flowline', (ctx, opt) => {
let offset = 0;
const timer = setInterval(() => { offset -= 2; manager.render(); }, 50);
return {
render() {
ctx.strokeStyle = opt.color; ctx.lineWidth = 3;
ctx.setLineDash([12, 8]); ctx.lineDashOffset = offset;
ctx.beginPath();
opt.points.forEach((p, i) => i ? ctx.lineTo(...p) : ctx.moveTo(...p));
ctx.stroke();
ctx.setLineDash([]);
},
destroy() { clearInterval(timer); }, // 关键!不清就会泄漏
};
});
// 图层清单完全由后端决定
const layers = await fetch('http://localhost:3000/api/layers').then(r => r.json());
layers.forEach(cfg => manager.addLayer(cfg));
document.getElementById('remove').onclick = () => manager.removeLayer('airline');
window.addEventListener('beforeunload', () => manager.destroy());
</script>
</body>
</html>
用了 ES Module,直接双击打开会被浏览器拦。在目录里跑
npx serve .然后访问提示的地址(同时 server.js 也要在跑)。
跑起来看看
- 灰色网格 + 红色半透明区域 + 一条流动的橙色虚线
- 点"移除航线图层"→ 虚线消失,动画定时器也被停掉了
- 验证泄漏:把
flowline里的destroy改成空函数,重复"移除→刷新页面"几次,打开 Chrome DevTools 的 Performance 面板能看到定时器越积越多------体会 destroy 的意义
关键点回顾
| 知识点 | 一句话 |
|---|---|
| 图层抽象 | 每个图层只要会 render 和 destroy,管理器不关心你画什么 |
| 注册表 | 类型和做法分离,新增图层类型不改老代码 |
| 配置驱动 | 显示什么图层由后端 JSON 决定,前端零改动支持不同客户 |
| 资源治理 | 定时器/图片/监听器必须在 destroy 里清理,否则页面切多了浏览器会崩 |
| zIndex 排序 | 重绘时从低到高画,实现图层上下叠放 |
想一想
- 如果业务要求"暂时隐藏航线但稍后还要显示",是 removeLayer 好还是加个
visible标记好?为什么?(提示:创建/销毁有成本) - addLayer 为什么要做"同 id 先删后加"?如果不管,业务方重复添加会发生什么?