第11章 - 事件系统与交互处理

11.1 两套事件体系与它们的边界

Leaflet 里有两种“事件”同时在跑:

  1. DOM 事件:浏览器原生的 pointerdown、wheel、keydown 等,目标是页面元素。
  2. Leaflet 事件:Map、Layer、Control 对象上 fire 出来的普通对象事件,与 DOM 无关,可以带任意数据。

地图交互的完整链路是:用户在容器上按下指针 → Map 的 DOM 监听器接手 → 判断命中了哪个图层 → 把 DOM 事件包装成 Leaflet 事件(带 latlng、containerPoint、layerPoint、originalEvent)→ 在命中图层和 Map 上依次 fire。写业务代码时几乎只碰第二层,排错时要知道第一层发生了什么。

2.0 把这一层彻底换成了 Pointer Events。mousedown、mouseup、mousemove、mouseover、mouseout、keypress 全部移除,对应 pointerdown、pointerup、pointermove、pointerover、pointerout、keydown。click、dblclick、contextmenu 名字不变。

11.2 Evented:五个方法撑起所有事件

Map、Layer、Control、Handler 都继承 Evented(src/core/Events.js),公开 API 只有五个:

方法 签名 说明
on on(types, fn, context) 添加监听;types 可以是空格分隔的多个类型,也可以是 {type: fn} 对象
off off(types, fn, context) 移除监听;不传参清空全部
once once(types, fn, context) 只触发一次,触发前自动移除
fire fire(type, data, propagate) 触发事件,data 会合并进事件对象
listens listens(type, fn, context, propagate) 是否有监听者,常用于性能优化

三种注册写法效果等价:

import {Marker} from 'leaflet';

const marker = new Marker([39.9, 116.4]).addTo(map);

// 1. 单类型 + 命名函数
function onClick(e) {
	console.log(e.latlng);
}
marker.on('click', onClick);
marker.off('click', onClick);   // 必须传同一个函数引用

// 2. 空格分隔多个类型
marker.on('pointerover pointerout', e => {
	e.target.setOpacity(e.type === 'pointerover' ? 0.6 : 1);
});

// 3. 对象映射(内部走一次 _on 快速路径,不加拆分逻辑)
marker.on({
	click: onClick,
	pointerover: () => console.log('enter')
});

三个源码级细节会直接影响行为:

  • 同样的函数 + 同样的 context 不会重复注册。_on() 里先 findListener() 查重,命中直接返回。所以“疯狂 on 同一个 handler”不会导致一次点击执行多次。
  • off 按引用匹配。匿名函数注册后无法单独移除;传了 context 的监听,off 时必须传同一个 context,否则找不到。这是线上“事件越绑越多”的头号原因。
  • context === this 会被归一化为 undefined,源码注释说明是为了省内存。等价地,obj.on('click', fn, obj) 与 obj.on('click', fn) 是同一个监听。

11.3 fire 的执行语义

fire 的源码不长,但每条分支都对应一个容易踩的坑:

fire(type, data, propagate) {
	if (!this.listens(type, propagate)) { return this; }

	const event = {
		...data,
		type,
		target: this,
		sourceTarget: data?.sourceTarget || this
	};

	const listeners = this._events?.[type];
	if (listeners) {
		const {length} = listeners;          // 先存长度
		for (let i = 0; i < length; i++) {
			const l = listeners[i];
			const {fn, ctx} = l;             // 先拷出 fn/ctx
			if (l.once) {
				this.off(type, fn, ctx);     // 先摘掉再调用
			}
			fn.call(ctx || this, event);
		}
	}

	if (propagate) { this._propagateEvent(event); }
	return this;
}

由此得到四条可以依赖的结论:

  1. 没人监听就不构造事件对象。高频事件(例如自定义的 mousemove 包装)在无监听时几乎没有成本,这是 Leaflet 敢在 move 上层层包装的前提。
  2. 触发过程中新加的监听器不会在本轮执行。循环上限是进入时的 length。2.0.0-beta.3 专门修复过“fire 期间添加的监听器被同一次 fire 调用”的回归,行为以当前版本为准:本轮不执行。
  3. 触发过程中被 off 的监听器不会执行。_off 会把该条目的 fn 覆盖成内部的空函数,即使它还在数组里。
  4. once 是先移除再调用,所以回调里再次 fire 同类型事件不会重入自身。

