第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)

三个细节:

  1. addTo() 开头会先调 this.remove(),所以同一个控件实例重复 addTo 不会产生两份 DOM。
  2. 底部两个位置用 prepend,顶部用 append,这决定了同一角落多个控件的堆叠顺序:左上角后添加的控件在下方,左下角后添加的控件在上方。想控制顺序,反过来安排添加次序。
  3. 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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);

// 追加临时数据源
map.attributionControl.addAttribution('Data &copy; 2026 某数据中心');
// 移除
map.attributionControl.removeAttribution('Data &copy; 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);

行为要点:

  1. 底图只能有一个在图上。构造时把两个底图都加进控件,但只 addTo 其中一个;切换时控件自己调用 map.removeLayer / addLayer。如果两个都 addTo,表现是两个瓦片叠着显示。
  2. autoZIndex: true 会给每个带 setZIndex 的图层按添加顺序分配 1、2、3……,避免切换后图层顺序变化。代价是它改写了你原本设定的 zIndex。
  3. collapsed: true 时控件只在鼠标悬停(pointerenter)、点击按钮、键盘激活时展开。触摸设备上 pointerleave 会误触发收起,源码里专门判断了 e.pointerType !== 'touch'。
  4. 控件会在地图上触发 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