第05章 - 图层体系与图层控制

Leaflet 的图层体系是一个三层结构:基类契约(LayerBase / Layer 规定图层必须实现什么)、分组容器(LayerGroup / FeatureGroup / GeoJSON 把多个图层当作一个操作)、图层控制器(LayersControl 给用户切换的界面)。

新手常犯的错误是把这三层混为一谈:以为 LayerGroup 是”图层集合的 UI”,以为 LayersControl 会自动加载图层,以为 addTo 可以重复调用。这一章把职责切清楚,并给出分组、批量操作、可见性控制的可用写法。

5.1 所有图层共享的契约

5.1.1 三个默认选项

src/layer/LayerBase.js 定义了每个图层都继承的三个选项:

选项 默认值 作用
pane 'overlayPane' 图层 DOM 挂到哪个 pane;GridLayer 覆盖为 'tilePane',Marker 为 'markerPane',弹窗为 'popupPane'
attribution null 显示在版权控件里的字符串;TileLayer 检测到 OSM 域名且未设置时自动补 OSM 版权
bubblingPointerEvents true 指针事件是否冒泡到地图;Marker 覆盖为 false

pane 与 renderer 有一个优先级关系,源码注释明确写了:Not effective if the renderer option is set (the renderer option will override the pane option)。给 Path 同时设两个时,renderer 赢。

5.1.2 必须实现与可选实现的方法

方法 是否必须 语义
onAdd(map) 必须 图层被加入地图时调用,创建 DOM、挂事件
onRemove(map) 必须 移除时调用,清理 DOM 与事件
beforeAdd(map) 可选 加入前调用,用于根据地图配置做初始化
getEvents() 可选 返回 {事件名: 回调},框架自动绑定并在移除时解绑
getAttribution() 可选 供版权控件读取
addTo(map) / remove() 继承 前者等价 map.addLayer(this),后者从当前地图移除

getEvents 是最值得用的可选方法。它的实现位于 LayerBase._layerAdd:

if (this.getEvents) {
  const events = this.getEvents();
  map.on(events, this);
  this.once('remove', () => map.off(events, this));   // 自动解绑
}

自己写 onAdd 里 map.on(...) 也行,但必须记得在 onRemove 里对称地 map.off。用 getEvents 就不会漏。

5.1.3 addTo 的重复调用

const marker = new Marker([30.27, 120.15]);
marker.addTo(map);
marker.addTo(map);   // 第二次什么也不做
console.log(Object.keys(map._layers).length);  // 1

Map.addLayer 用 Util.stamp(layer) 生成唯一 ID 做去重,重复添加直接 return this。这带来一个隐性行为:同一个图层对象不能同时属于两张地图。需要显示在两处时必须创建两个实例,或者用插件做同步。

5.2 分组:LayerGroup 与 FeatureGroup

5.2.1 二者的差异

FeatureGroup 继承 LayerGroup,只多做了四件事:

能力 LayerGroup FeatureGroup
成员事件冒泡到分组 否 是(addLayer 时调 addEventParent)
bindPopup 一次绑到所有成员 否 是
setStyle 批量改样式 否 是(逐个调成员的 setStyle)
getBounds() 聚合包围盒 否 是
layeradd / layerremove 事件 否 是

代码上的差别很直接:

import {LeafletMap, TileLayer, Marker, Polyline, LayerGroup, FeatureGroup} from 'leaflet';

const map = new LeafletMap('map').setView([30.27, 120.15], 12);
new TileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {maxZoom: 19}).addTo(map);

// FeatureGroup:能批量绑弹窗、能冒泡事件、能取聚合 bounds
const fg = new FeatureGroup([
  new Marker([30.27, 120.15]),
  new Marker([30.30, 120.20])
]).addTo(map);

fg.bindPopup('我是分组里的成员');
fg.on('click', e => console.log('点了', e.propagatedFrom.getLatLng()));

console.log(fg.getBounds().toBBoxString());  // 聚合包围盒

// LayerGroup:只负责"一起加、一起删",没有上面这些能力
const lg = new LayerGroup([new Polyline([[30.2, 120.1], [30.3, 120.2]])]).addTo(map);
lg.clearLayers();

判断标准很简单:需要对成员做统一操作(统一样式、统一弹窗、聚合范围、统一事件)就用 FeatureGroup;只需要成组加载/卸载就用 LayerGroup,后者更轻。