监听器数量上去之后,查找方式会变。源码里 INDEX_SIZE = 32:同一事件类型的监听器超过 32 个时,内部再建一张 Map 索引(先按函数、再按 context),让 off/listens 的平均成本从线性扫描变成常数级;删除不立即压缩数组,累计删除过半才重建。2.0.0-beta.3 的发布说明给出的数据是:给地图添加 5 万个带 popup 的 marker,从 15.6 秒降到 0.5 秒。这不是 marker 本身变快,而是每个 marker 的内部事件绑定不再互相拖累。

自建可监听对象不需要继承 Leaflet 的类,继承 Evented 即可:

import {Evented} from 'leaflet';

class DataSource extends Evented {
	constructor(name) {
		super();
		this.name = name;
	}

	push(rows) {
		// 先问有没有人听,避免白构造数据
		if (this.listens('data')) {
			this.fire('data', {rows, count: rows.length});
		}
	}
}

const source = new DataSource('sensors');
source.on('data', e => console.log(`${e.count} rows`));
source.push([{id: 1}, {id: 2}]);  // 输出 "2 rows"

11.4 事件传播:从图层冒泡到组和地图

Leaflet 的事件可以向上冒泡,机制是“事件父级”(event parent):

const group = new FeatureGroup([marker1, marker2]);
group.on('click', e => {
	// e.target 是最后触发的对象,e.propagatedFrom 是直接冒泡上来的对象
	console.log(e.propagatedFrom === marker1); // true
});

// 等价于 FeatureGroup.addLayer 内部做的:layer.addEventParent(group)
marker1.addEventParent(group);

事件对象上四个字段要分清:

字段 含义
target 事件最终触发在哪个对象上。传播时每往上一层都重新赋值
sourceTarget 最初 fire 的对象,传播过程中不变
propagatedFrom 把事件传给我的那个对象(上一跳)
layer 1.x 的旧名,2.0 仍作为 propagatedFrom 的别名存在,但不在类型里

FeatureGroup 自动做两件事:addLayer 时调 layer.addEventParent(this),removeLayer 时调 removeEventParent。所以“给组绑一个事件,统一处理所有子图层”是可用且推荐的写法,不必逐个绑。

图层事件冒泡到地图,靠的是另一套路径:Map 维护一张 _targets 表,Layer.addInteractiveTarget(element) 把 DOM 元素注册进去。DOM 事件到达时,_findEventTargets() 从 e.target 沿 parentNode 向上走,遇到注册过且 listens(type, true) 的对象就收集起来,最后依次 fire(type, data, true)。

是否继续往地图冒泡由选项控制:

  • Path(折线、多边形、圆)的 bubblingPointerEvents 默认 true,点击图形时地图也会收到 click。
  • Marker 的 bubblingPointerEvents 默认 false,点 marker 不会触发地图 click。
  • FeatureGroup 的成员是 Path 时,地图与组都会收到,注意别在两级都写重复逻辑。

要主动截断,在监听器里调 DomEvent.stopPropagation(e):

import {DomEvent} from 'leaflet';

polygon.on('click', e => {
	DomEvent.stopPropagation(e);  // 地图不再收到这次 click
	openSidePanel(e.target.feature.properties.name);
});

注意 stopPropagation 接受的是 Leaflet 事件对象,内部把它对应的原生事件登记进 stoppedEvents 集合,_fireDOMEvent 检查到这个标记就停止后续目标。

11.5 从 DOM 事件到 Leaflet 事件

Map._initEvents() 在容器上绑定固定的一组 DOM 事件:

click dblclick pointerdown pointerup pointerover pointerout pointermove contextmenu keydown keyup

