第10章 - 控件系统与界面定制
10.1 控件不是装饰品,是地图的 API 表面
地图容器里能放的东西分三类:会随地图移动的 Layer、解释浏览器事件并改变地图状态的 Handler、固定在地图角落的 Control。前两类在第 5、11 章讲透,本章只处理第三类。判断标准很简单:滚动、缩放地图时位置是否变化——变的是 Layer,不变的是 Control。
控件之所以重要,是因为它把地图能力暴露给用户。一个没有缩放控件的地图,滚轮缩放和键盘 +/- 仍然可用,但触屏用户、只能点按的辅助设备用户没有入口。Leaflet 2.0 默认只挂两个控件:ZoomControl(左上)和 AttributionControl(右下)。这两个由 Map 的 zoomControl、attributionControl 选项控制,其余控件都要显式 addTo(map)。
import {LeafletMap, TileLayer} from 'leaflet';
import 'leaflet/styles.css';
// 默认:左上缩放 + 右下版权
const map = new LeafletMap('map', {
center: [39.9042, 116.4074],
zoom: 12
});
// 关掉默认控件,稍后自己加
const bare = new LeafletMap('map2', {
center: [39.9042, 116.4074],
zoom: 12,
zoomControl: false,
attributionControl: false
});
10.2 Control 基类:四个位置与两条生命周期
所有内置控件都继承 Control,源码 src/control/Control.js 里它的默认选项只有一项:
const defaultOptions = {
position: 'topright'
};
基类默认位置是右上,内置控件各自覆盖了它:ZoomControl 是左上,AttributionControl 是右下,LayersControl 是右上,ScaleControl 是左下,FullscreenControl 是左上。自定义控件不写 position 就落在右上角,这是“控件跑到意外位置”最常见的原因。
Control 的生命周期只有两步:
new MyControl(options)
└─ constructor: Util.setOptions(this, options); this.callInitHooks();
addTo(map)
├─ this.remove() 先清理旧 map 上的挂载
├─ this._map = map
├─ container = this.onAdd(map) 子类创建并返回 HTMLElement
├─ container.classList.add('leaflet-control')
├─ corner.append/prepend(container) 按 position 插入 _controlCorners
└─ map.on(this.getEvents(), this) 2.0 新增的控件事件钩子
remove()
├─ this._container.remove()
├─ this._map.off(this._mapEvents, this)
└─ this.onRemove?.(this._map)
三个细节:
addTo()开头会先调this.remove(),所以同一个控件实例重复addTo不会产生两份 DOM。- 底部两个位置用
prepend,顶部用append,这决定了同一角落多个控件的堆叠顺序:左上角后添加的控件在下方,左下角后添加的控件在上方。想控制顺序,反过来安排添加次序。 getEvents()是 2.0 才有的控件钩子,返回的对象在挂载时自动map.on、卸载时自动map.off。1.x 里控件常在onAdd里手动绑map.on、在onRemove里解绑,漏掉一边就会留下悬挂监听器。迁移代码时把这段搬进getEvents()更稳。
import {Control, DomUtil} from 'leaflet';
class StatusControl extends Control {
static defaultOptions = {position: 'bottomright'};
onAdd(map) {
const div = DomUtil.create('div', 'leaflet-bar status-box');
div.textContent = 'ready';
this._div = div;
return div;
}
getEvents() {
// 自动随控件挂载/卸载,无需手写 onRemove
return {zoomend: this._onZoomEnd};
}
_onZoomEnd() {
this._div.textContent = `z=${this._map.getZoom()}`;
}
}
map.addControl(new StatusControl());
10.3 内置控件逐个拆
10.3.1 ZoomControl
ZoomControl 的选项不多,但每一个都影响行为:
| 选项 | 类型 | 默认值 | 作用 |
|---|---|---|---|
position |
string | 'topleft' |
四角之一 |
zoomInText |
string | 内联 SVG 加号 | 放大按钮内容 |
zoomInTitle |
string | 'Zoom in' |
按钮 title 与 aria-label |
zoomOutText |
string | 内联 SVG 减号 | 缩小按钮内容 |
zoomOutTitle |
string | 'Zoom out' |
同上 |
点击按钮时执行 map.zoomIn(zoomDelta * (e.shiftKey ? 3 : 1))。也就是说按住 Shift 点击一次按钮跳 3 级,键盘上 Shift + + 同理。默认 zoomDelta 是 1,改成 0.5 可以让按钮走半级。
按钮到达 map.getMinZoom() / getMaxZoom() 界限时会加上 leaflet-disabled 类并设置 aria-disabled="true",同时监听 zoomend 和 zoomlevelschange 两个事件刷新状态。所以新增一个带 maxZoom 的瓦片图层导致地图级别上限变化时,按钮会自己变灰,不需要手动调用。
disable() / enable() 是控件级开关,跟地图的缩放界限无关,业务上做“只读模式”时用它:
map.zoomControl.disable();
// 恢复
map.zoomControl.enable();
注意 map.zoomControl 只在创建地图时 zoomControl: true 才存在。自定义创建并 addTo 的实例不会自动赋给这个属性,需要自己保存引用。
10.3.2 AttributionControl
版权控件从每个图层的 getAttribution() 拉文本。TileLayer 的 attribution 选项存在 layer.options.attribution 上,基类 Layer.getAttribution() 直接返回它,所以常规写法就是给图层传 attribution 字符串。
内部控制逻辑在 src/control/AttributionControl.js:
prefix默认是带乌克兰国旗 SVG 的 Leaflet 链接,传false可去掉,setPrefix()可运行时改。- 监听
layeradd/layerremove/zoomend。zoomend那条是为了处理带minZoom/maxZoom的图层:图层不在当前缩放范围内时不显示其版权。 - 每段版权文本用一个计数器维护引用计数,两图层写同样的文本,移除其中一个不会把整段文本删掉。
- 容器调用了
DomEvent.disableClickPropagation(),点击版权链接不会触发地图click。
new TileLayer(url, {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
// 追加临时数据源
map.attributionControl.addAttribution('Data © 2026 某数据中心');
// 移除
map.attributionControl.removeAttribution('Data © 2026 某数据中心');
10.3.3 ScaleControl
比例尺控件的算法是:取地图容器中线 y = height / 2,计算 (0, y) 到 (maxWidth, y) 两点之间的真实距离,再把这个米数向下取整到 1/2/3/5/10 × 10^n 的刻度。所以显示的数字通常是 100、200、500、1000 这类值,而不是任意数字。
| 选项 | 默认值 | 说明 |
|---|---|---|
position |
'bottomleft' |
位置 |
maxWidth |
100 |
像素,刻度线最大宽度 |
metric |
true |
显示公制 |
imperial |
true |
显示英制 |
updateWhenIdle |
false |
false 时监听 move,拖动过程中实时更新;true 时只监听 moveend |
默认同时显示公制和英制两行刻度线。中文站点一般只保留公制:new ScaleControl({metric: true, imperial: false})。
updateWhenIdle: false 意味着拖动时每帧都可能触发一次距离计算。map.distance() 走 Haversine 公式,单次成本不高,但如果一个页面挂了多个自定义控件也在 move 上做计算,聚在一起就明显了。性能敏感时改成 true。
比例尺的精度受投影影响。EPSG:3857 按球面墨卡托计算,纬度越高,屏幕上的实际地面距离比显示的短,这是投影本身的性质,不是控件算错。需要严格比例尺的工程测量场景,应换成能保证等距的本地投影,而不是把 maxWidth 调大。
10.3.4 LayersControl
图层切换控件是内置控件里参数最多、坑也最多的一个。构造函数接收两个对象字面量:底图(单选)和叠加层(复选)。
import {LayersControl, TileLayer, Marker} from 'leaflet';
const baseLayers = {
'OSM': new TileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {maxZoom: 19}),
'暗色': new TileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', {
subdomains: 'abcd',
maxZoom: 20
})
};
const overlays = {
'标注': new Marker([39.9, 116.4])
};
new LayersControl(baseLayers, overlays, {
collapsed: true, // 默认折叠成图标
collapseDelay: 0, // 收起延迟,长列表可设 500ms
autoZIndex: true, // 自动给图层分配递增 zIndex
hideSingleBase: false,
sortLayers: false,
position: 'topright',
title: 'Layers'
}).addTo(map);
行为要点:
- 底图只能有一个在图上。构造时把两个底图都加进控件,但只
addTo其中一个;切换时控件自己调用map.removeLayer/addLayer。如果两个都addTo,表现是两个瓦片叠着显示。 autoZIndex: true会给每个带setZIndex的图层按添加顺序分配 1、2、3……,避免切换后图层顺序变化。代价是它改写了你原本设定的 zIndex。collapsed: true时控件只在鼠标悬停(pointerenter)、点击按钮、键盘激活时展开。触摸设备上pointerleave会误触发收起,源码里专门判断了e.pointerType !== 'touch'。- 控件会在地图上触发
baselayerchange、overlayadd、overlayremove三个事件,事件对象里有name和layer。
map.on('baselayerchange', e => {
console.log('底图切成', e.name);
});
map.on('overlayadd', e => {
// 叠加层被勾选,适合懒加载数据
console.log('打开', e.name);
});
onRemove 里会解绑 zoomend 与所有图层的 add remove 监听,但构造时传入的图层对象不会被销毁,控件删掉后图层还在内存里。SPA 里反复创建、销毁地图时,这是常见泄漏点。
10.3.5 FullscreenControl
2.0.0-beta.2 才加入的内置控件,替代原来的 leaflet.fullscreen 插件。
import {FullscreenControl} from 'leaflet';
new FullscreenControl({
position: 'topleft',
container: document.querySelector('.map-shell'), // 想让侧栏一起全屏时传入
pseudo: false // true 则始终用 CSS 铺满窗口
}).addTo(map);
map.on('enterfullscreen', () => console.log('进入全屏'));
map.on('exitfullscreen', () => console.log('退出全屏'));
实现上优先调用 requestFullscreen();浏览器不支持或调用被拒(例如 iPhone、iframe 没有 allow="fullscreen")时退回 pseudo 模式,给目标容器加 leaflet-pseudo-fullscreen 类并用 CSS 铺满窗口。Escape 键在 pseudo 模式下由控件自己监听关闭。
container 参数是它比旧插件好用的地方:传入包含地图和侧栏的外层容器,全屏的是整个工作区,而不是地图单独铺满屏幕。
10.4 自定义控件的三个坑
10.4.1 点击穿透到地图
控件默认会把自己的事件冒泡给地图。一个“图层列表”控件,点击列表项的同时地图收到 click,如果绑了“点地图关弹窗”,弹窗会莫名其妙被关掉。
onAdd(map) {
const div = DomUtil.create('div', 'my-panel');
DomUtil.create('button', '', div).textContent = '刷新';
DomEvent.disableClickPropagation(div);
DomEvent.disableScrollPropagation(div); // 面板可滚动时必须加,否则滚轮会缩放地图
return div;
}
disableClickPropagation() 做了两件事:在 pointerdown、dblclick、contextmenu 上阻止冒泡,并把元素登记进内部的 clickDisabledElements 集合,让地图在 click 阶段直接跳过该元素。disableScrollPropagation() 则只在 wheel 上阻止冒泡。
10.4.2 焦点与键盘
2.0 移除了 1.x 内部方法 Control#_refocusOnMap。点击控件之后焦点留在控件上,不再被强行拉回地图。自定义控件如果要承接键盘操作,需要自己保证焦点顺序合理:
- 按钮用
<button>而不是<div>:浏览器会处理 Enter/Space。 - 如果非要用
<a href="#">模拟按钮,像内置控件那样设role="button",并自己监听空格键。内置控件通过内部工具clickOnSpace()实现,外部代码可以等价写成:
link.addEventListener('keydown', e => {
if (e.key === ' ' && !e.repeat) {
e.preventDefault();
link.click();
}
});
- 图标按钮必须给
aria-label,缩放控件就是这么做的,注释说明是为了让 VoiceOver 念成 “Zoom in - button”。
10.4.3 用 getEvents() 而不是手写 on/off
// 容易漏解绑的写法
onAdd(map) {
map.on('moveend', this._update);
return this._container = DomUtil.create('div');
}
onRemove() {
// 忘了 map.off,控件删了监听器还在
}
Control.addTo() 内部是:
this._mapEvents = {...this.getEvents?.(), unload: this.remove};
map.on(this._mapEvents, this);
用 getEvents() 返回监听表,挂载和卸载自动成对,并且额外把地图 unload 事件接到 remove,地图销毁时控件会自己摘掉。
10.5 控件布局与响应式
四个角对应的 DOM 容器由 Map._initPanes() 之后创建,结构是:
.leaflet-control-container
├── .leaflet-top.leaflet-left → 'topleft'
├── .leaflet-top.leaflet-right → 'topright'
├── .leaflet-bottom.leaflet-left → 'bottomleft'
└── .leaflet-bottom.leaflet-right → 'bottomright'
控件容器 .leaflet-control 是 pointer-events: auto,角容器是 pointer-events: none,角容器铺满整条边缘却不挡地图拖拽,可点击的只有控件本身。
窄屏上的典型问题是两个控件在同一个角落重叠。处理方式是运行时按视口宽度移动控件,setPosition() 会先 remove 再 add,DOM 与监听器都重建一次:
function layoutControls(map, scale, attribution) {
const narrow = matchMedia('(max-width: 640px)').matches;
scale.setPosition(narrow ? 'topright' : 'bottomleft');
attribution.setPosition(narrow ? 'bottomleft' : 'bottomright');
}
const mq = matchMedia('(max-width: 640px)');
layoutControls(map, scale, attribution);
mq.addEventListener('change', () => layoutControls(map, scale, attribution));
四角容器由 Map._initControlPos() 在创建地图时一次性生成,结构固定为 top/bottom × left/right 四个 div。_controlCorners 是私有属性,公开 API 只有 addControl、removeControl、getContainer。确实需要把控件放到地图容器外面时,写成独立 DOM 组件(Control 只管地图内四角),或者照 FullscreenControl 的 container 思路,把整个地图搬进布局容器。
移动端的触控目标尺寸:.leaflet-bar a 规则把按钮固定为 30px 见方,缩放和全屏按钮都套这个类。WCAG 2.2 的 Target Size 最低要求是 24×24 CSS px,30px 达标但不宽裕;自己加的一排图标按钮,建议按 36×36 以上设计。另外地图容器在 2.0 里始终带 leaflet-touch 类(源码注释说明该类和 leaflet-retina 在官方 CSS 里已无规则,保留是给插件样式表做挂点),所以不要再指望用这个类区分触摸设备,用 matchMedia('(pointer: coarse)')。
10.6 参数速查
| 类 | 关键选项 | 默认值 | 备注 |
|---|---|---|---|
Control |
position |
'topright' |
子类通常覆盖 |
ZoomControl |
zoomInText / zoomOutText |
SVG | 可换文字或图标 |
zoomInTitle / zoomOutTitle |
'Zoom in' / 'Zoom out' |
同时用作 aria-label | |
AttributionControl |
prefix |
Leaflet 链接 | false 关闭 |
ScaleControl |
maxWidth |
100 |
像素 |
metric / imperial |
true / true |
双单位 | |
updateWhenIdle |
false |
拖动中实时更新 | |
LayersControl |
collapsed |
true |
|
collapseDelay |
0 |
毫秒 | |
autoZIndex |
true |
覆写图层 zIndex | |
hideSingleBase |
false |
单个底图时隐藏分组 | |
sortLayers |
false |
sortFunction 配合 |
|
FullscreenControl |
container |
地图容器 | 全屏目标 |
pseudo |
false |
强制 CSS 全屏 | |
Map |
zoomControl |
true |
是否创建默认缩放控件 |
attributionControl |
true |
是否创建默认版权控件 | |
translate |
text => text |
控件文案国际化入口 |
10.7 排错
按钮点了没反应,DOM 上却有 leaflet-disabled。 说明已到缩放边界。检查是否给地图或图层设了 minZoom / maxZoom,或者 zoomSnap 让实际级别停在半级上。map.getMaxZoom() 在未设置 maxZoom 时返回所有图层里最大的那个,没有图层则返回 Infinity。
控件被地图上的元素盖住。 .leaflet-control 的 z-index 是 800,高于所有 panes(400~700),但 2.0 给地图容器加了 isolation: isolate,地图内部的 z-index 不会外溢到页面。如果控件被页面上的下拉框盖住,不是提高控件 z-index 能解决的,要调整页面层级或把下拉框移进地图容器。
图层面板展开后被其他控件遮挡。 2.0 的 CSS 里有 .leaflet-top:has(.leaflet-control-layers-expanded) { z-index: 1001; },会自动提升整个左上/右上角。如果自己写的面板在同角落,也可能被它压低,把面板放到不同角落或者避免与 LayersControl 同角。
LayersControl 切换底图时闪一下。 常见原因是底图没有全部在控件里声明,或者切换代码里同时手动 removeLayer / addLayer,与控件内部逻辑打架。底图和叠加层全部交给控件管理,业务代码只监听事件。
自建控件在 SPA 路由切换后报 Cannot read properties of null。 地图 remove() 之后控件的 this._map 被置为 null,延时回调里访问 this._map.getZoom() 就崩。回调里先判断 if (!this._map) return;,或者把定时器在 onRemove 里清掉。
比例尺在移动端显示 0 m。 地图还没完成初始化时 getSize() 可能返回 0,导致算出的距离为 0。ScaleControl 自己用 map.whenReady(this._update, this) 规避了这一点;自定义控件里做类似计算也要等 whenReady 或 load 事件。
10.8 生产建议
- 默认控件留
zoomControl和attributionControl,别为了“干净”全关掉。版权是瓦片服务条款要求的,缩放是可用性底线。 - 一个页面只保留一个缩放入口。自己画了 +
-按钮,就把zoomControl: false,否则同一位置两个控件,用户点一次跳两级(两个按钮叠在一起各响应一次点击)。 - 控件数量控制在 5 个以内。右上角堆到 4 个以上,在 1024px 宽的屏幕上会压住地图右上区域;把低频功能收进一个自定义面板。
- 需要国际化的项目,从第一天就用
map.translate覆盖内置文案,而不是等到上线前再去找散落在源码里的英文。ZoomControl的zoomInTitle、LayersControl的title、FullscreenControl的enterTitle都会走这个函数。 - 控件里的异步操作要有 loading 与错误态:按钮点击后立即
disabled,完成再恢复,避免重复请求。
10.9 本章小结
| 要点 | 结论 |
|---|---|
| 控件本质 | 固定在四角的 DOM,不随视图移动;生命周期是 onAdd → 挂载 → onRemove |
| 默认位置 | 基类 topright,内置控件各自覆盖;底部角落用 prepend,顶部用 append |
| 默认控件 | 只有 ZoomControl 与 AttributionControl,由两个 Map 选项控制 |
| 控件事件 | 2.0 用 getEvents() 声明,挂载/卸载自动成对,还自动接 unload |
| 事件隔离 | disableClickPropagation + disableScrollPropagation,滚动面板尤其不能漏 |
| 图层控件 | 底图单选、叠加层复选;autoZIndex 会改写图层 zIndex |
| 响应式 | setPosition() 重建 DOM,配合 matchMedia 在断点切换位置 |
| 国际化 | map.translate 覆盖全部内置文案,控件标题同时是 aria-label |