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

三个细节:

  1. mouseenter/mouseleave 只存在于图层级事件里,不在 10.2 的 55 个基础事件中。它们按要素去重,鼠标在同一要素内移动不会重复触发。
  2. 图层必须已存在。在 style.load 之前注册不会报错,但永远不触发;setStyle 之后旧图层没了,监听器仍然在,指向一个不存在的图层。
  3. 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();高频事件必须自行节流