wheel 不在这张表里,它由 ScrollWheelZoomHandler 单独接管(否则 scrollWheelZoom: false 就关不掉滚轮)。每个事件都走同一条处理链:

DOM event ──▶ Map._handleDOMEvent
              ├─ canvas 图层命中测试(renderer._layerAt)
              ├─ pointerover/pointerout 状态维护
              └─ Map._fireDOMEvent(e, type, targets)
                    ├─ click 先补发 preclick
                    ├─ 组装 originalEvent / containerPoint / layerPoint / latlng
                    └─ targets 逐个 fire(type, data, true)

关键点:

  • latlng 是地图坐标,不是屏幕坐标。containerPoint 相对地图容器,layerPoint 相对地图 pane(已减像素原点)。三者关系固定:containerPoint = layerPoint + mapPanePos。
  • click 之前必定先 fire('preclick')。Popup 就是靠 preclick 在业务 click 之前关闭,避免“点这个 marker 先关旧弹窗再开新弹窗”的顺序错乱。
  • 键盘触发的 click 不会结束拖拽。源码判断 e.detail > 0 才算真实指针点击;element.click() 和键盘产生的是 detail: 0。
  • contextmenu 有监听时会被 preventDefault(),浏览器右键菜单不会弹出,长按同理(tapHold 默认开启)。
  • canvas 图层没有 DOM 元素,命中靠 _layerAt(e, click) 逐个 canvas 渲染器做几何判断;2.0 的 <canvas> 设了 pointer-events: none,所以给 canvas 元素加原生监听器不会触发,必须监听图层或地图。
map.on('click', e => {
	console.log(e.latlng.lat, e.latlng.lng);
	console.log(e.containerPoint, e.layerPoint);
	console.log(e.originalEvent.pointerType); // 'mouse' | 'touch' | 'pen'
});

11.6 pointer 事件迁移对照

从 1.x 升级时,下表右边的旧名在 2.0 里“监听不到且只报一次错”:

1.x 2.0
mousedown / mouseup / mousemove pointerdown / pointerup / pointermove
mouseover / mouseout pointerover / pointerout
keypress keydown
map.mouseEventToContainerPoint() map.pointerEventToContainerPoint()
DomEvent.getMousePosition() DomEvent.getPointerPosition()
bubblingMouseEvents bubblingPointerEvents(旧名仍生效)
map.touchZoom map.pinchZoom(旧名仍生效,打印弃用警告)

源码 Evented.__REMOVED_EVENTS 里硬编码了这张表:注册旧事件时用 console.error 报一次“已移除,请改用 X”,然后照常把监听器注册进去——它永远不会被触发,因为没人 fire 这个名字。看到这条日志就说明代码没改完。

最隐蔽的一点:pointer 事件对触摸和笔同样触发。1.x 的 mousemove 在手指拖动地图时不会触发,2.0 的 pointermove 会。只给鼠标准备的悬停提示逻辑必须自己过滤:

layer.on('pointerover', e => {
	if (e.originalEvent.pointerType !== 'mouse') { return; }
	showHoverCard(e.latlng);
});

11.7 Map 事件清单

按用途分组,事件名都来自 Map 的类型定义:

分组 事件 触及时机
加载 load 首次设置 center/zoom 后
视图 movestart move moveend 平移开始 / 过程中 / 结束
视图 zoomstart zoom zoomend 缩放开始 / 过程中 / 结束
视图 viewprereset viewreset 需要重绘前 / 重绘时
视图 resize unload zoomlevelschange 尺寸变化 / 地图销毁 / 缩放级数范围变化
指针 click dblclick preclick contextmenu 点击、双击、点击前、右键或长按
指针 pointerdown pointerup pointerover pointerout pointermove 指针按下 / 抬起 / 进入 / 离开 / 移动
键盘 keydown keyup focus blur 地图容器有焦点时
拖拽 dragstart drag dragend 拖拽开始 / 过程 / 结束
框选缩放 boxzoomstart boxzoomend 按住 Shift 框选开始 / 结束
图层 layeradd layerremove 图层加入 / 移除
图层控制 baselayerchange overlayadd overlayremove 通过 LayersControl 切换时
弹窗 popupopen popupclose autopanstart 打开 / 关闭 / 自动平移开始
工具提示 tooltipopen tooltipclose 打开 / 关闭
定位 locationfound locationerror map.locate() 成功 / 失败
其他 worldcopyjump enterfullscreen exitfullscreen zoomanim 世界副本跳转 / 全屏切换 / 缩放动画帧