5.2.2 嵌套与遍历

分组可以嵌套,eachLayer 只遍历直接成员,FeatureGroup.getBounds 会递归到嵌套分组:

const inner = new LayerGroup([new Marker([30.27, 120.15])]);
const outer = new FeatureGroup([inner, new Marker([30.30, 120.20])]);
outer.addTo(map);

outer.eachLayer(l => console.log(l.constructor.name));   // LayerGroup, Marker
console.log(outer.getBounds().isValid());                 // true,包含嵌套分组的点

5.2.3 GeoJSON 是 FeatureGroup 的子类

GeoJSON 继承 FeatureGroup,所以上面所有能力它都有。差别在于它多了一套”从数据到图层”的转换:style、onEachFeature、pointToLayer、filter、coordsToLatLng。

import {GeoJSON} from 'leaflet';

const data = {
  type: 'FeatureCollection',
  features: [
    {type: 'Feature', properties: {name: 'A', level: 2},
     geometry: {type: 'Point', coordinates: [120.15, 30.27]}}
  ]
};

new GeoJSON(data, {
  filter: feature => feature.properties.level > 1,
  pointToLayer: (feature, latlng) => new CircleMarker(latlng, {radius: 8}),
  onEachFeature: (feature, layer) => layer.bindPopup(feature.properties.name)
}).addTo(map);

数据格式的细节在第 13 章展开,这里只需记住:GeoJSON 是 FeatureGroup,所以 .getBounds()、.setStyle()、.eachLayer() 都能直接用。

5.3 LayersControl:给用户切换的界面

5.3.1 基本用法

import {LeafletMap, TileLayer, LayersControl, Marker, FeatureGroup} from 'leaflet';

const map = new LeafletMap('map').setView([30.27, 120.15], 12);

const osm = new TileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
  maxZoom: 19, attribution: '© OpenStreetMap contributors'
});
const dark = new TileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', {
  subdomains: 'abcd', maxZoom: 20, attribution: '© OpenStreetMap contributors © CARTO'
});

osm.addTo(map);   // 默认显示的底图必须自己先 addTo

const pois = new FeatureGroup([new Marker([30.27, 120.15])]).addTo(map);

new LayersControl(
  {'标准底图': osm, '暗色底图': dark},       // 基础图层:单选
  {'兴趣点': pois}                            // 叠加层:多选
).addTo(map);

一个必踩的坑:LayersControl 不会自动把底图加到地图上。它的构造函数只登记,选中状态由 map.addLayer / removeLayer 驱动。所以第一个参数里的图层,至少有一个要在创建控件前显式 addTo(map),否则打开页面是空白。

5.3.2 选项

选项 默认值 说明
collapsed true 折叠成图标,悬停/点击展开
collapseDelay 0 折叠延迟毫秒数;长列表建议设 2000 左右,方便滚动
position 'topright' 四角之一
title 'Layers' 展开按钮的标题与列表的无障碍标签,可配 translate 翻译
autoZIndex true 给支持 setZIndex 的图层自动分配递增 z-index,保证切换顺序稳定
hideSingleBase false 只有一个基础图层时隐藏该分组
sortLayers false 按名称排序
sortFunction 按名字字母序 自定义排序

autoZIndex 的实现值得看一眼:

if (this.options.autoZIndex && layer.setZIndex) {
  this._lastZIndex++;
  layer.setZIndex(this._lastZIndex);
}

它只对”有 setZIndex 方法”的图层生效。GridLayer、ImageOverlay、VideoOverlay 有;Marker、Path 没有。所以”我加了控件但 marker 层级还是乱的”是预期行为——marker 的层级由纬度决定(见第 8 章)。

5.3.3 事件

事件 触发时机
baselayerchange 用户切换基础图层
overlayadd 用户勾选叠加层
overlayremove 用户取消勾选叠加层
map.on('baselayerchange', e => {
  console.log('切换到底图:', e.name);
  // e.layer 是当前选中的图层
});

注意这些事件只在用户通过控件操作时触发。代码里直接 map.addLayer() 不会触发 baselayerchange,只会触发 layeradd。

5.4 地图侧的图层管理

5.4.1 增删查

方法 说明
map.addLayer(layer) 加入图层;重复加入无副作用
map.removeLayer(layer) 移除;不存在时静默返回
map.hasLayer(layer) 判断是否在图上
map.eachLayer(fn, ctx) 遍历所有图层
map.getPane(name) / getPanes() 取 pane 元素

