第10章 - 交互与事件系统
10.1 事件系统的两层结构
MapLibre 的事件分两层:DOM 事件进来,地图事件出去。中间是 HandlerManager,它把 canvas 上的原始鼠标/触摸/键盘事件翻译成相机动作与地图事件。
canvas DOM 事件 HandlerManager Map 事件
──────────────────────────────────────────────────────────────────────────
mousedown / mousemove / mouseup ──► dragPan、dragRotate、boxZoom ──► movestart / move / moveend
doubleClickZoom ──► zoomstart / zoom / zoomend
wheel ──► scrollZoom ──► dragstart / drag / dragend
touchstart / move / end ──► touchZoomRotate、touchPitch ──► rotatestart / rotate / rotateend
keydown / keyup ──► keyboard ──► pitchstart / pitch / pitchend
(每个 handler 各管一段) ──► rollstart / roll / rollend
──────────────────────────────────────────────────────────────────────────
直接转发(不经 handler) click / dblclick / contextmenu / mousemove / mouseover /
mouseout / mousedown / mouseup / touch* / wheel
所有事件通过 map.on(type, listener) 订阅。Map 继承自 Evented(v6 起 Map extends Evented<MapEventType>,不再继承 Camera),因此 on/once/off 的签名是泛型的,编辑器能直接提示每个事件对象的字段。
const map = new maplibregl.Map({container: 'map', style: 'https://demotiles.maplibre.org/style.json'});
map.on('click', (e) => {
// e 的类型是 MapMouseEvent:type / point / lngLat / originalEvent / preventDefault
console.log(e.type, e.point, e.lngLat.toArray());
});
map.on('moveend', (e) => {
// e 的类型是 MapMovementEvent
console.log('zoom 现在是', map.getZoom());
});
10.2 55 个地图事件的分组清单
v6 的 MapEventType 定义了 55 个事件,按来源分六组:
| 分组 | 事件 |
|---|---|
| 生命周期 | load、idle、render、resize、remove、error |
| 样式与数据 | styledata、styledataloading、style.load、sourcedata、sourcedataloading、data、dataloading、dataabort、sourcedataabort、styleimagemissing |
| 相机移动 | movestart、move、moveend、zoomstart、zoom、zoomend、rotatestart、rotate、rotateend、dragstart、drag、dragend、pitchstart、pitch、pitchend、rollstart、roll、rollend |
| 鼠标 | click、dblclick、contextmenu、mousedown、mouseup、mousemove、mouseover、mouseout |
| 触摸 | touchstart、touchmove、touchend、touchcancel |
| 其他 | boxzoomstart、boxzoomend、boxzoomcancel、wheel、webglcontextlost、webglcontextrestored、terrain、projectiontransition、cooperativegestureprevented |
v6 把事件从类型别名改成了真正的类:MapMouseEvent、MapTouchEvent、MapWheelEvent、MapMovementEvent、MapSourceDataEvent、MapStyleDataEvent、MapStyleLoadEvent、MapTerrainEvent、MapProjectionEvent、MapContextEvent、MapBoxZoomEvent、ErrorEvent。迁移指南提醒:判断类型请用 event.type,不要用 instanceof。
10.3 鼠标事件的字段
MapMouseEvent 是使用频率最高的事件对象:
| 字段 | 类型 | 说明 |
|---|---|---|
type |
字符串 | click、dblclick、mousedown、mouseup、mousemove、mouseover、mouseout、contextmenu 等 |
point |
Point |
相对地图容器左上角的 CSS 像素坐标 |
lngLat |
LngLat |
光标对应的地理坐标 |
originalEvent |
MouseEvent |
原始 DOM 事件,取 clientX、shiftKey 等 |
target |
Map |
触发事件的 Map 实例 |
preventDefault() |
方法 | 阻止地图的默认行为(见 10.5) |
defaultPrevented |
只读布尔 | 是否已调用过 preventDefault |
map.on('click', (e) => {
const {x, y} = e.point;
const [lng, lat] = e.lngLat.toArray();
console.log(`屏幕 (${x}, ${y}) → 经纬度 ${lng.toFixed(5)}, ${lat.toFixed(5)}`);
// 需要 DOM 事件上的修饰键
if (e.originalEvent.shiftKey) {
console.log('按住 Shift 点击');
}
});
触摸事件 MapTouchEvent 额外带 points(各触点像素坐标数组)与 lngLats(对应地理坐标数组),而 point/lngLat 给的是所有触点的中心。做双指手势类自定义交互时用这两个数组。
10.4 图层级事件与要素拾取
map.on(type, layerId, listener) 是带图层的重载,回调里的 e.features 已经过 queryRenderedFeatures 筛选:
const layerId = 'poi-label';
// 悬停高亮:进入换光标,离开还原
map.on('mouseenter', layerId, () => {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', layerId, () => {
map.getCanvas().style.cursor = '';
});
// 点击展示详情
map.on('click', layerId, (e) => {
const f = e.features[0];
new maplibregl.Popup()
.setLngLat(e.lngLat)
.setHTML(`<b>${f.properties.name}</b><br>id: ${f.id}`)
.addTo(map);
});
三个细节:
mouseenter/mouseleave只存在于图层级事件里,不在 10.2 的 55 个基础事件中。它们按要素去重,鼠标在同一要素内移动不会重复触发。- 图层必须已存在。在
style.load之前注册不会报错,但永远不触发;setStyle之后旧图层没了,监听器仍然在,指向一个不存在的图层。 e.features的几何是屏幕坐标系下的简化结果,不要直接当业务坐标用。要原始坐标走querySourceFeatures。
10.5 preventDefault 能挡掉什么
MapMouseEvent.preventDefault() 不是”阻止事件冒泡”,而是”阻止地图继续处理这个事件”。官方文档列出它能挡掉的行为:
mousedown上的DragPanHandler行为(拖动平移)mousedown上的DragRotateHandler行为(拖动旋转)mousedown上的BoxZoomHandler行为(Shift 拖框放大)dblclick上的DoubleClickZoomHandler行为(双击放大)
触摸事件的 preventDefault() 则挡掉 touchstart 上的拖动平移与双指缩放旋转。
// 在要素上按下时不要开始拖地图(例如准备自己实现拖拽要素)
map.on('mousedown', 'draggable-marker', (e) => {
e.preventDefault();
startMyDrag(e);
});
这是”自定义拖拽”与”地图平移”冲突的标准解法。没调 preventDefault 时,两者会同时发生,表现为”拖要素的时候地图也跟着跑”。
10.6 交互 handler 清单
九个内置 handler,每个都可以单独开关,也都可以拿到实例调用 enable()/disable():
| handler | 控制的交互 | 默认 |
|---|---|---|
dragPan |
拖动平移 | 开 |
dragRotate |
拖动旋转/倾斜 | 开 |
scrollZoom |
滚轮缩放 | 开 |
boxZoom |
Shift + 拖框缩放 | 开 |
doubleClickZoom |
双击放大 | 开 |
keyboard |
键盘方向键、+/- | 开 |
touchZoomRotate |
双指缩放旋转 | 开 |
touchPitch |
双指倾斜 | 开 |
cooperativeGestures |
需 Ctrl/⌘ 或双指才响应 | 关 |
// 创建时关掉,避免滚动页面时误触地图缩放
const map = new maplibregl.Map({
container: 'map',
style: 'https://demotiles.maplibre.org/style.json',
scrollZoom: false,
dragRotate: false
});
// 运行期按条件开关
map.scrollZoom.disable();
map.dragPan.disable();
// 6.15 起 dragRotate 支持围绕指针旋转
map.dragRotate.enable({around: 'pointer'});
cooperativeGestures 值得单独提:它适合”地图嵌在长页面里”的场景。开启后,桌面端需按住 Ctrl/⌘ 才响应滚轮与拖拽,触摸端需双指,单指滑动会正常滚动页面。官方注释还说明了一个细节:地图进入全屏(FullscreenControl)后,协作手势会被自动关闭。
10.7 事件触发顺序与去重
相机相关事件成对出现,顺序固定:
一次拖拽平移
movestart → (dragstart → drag → dragend) → move → ... → moveend
一次滚轮缩放
zoomstart → (movestart) → zoom → move → ... → zoomend → moveend
一次 fitBounds 动画
movestart → zoomstart(若 zoom 变化)→ move/zoom 逐帧 → zoomend → moveend
几个易错点:
move在动画期间每帧触发。 在里面做重计算会直接拖慢动画。需要节流或改用moveend。zoom事件同样高频。 需要”缩放结束再做事”,用zoomend。idle不代表”没动过”,而是”稳定了”。 相机、瓦片、淡入淡出全部结束才触发;瓦片 404 也会让idle延迟或永不触发(历史 bug,6.14/6.15 修过多次)。load只对当前样式触发一次,setStyle后会重新触发。要一次性初始化用once。
// 反例:move 里做 DOM 查询,拖动时每帧一次,必卡
map.on('move', () => {
document.querySelectorAll('.poi').forEach((el) => {/* ... */});
});
// 正例:只在移动结束后算
map.on('moveend', () => {
updateVisiblePoiList(map.getBounds());
});
// 需要"稳定后截图",用 idle + once
map.once('idle', () => {
console.log('可以截图了');
});
10.8 事件对象的防抖与退订
高频事件要自己控制频率。不要依赖库替你节流——move/zoom 是逐帧回调,库里不会合并。
// 简单节流:至少间隔 100ms
function throttle(fn, wait) {
let last = 0;
let timer = null;
return (...args) => {
const now = Date.now();
const remain = wait - (now - last);
if (remain <= 0) {
last = now;
fn(...args);
} else if (!timer) {
timer = setTimeout(() => {
timer = null;
last = Date.now();
fn(...args);
}, remain);
}
};
}
map.on('move', throttle(() => {
console.log('视角变化(节流后)');
}, 100));
退订有两种写法,v5 起推荐 Subscription:
// 写法一:保存函数引用
function onClick(e) { console.log(e.lngLat); }
map.on('click', onClick);
map.off('click', onClick);
// 写法二:用返回的 Subscription(推荐)
const sub = map.on('click', (e) => console.log(e.lngLat));
sub.unsubscribe();
// once 也会返回 Subscription
const onceSub = map.once('load', () => {});
onceSub.unsubscribe(); // 在触发前取消
组件化开发里,把 Subscription 集中存进数组,卸载时统一 unsubscribe(),比手工配对 on/off 不容易漏。
10.9 排错清单
图层级事件不触发。 三个原因:图层 id 写错、图层还没加载(在 style.load 前注册)、setStyle 后旧图层被替换。注册前用 map.getLayer(id) 判一下。
click 触发两次。 常见于同时注册了 map.on('click', fn) 和 map.on('click', layerId, fn)——后者内部也是一次 queryRenderedFeatures,但两个监听都会执行。要么只用图层级,要么在全局回调里判断 e.features。
拖动地图时误触发 click。 clickTolerance 默认 3 px,超过这个位移不算点击。如果自定义了拖拽逻辑并调用了 preventDefault,注意它挡的是地图默认行为,不是你自己的点击判断。
contextmenu 在桌面端被浏览器菜单盖住。 需要在容器上阻止默认右键菜单:document.getElementById('map').addEventListener('contextmenu', (e) => e.preventDefault())。移动端长按触发 contextmenu 是 6.11 引入的行为。
error 事件把所有问题都吃掉。 库的错误统一走 error 事件而不是 throw。不注册监听时它打印到控制台;注册了就必须自己区分来源(e.error 可能是 AJAXError、校验错误、WebGL 上下文错误)。
webglcontextlost 后地图一片空白。 上下文丢失会触发样式重建。监听 webglcontextrestored 后不要立刻操作 source/layer——getSource 可能返回 undefined,要等下一次 style.load 再做。
map.on('error', (e) => {
const err = e.error;
if (!err) return;
// 网络类错误带 status
if ('status' in err) console.error('请求失败', err.status, err.url);
else console.error('地图错误', err.message);
});
map.on('webglcontextlost', () => console.warn('WebGL 上下文丢失,等待恢复'));
map.on('webglcontextrestored', () => {
map.once('style.load', () => console.log('样式已重建,可以继续操作'));
});
10.10 生产建议
把事件监听集中管理。 建一个 EventBus 或在组件生命周期里统一注册/退订,避免”页面切走了监听还在跑”。
高频事件一律节流。 move、zoom、mousemove、render 四个是性能雷区。render 更极端——它是每帧都触发,只能用来计数或调试。
点击拾取加半径与图层限制。 queryRenderedFeatures 不传 layers 会遍历所有图层,要素多时明显卡顿。用 point 构造一个小矩形(±5 px)能显著提升命中率与速度。
手势优先级要想清楚。 自定义交互(绘制、拖拽要素)与地图默认手势冲突时,用 preventDefault() 显式让路,而不是靠 stopPropagation 之类的 DOM 手段。
移动端验证 touchPitch 与 cooperativeGestures。 这两个在真机与模拟器上的表现差异较大,尤其是嵌入 WebView 的场景。
错误上报按类型分流。 瓦片 404 是常态(尤其是超出范围的请求),不该报成严重错误;样式校验错误和 WebGL 上下文错误才是需要告警的。
10.11 本章小结
| 主题 | 结论 |
|---|---|
| 事件数量 | MapEventType 定义 55 个事件,分生命周期、数据、相机、鼠标、触摸、其他六组 |
| 事件类 | v6 起都是类实例;判断类型用 event.type,不要用 instanceof |
| 鼠标事件字段 | point(容器像素)、lngLat、originalEvent、preventDefault() |
| 图层事件 | map.on(type, layerId, fn),额外提供 mouseenter/mouseleave,需图层已存在 |
| preventDefault | 挡掉 dragPan / dragRotate / boxZoom / doubleClickZoom 的默认行为 |
| handler | 9 个可单独开关;cooperativeGestures 适合嵌在长页面 |
| 顺序 | movestart → drag/move → moveend;zoom 与 move 会交替触发 |
| 订阅管理 | on 返回 Subscription,可 unsubscribe();高频事件必须自行节流 |