move、zoom 系列在用户操作触发时,事件对象里带 originalEvent(2.0.0-beta.2 加入),可以区分“用户拖动”和“代码调用 setView”:

map.on('moveend', e => {
	if (!e.originalEvent) { return; }  // 程序触发,忽略
	syncViewToUrl(map.getCenter(), map.getZoom());
});

11.8 Handler:可开关的内置交互

内置交互全部是 Handler 子类,挂在 Map 的固定属性上,统一提供 enable()、disable()、enabled():

属性 默认 对应 Map 选项 作用
map.dragging 开 dragging 拖动平移
map.keyboard 开 keyboard 键盘平移缩放
map.scrollWheelZoom 开 scrollWheelZoom 滚轮缩放
map.doubleClickZoom 开 doubleClickZoom 双击缩放
map.boxZoom 开 boxZoom Shift + 拖拽框选缩放
map.pinchZoom 开 pinchZoom 双指捏合缩放
map.tapHold 开 tapHold 长按模拟右键菜单(2.0 起所有平台默认开)

做只读地图时逐个关闭,比设一堆选项更直观:

for (const name of ['dragging', 'keyboard', 'scrollWheelZoom',
	'doubleClickZoom', 'boxZoom', 'pinchZoom']) {
	map[name].disable();
}

键盘交互的细节值得单独记:方向键平移 keyboardPanDelta(默认 80px),+/- 缩放 zoomDelta(默认 1),按住 Shift 全部乘 3。键位定义在静态属性 KeyboardHandler.keys 上,值是 KeyboardEvent.key 而不是老式的 keyCode,可以整体替换:

import {KeyboardHandler} from 'leaflet';

KeyboardHandler.keys.zoomIn = ['+', '=', 'x'];
KeyboardHandler.keys.zoomOut = ['-', '_', 'z'];

Escape 关闭当前 popup 由键盘处理器负责,条件是 popup.options.closeOnEscapeKey(默认 true)且焦点不在控件容器里。所以把搜索框放进控件后,在输入框里按 Esc 不会误关弹窗。

ariaKeyShortcuts 在 2.0 默认关闭(1.x 会自动设置,但只对美式键盘正确)。需要读屏软件播报快捷键时显式开启:

new LeafletMap('map', {ariaKeyShortcuts: true}).setView([39.9, 116.4], 12);

11.9 自己写交互:DomEvent 与节流

不继承 Handler 也能挂交互,DomEvent 提供了一层薄封装,好处是它按“元素 + 类型 + 函数 + context”记账,解绑不会残留:

import {DomEvent, Util} from 'leaflet';

const panel = document.querySelector('#coords');

// 等价于 addEventListener,但支持空格分隔多类型与 context
DomEvent.on(map.getContainer(), 'pointermove', Util.throttle(e => {
	const p = map.pointerEventToContainerPoint(e);
	const ll = map.containerPointToLatLng(p);
	panel.textContent = `${ll.lat.toFixed(5)}, ${ll.lng.toFixed(5)}`;
}, 100), this);

// 解绑必须传同一个函数引用、同一个 context
const handler = e => { /* ... */ };
DomEvent.on(el, 'click', handler);
DomEvent.off(el, 'click', handler);

pointermove 在拖拽和悬停时都能到每秒 60 次以上,直接在里面写 DOM 是性能事故现场。Util.throttle(fn, time, context) 是 Leaflet 自带的节流实现,坐标显示这类场景 100ms 足够。节流函数记得保存引用,否则无法解绑。

