第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 有完整列表),实际项目中影响最大的是三条:

  1. custom layer 的 render 第一参数是 WebGL2RenderingContext。 上下文类型收紧,WebGL1 的写法(gl.createVertexArray 不存在等)需要改。
  2. 碰撞属性从 *-allow-overlap 转向 *-overlap。 旧写法仍能工作(作为回退),但新代码应直接用 never/always/cooperative。
  3. worker 数与缓存策略变化。 升级后要重跑 20.6 的性能检查,不要假设旧参数仍最优。

升级流程:锁版本 → 跑通 20.6 清单 → 对比帧率与内存 → 再合并。不要和业务功能一起升。

20.8 本章小结

要点 事实
代码分层 入口 / 配置 / 数据 / 地图 / UI 五层,业务图层带卸载函数
图层装配 挂 style.load,不是 load;setStyle 后需重放
底图选择 矢量瓦片可实时换色,栅格只能换 URL;PMTiles 运维最低
交互清理 换图层同步 off,否则回调重复执行
上线门槛 首屏 ≤1.5s、40fps、内存不涨、404 <0.5%、版权齐全
升级策略 锁版本、单独升级、跑完清单再合并