第11章 - 事件系统与交互处理
11.1 两套事件体系与它们的边界
Leaflet 里有两种“事件”同时在跑:
- DOM 事件:浏览器原生的
pointerdown、wheel、keydown等,目标是页面元素。 - 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;
}
由此得到四条可以依赖的结论:
- 没人监听就不构造事件对象。高频事件(例如自定义的
mousemove包装)在无监听时几乎没有成本,这是 Leaflet 敢在move上层层包装的前提。 - 触发过程中新加的监听器不会在本轮执行。循环上限是进入时的
length。2.0.0-beta.3 专门修复过“fire 期间添加的监听器被同一次 fire 调用”的回归,行为以当前版本为准:本轮不执行。 - 触发过程中被
off的监听器不会执行。_off会把该条目的fn覆盖成内部的空函数,即使它还在数组里。 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 可改键位 |