DomEvent 还提供 stop(e)(同时阻止冒泡和默认行为)、stopPropagation、preventDefault、getPointerPosition(e, container)、getWheelDelta(e)。注意 2.0 起这些函数返回 undefined,1.x 的链式调用 DomEvent.on(...).on(...) 会直接报错。

11.10 排错

监听器绑了但从不触发。 先看控制台有没有 The event mousedown has been removed in Leaflet 2. Use pointerdown instead.。这类错误每种类型只打印一次,很容易被其他日志淹没。其次确认事件名没拼错——Leaflet 对未知事件名不报错,on('clickx', fn) 静默通过。

off 之后事件还在触发。 三种可能:注册的是匿名箭头函数,引用不同;注册时传了 context,off 没传;功能在别的实例上重复注册。用命名函数 + 显式 context 是唯一可靠写法。

一次点击回调执行两次。 检查是否同时在图层和 FeatureGroup 上绑了。Path 的 bubblingPointerEvents 默认 true,组内成员的事件会冒泡到组,组再冒泡到地图。要么只在组上绑,要么在成员回调里 DomEvent.stopPropagation(e)。

触摸设备上悬停效果卡住。 pointerover 在触摸时也会触发,手指抬起后 pointerout 可能不按预期顺序到达。悬停 UI 一律先判断 pointerType === 'mouse'。

canvas 图层的自定义 addEventListener 不生效。 2.0 的 canvas 元素是 pointer-events: none,所有命中由地图侧几何测试完成。监听 layer.on('click') 或 map.on('click')。

地图销毁后仍收到事件回调。 map.remove() 会调用 _initEvents(true) 解绑容器上的 DOM 监听并清空 _targets,但业务代码自己 map.on(...) 注册的监听器还在对象上。SPA 路由切换时要显式 map.off(),或在 unload 事件里统一清理。

e.latlng 是 undefined。 键盘的 keydown/keyup 不计算坐标;另外在 map.on('load') 里地图还没设定视图时也没有坐标。改用 getCenter()。

11.11 生产建议

  • 事件注册集中管理。写一个 bindEvents(map) 函数返回解绑函数,组件卸载时调用,比散落在各处的 off 可靠得多。
  • 高频事件必须节流。pointermove、move、zoom 上做 DOM 写入或请求,用 Util.throttle 或 requestAnimationFrame 合并到一帧一次。
  • 能用 once 就不用 on + 手动 off。地图初始化完成这类一次性逻辑尤其如此。
  • 图层批量操作时先 map.off 再 on。往 FeatureGroup 里加一万个图层,每个 addLayer 都会触发 layeradd,如果监听器里每次重排图例,成本是 O(n²)。LayersControl 内部用 _handlingClick 标志位规避了同类问题,业务代码可以用 map.off('layeradd')...map.on(...) 包住。
  • 不要用 fire 传递大对象。事件是浅拷贝,大数组会被每个监听器闭包持有。
  • 2.0 升级时全局搜 mousedown|mouseup|mousemove|mouseover|mouseout|keypress|mouseEventTo,比等控制台报错快。

11.12 本章小结

要点 结论
两层事件 DOM 事件 → Map 包装成带 latlng/originalEvent 的 Leaflet 事件
五个方法 on/off/once/fire/listens,组合是首选写法
匹配规则 按函数引用 + context 匹配;同函数同 context 不重复注册
fire 语义 无人监听早退;触发中新增的监听器本轮不执行;被移除的不执行
索引阈值 同类型监听器超过 32 个走 Map 索引;beta.3 让 5 万 marker 从 15.6s 降到 0.5s
传播 addEventParent 向上冒泡;propagatedFrom 是上一跳,sourceTarget 是源头
事件改名 mouse* → pointer*,keypress → keydown;旧名只报一次错且永不触发
pointer 差异 触摸也触发 pointermove/pointerover,悬停逻辑要过滤 pointerType
交互开关 内置交互都在 map.<handler> 上,enable/disable 统一控制
键盘 keyboardPanDelta 80px、Shift ×3;KeyboardHandler.keys 可改键位