map.removeLayer 找不到图层时不抛错:

const marker = new Marker([30, 120]);
map.removeLayer(marker);   // 没加过,静默返回

2.0.0-beta.1 的 bug fix 列表里就有”removeLayer 在未知 id 上抛异常”这一条,该版本已修复。如果你的代码依赖”移除不存在的图层要报错”,得自己判断。

5.4.2 用分组做业务层的图层注册表

真实项目里图层数量一多,散落在各处的 addTo / remove 会失控。一个可行的组织方式是按业务域分 LayerGroup,把组当注册表:

const registry = {
  base: new LayerGroup(),
  poi: new LayerGroup(),
  analysis: new LayerGroup()
};

for (const group of Object.values(registry)) {
  group.addTo(map);   // 组在图上,成员按需增删
}

// 加载 POI
registry.poi.clearLayers();
layers.forEach(l => registry.poi.addLayer(l));

// 一次性隐藏分析结果
map.removeLayer(registry.analysis);
registry.analysis.addTo(map);   // 重新显示

这种方式的好处是”清空重载”和”整组显隐”都只有一行,而且 eachLayer 天然支持按组做批量操作。

5.4.3 z-index 与显隐

  • 显隐用 map.removeLayer / addLayer,不要改 display: none——后者会让 Leaflet 继续为不可见图层计算位置与加载瓦片。
  • 层级用 pane(见第 3 章)或图层的 zIndex 选项。TileLayer 支持 zIndex(默认 1)与 setZIndex();ImageOverlay 也一样。
  • marker 的相对层级由 zIndexOffset(默认 0)与纬度决定,不是全局 z-index。

5.5 踩坑清单

现象 原因 修法
打开页面底图空白,切换后正常 没有任何底图被 addTo(map) 创建控件前先把默认底图加上
勾选叠加层后事件不触发 只监听了 layeradd,没监听 overlayadd 控件操作触发的是 overlayadd
同一图层加到两张地图,第二张不显示 图层实例绑定唯一 _map 创建两个实例,或用插件同步
移除图层后仍占内存 / 仍响应事件 自定义图层漏了 onRemove 清理 用 getEvents(),或手动 map.off
分组 getBounds() 抛错 用了 LayerGroup(无此方法) 改用 FeatureGroup
clearLayers() 后地图还在请求瓦片 清的是分组,瓦片层不在组里 检查瓦片层是否加到了分组
两层瓦片叠在一起看不出效果 都用了默认 zIndex: 1 设成 2 / 3 等不同值

5.6 生产建议

底图只留一个在图上。 LayersControl 的基础图层组是单选,但代码里如果直接对多个底图 addTo,会出现多层瓦片叠加、请求量翻倍。用一个封装函数统一切换:

let currentBase = null;

function setBaseLayer(layer) {
  if (currentBase) {
    map.removeLayer(currentBase);
  }
  map.addLayer(layer);
  currentBase = layer;
}

图层实例复用,不要每次重建。 反复 new TileLayer(...) + addTo 会让浏览器重复下载同一批瓦片。把常用图层在模块级创建一次,之后只做 addLayer / removeLayer。

业务分组与控件分组分开。 LayersControl 只暴露给用户真正需要切换的图层;分析结果、临时图形这类不该让用户关掉的,放在独立 LayerGroup 里,不进控件。

attribution 不要省。 大多数瓦片服务商的条款要求署名,OSM 更严格。TileLayer 会自动识别 tile.openstreetmap.org 并补默认版权,但换成自建服务时不会。

5.7 本章小结

要点 结论
图层契约 必须实现 onAdd / onRemove;getEvents 可让框架代管事件绑定
三个共享选项 pane(默认 overlayPane)、attribution(null)、bubblingPointerEvents(true,Marker 为 false)
分组选择 只需成组增删用 LayerGroup;要批量弹窗/样式/范围/事件冒泡用 FeatureGroup
GeoJSON 继承 FeatureGroup,具备全部批量能力
LayersControl 不会自动加底图;autoZIndex 只对有 setZIndex 的图层生效
控件事件 baselayerchange / overlayadd / overlayremove 仅由用户操作触发
显隐方式 用 addLayer / removeLayer,不要用 CSS 隐藏