第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 隐藏 |