第20章 - 综合实战与项目落地
20.1 项目要做成的样子
前 19 章把 API 拆开讲了一遍。这一章把它们装回一个真实场景:一个”网点运营看板”——矢量底图 + 网点聚合 + 热力分布 + 地形开关 + 详情弹窗,要求首屏 1.5 秒内可交互、缩放不掉到 40 fps 以下、能在 3 年前的笔记本上跑。
这个需求覆盖了几乎所有关键决策点,而且每个决策都有代价。下面按”搭骨架 → 定数据 → 做交互 → 保性能 → 上线”的顺序走一遍。
20.2 骨架:先定分层,再写代码
看板的代码分四层,层间只通过数据传递,不互相 import 实现细节:
main.js 入口:创建 Map、装配模块
├── config.js 配置:底图 URL、初始视角、业务图层参数
├── data/ 数据层:fetch 封装、GeoJSON 规范化、缓存
├── map/ 地图层:图层定义、样式切换、交互绑定
└── ui/ 界面层:面板、图例、筛选器(DOM,不进 MapLibre)
不要把所有逻辑塞进 map.on('load')。 一个 800 行的 load 回调是这类项目最常见的债务来源:换底图要改它、加图层要改它、调性能还要改它。每个业务图层一个函数,返回”卸载函数”,样式切换时统一调用卸载再重建。
// map/layers/points.js
export function mountPoints(map, sourceId, layerId, data) {
map.addSource(sourceId, {
type: 'geojson',
data,
cluster: true,
clusterRadius: 60,
clusterMaxZoom: 14
});
map.addLayer({ id: layerId + '-cluster', type: 'circle', source: sourceId,
filter: ['has', 'point_count'],
paint: {
'circle-color': ['step', ['get', 'point_count'], '#51bbd6', 100, '#f1f075', 750, '#f28cb1'],
'circle-radius': ['step', ['get', 'point_count'], 18, 100, 24, 750, 30]
}
});
map.addLayer({ id: layerId + '-count', type: 'symbol', source: sourceId,
filter: ['has', 'point_count'],
layout: { 'text-field': ['get', 'point_count_abbreviated'], 'text-size': 12, 'text-allow-overlap': true }
});
map.addLayer({ id: layerId + '-point', type: 'circle', source: sourceId,
filter: ['!', ['has', 'point_count']],
paint: { 'circle-color': '#11b4da', 'circle-radius': 4, 'circle-stroke-width': 1, 'circle-stroke-color': '#fff' }
});
return function unmount() {
for (const id of [layerId + '-cluster', layerId + '-count', layerId + '-point']) {
if (map.getLayer(id)) map.removeLayer(id);
}
if (map.getSource(sourceId)) map.removeSource(sourceId);
};
}
if (map.getLayer(id)) 这种判空不是洁癖:换样式(setStyle)会清空所有图层,重放时如果无条件 addLayer 会抛 Layer already exists。
20.3 底图与样式策略
底图有三条路,按运维成本排:
| 方案 | 首屏 | 运维 | 适合 |
|---|---|---|---|
| 公共栅格瓦片(OSM 等) | 中 | 零 | 原型、内部工具 |
| 自建矢量瓦片(Martin/TiTiler) | 快 | 需服务 | 数据敏感、需定制样式 |
| PMTiles 单文件 + CDN | 快 | 极低 | 静态底图、演示/离线包 |
矢量底图的好处不只是清晰:setPaintProperty 能实时改配色,一套瓦片做出”日间/夜间”两套风格,不用两份瓦片。栅格底图做不到——只能换 URL,重下一遍。
底图切换用 setStyle 最省事,但代价是所有自定义图层丢失(19.4 节讲过 serialize 会跳过 type === 'custom')。正确做法是把图层装配挂到 style.load:
function applyLayers() {
mountPoints(map, 'sites', 'sites', sitesData);
mountHeat(map, 'sites');
}
map.on('style.load', applyLayers);
function switchBase(styleUrl) {
map.setStyle(styleUrl); // 触发 style.load → 自动重放图层
}
注意 style.load 也会在首次加载时触发,所以 applyLayers 里不能假设”图层不存在”,判空必不可少。
20.4 完整装配示例
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>网点运营看板</title>
<link href="https://unpkg.com/maplibre-gl@6.17.0/dist/maplibre-gl.css" rel="stylesheet" />
<style>
body { margin: 0; }
#map { height: 100vh; }
.toolbar { position: absolute; top: 12px; left: 12px; z-index: 5; display: flex; gap: 6px;
background: #fff; padding: 6px; border-radius: 6px; box-shadow: 0 0 0 2px rgba(0,0,0,.1); }
.toolbar button { border: 0; background: #f1f3f5; padding: 6px 10px; border-radius: 4px; cursor: pointer; }
.toolbar button[aria-pressed="true"] { background: #1d3557; color: #fff; }
</style>
</head>
<body>
<div id="map"></div>
<div class="toolbar">
<button id="btn-base" aria-pressed="false">暗色底图</button>
<button id="btn-terrain" aria-pressed="false">地形</button>
<button id="btn-heat" aria-pressed="true">热力</button>
</div>
<script src="https://unpkg.com/maplibre-gl@6.17.0/dist/maplibre-gl.js"></script>
<script>
const CENTER = [116.39, 39.9];
// 演示用数据:真实项目从接口取,再做降精度与去属性
const sites = {
type: 'FeatureCollection',
features: Array.from({ length: 2000 }, (_, i) => ({
type: 'Feature',
id: i,
properties: { name: '网点' + i, capacity: 10 + (i % 40) },
geometry: {
type: 'Point',
coordinates: [CENTER[0] + (Math.random() - 0.5) * 0.6, CENTER[1] + (Math.random() - 0.5) * 0.4]
}
}))
};
const map = new maplibregl.Map({
container: 'map',
style: 'https://demotiles.maplibre.org/style.json',
center: CENTER,
zoom: 9,
fadeDuration: 200,
crossSourceCollisions: false
});
map.addControl(new maplibregl.NavigationControl({ visualizePitch: true }), 'top-right');
map.addControl(new maplibregl.ScaleControl({ maxWidth: 120 }), 'bottom-left');
let unmountBusiness = null;
function applyLayers() {
if (unmountBusiness) unmountBusiness();
if (!map.getSource('sites')) {
map.addSource('sites', { type: 'geojson', data: sites, cluster: true, clusterRadius: 60, clusterMaxZoom: 14 });
}
if (!map.getLayer('heat')) {
map.addLayer({
id: 'heat', type: 'heatmap', source: 'sites',
paint: {
'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 0, 4, 9, 20, 15, 40],
'heatmap-weight': ['get', 'capacity'],
'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 0, 1, 9, 2.5],
'heatmap-color': ['interpolate', ['linear'], ['heatmap-density'],
0, 'rgba(33,102,172,0)', 0.2, 'rgb(103,169,207)', 0.5, 'rgb(253,219,199)', 1, 'rgb(178,24,43)'],
'heatmap-opacity': ['interpolate', ['linear'], ['zoom'], 11, 0.85, 13, 0]
}
});
}
if (!map.getLayer('sites-cluster')) {
map.addLayer({ id: 'sites-cluster', type: 'circle', source: 'sites', filter: ['has', 'point_count'],
paint: {
'circle-color': ['step', ['get', 'point_count'], '#51bbd6', 100, '#f1f075', 750, '#f28cb1'],
'circle-radius': ['step', ['get', 'point_count'], 16, 100, 22, 750, 28]
}
});
map.addLayer({ id: 'sites-count', type: 'symbol', source: 'sites', filter: ['has', 'point_count'],
layout: { 'text-field': ['get', 'point_count_abbreviated'], 'text-size': 12, 'text-allow-overlap': true }
});
map.addLayer({ id: 'sites-point', type: 'circle', source: 'sites', filter: ['!', ['has', 'point_count']],
paint: { 'circle-color': '#e63946', 'circle-radius': 5 }
});
}
unmountBusiness = () => { /* 业务图层清理逻辑 */ };
}
map.on('style.load', applyLayers);
// 聚合交互:点簇放大,点单点开弹窗
const popup = new maplibregl.Popup({ closeOnClick: true, maxWidth: '280px' });
map.on('click', 'sites-cluster', async (e) => {
const f = e.features[0];
const zoom = await map.getSource('sites').getClusterExpansionZoom(f.properties.cluster_id);
map.easeTo({ center: f.geometry.coordinates, zoom, duration: 500 });
});
map.on('click', 'sites-point', (e) => {
const f = e.features[0];
popup.setLngLat(f.geometry.coordinates)
.setDOMContent((() => {
const div = document.createElement('div');
const h = document.createElement('b');
h.textContent = f.properties.name; // 外部数据走 textContent
div.appendChild(h);
return div;
})())
.addTo(map);
});
// 工具按钮
document.getElementById('btn-terrain').addEventListener('click', (e) => {
const btn = e.currentTarget;
const on = btn.getAttribute('aria-pressed') === 'true';
if (!on) {
if (!map.getSource('dem')) {
map.addSource('dem', { type: 'raster-dem',
tiles: ['https://demotiles.maplibre.org/terrain-tiles/tiles.json'], tileSize: 256 });
}
map.setTerrain({ source: 'dem', exaggeration: 1.3 });
map.easeTo({ pitch: 62, duration: 600 });
} else {
map.setTerrain(null);
map.easeTo({ pitch: 0, duration: 600 });
}
btn.setAttribute('aria-pressed', String(!on));
});
document.getElementById('btn-heat').addEventListener('click', (e) => {
const btn = e.currentTarget;
const visible = map.getLayoutProperty('heat', 'visibility') !== 'none';
map.setLayoutProperty('heat', 'visibility', visible ? 'none' : 'visible');
btn.setAttribute('aria-pressed', String(!visible));
});
map.on('error', (err) => console.error('[map]', err.error && err.error.message));
</script>
</body>
</html>
这段代码把前面各章的核心用法串起来了:cluster 源 + 过滤分层(第 15 章)、heatmap 首档透明(第 15 章)、getClusterExpansionZoom 异步(第 15 章)、setTerrain 传 null 关闭(第 14 章)、setDOMContent 避免 XSS(第 12 章)、style.load 重放图层(第 19 章)。
20.5 三处最常出事的配置
style.load 与 load 混用。 load 只触发一次,style.load 每次换样式都触发。图层装配一律挂 style.load,相机初始化挂 load。挂错的症状是”换底图后图层消失”或”刷新后图层重复两遍”。
清空交互监听。 map.on('click', 'layer-id', fn) 绑定的是图层 id;图层被 removeLayer 后监听还在,重建同名图层会导致回调执行两次。移除图层时同步 map.off('click', 'layer-id', fn)。
销毁顺序。 单页应用离开页面时:先 map.off() 自己的监听,再 map.remove();map.remove() 之后调用任何方法都会抛错。DOM 里的 Popup/Marker 由 map.remove() 清理,但你自己挂在地图容器上的 DOM 覆盖层要手动删。
20.6 上线检查清单
| 类别 | 检查项 | 判定标准 |
|---|---|---|
| 首屏 | 从 new Map 到 load |
≤ 1.5s(4G、中端机) |
| 交互 | 缩放时帧间隔 | 平均 ≤ 25ms(约 40fps) |
| 内存 | 连续操作 5 分钟后 JS 堆 | 无持续增长(±10%) |
| 底图 | 瓦片 404 率 | < 0.5%,且集中在数据边界外 |
| 无障碍 | 控件 aria-label |
已本地化(locale 配置) |
| 版权 | 每类数据源 attribution |
全部出现在版权控件 |
| 移动端 | 双指缩放 / 旋转 | 关闭 cooperativeGestures 时不误触 |
| 降级 | WebGL 不可用时 | 有提示,不白屏(监听 GPUInitializationError) |
| 版本 | getVersion() |
与锁定版本一致,CDN 引用带版本号 |
最后一项常被忽略:CDN 用 @latest 会让线上版本随上游漂移,某天突然因为一个次版本的行为变化而坏掉。https://unpkg.com/maplibre-gl@6.17.0/... 里的版本号必须写死,或者自建包托管。
20.7 迁移与升级
v5 → v6 的迁移要点(docs/guides/v5-to-v6-migration-guide.md 有完整列表),实际项目中影响最大的是三条:
- custom layer 的
render第一参数是WebGL2RenderingContext。 上下文类型收紧,WebGL1 的写法(gl.createVertexArray不存在等)需要改。 - 碰撞属性从
*-allow-overlap转向*-overlap。 旧写法仍能工作(作为回退),但新代码应直接用never/always/cooperative。 - worker 数与缓存策略变化。 升级后要重跑 20.6 的性能检查,不要假设旧参数仍最优。
升级流程:锁版本 → 跑通 20.6 清单 → 对比帧率与内存 → 再合并。不要和业务功能一起升。
20.8 本章小结
| 要点 | 事实 |
|---|---|
| 代码分层 | 入口 / 配置 / 数据 / 地图 / UI 五层,业务图层带卸载函数 |
| 图层装配 | 挂 style.load,不是 load;setStyle 后需重放 |
| 底图选择 | 矢量瓦片可实时换色,栅格只能换 URL;PMTiles 运维最低 |
| 交互清理 | 换图层同步 off,否则回调重复执行 |
| 上线门槛 | 首屏 ≤1.5s、40fps、内存不涨、404 <0.5%、版权齐全 |
| 升级策略 | 锁版本、单独升级、跑完清单再合并 |