第04章 - Map 地图对象详解

上一章讲了 Leaflet 的骨架,这一章落到唯一那个”中枢”对象:LeafletMap。地图上所有图层、控件、交互处理器、事件、pane 都挂在它身上,它同时承担五个角色——DOM 容器的持有者、视图状态的唯一真相、图层注册表、事件总线、坐标换算器。

正因为职责这么多,Map 的选项有 40 多个,方法 70 多个。全背下来没有意义。这一章按”初始化 → 视图 → 坐标 → 尺寸 → pane → 事件”的顺序梳理,重点讲清楚默认值的含义与 2.0 的行为变化,剩下的查 API 文档即可。

4.1 构造一个地图:初始化顺序决定了什么能生效

src/map/Map.js 的构造器按固定顺序执行六步,这个顺序直接决定了哪些选项在什么时候生效:

constructor(id, options) {
  options = Util.setOptions(this, options);   // ① 合并默认值
  this._initContainer(id);                    // ② 找到/创建容器,设 role="region"、aria-label="Map"
  this._initLayout();                         // ③ 建 pane 树与控制容器
  this._initEvents();                         // ④ 绑定 DOM 事件(resize、scroll 等)
  if (options.maxBounds) this.setMaxBounds(options.maxBounds);
  if (options.zoom !== undefined) this._zoom = this._limitZoom(options.zoom);
  if (options.center && options.zoom !== undefined) {
    this.setView(new LatLng(options.center), options.zoom, {reset: true});
  }
  // ⑤ 创建控件(attribution、zoom)与 7 个 handler
  this.callInitHooks();
  this._addLayers(this.options.layers);       // ⑥ 挂初始图层
}

从这段代码能读出三个结论。

第一,只给 center 不给 zoom 不会设置视图。 判断条件是 options.center && options.zoom !== undefined,两者缺一不可。这也是”我传了 center 但地图还是空白”最常见的原因。

第二,layers 选项在控件之后才加载。 所以初始图层里的 GridLayer 能正确触发 zoomlevelschange,进而调整地图缩放范围。

第三,初始化就会触发 setView(..., {reset: true})。 1.x 里这个参数是三态的(第三个参数 true 表示强制 reset),2.0 改成了选项对象,迁移文档明确写着 setView(center, zoom, true) no longer forces a view reset. Use setView(center, zoom, {reset: true})。老代码传布尔值不会报错,但 reset 语义丢了。

4.1.1 容器参数

new LeafletMap('map') 接受字符串 ID 或直接传 HTMLElement:

import {LeafletMap, TileLayer} from 'leaflet';

const el = document.getElementById('map');
const map = new LeafletMap(el, {center: [30.27, 120.15], zoom: 12});

传元素的好处是可以在框架里用 ref 拿到节点(React 的 useRef、Vue 的 template ref),避免 ID 冲突。传字符串时,内部走 getElementById,找不到会抛 Map container not found。

4.2 选项按功能分组

4.2.1 状态类选项

选项 类型 默认值 说明
crs CRS 类 EPSG3857 坐标参考系,非专业需求不要改
center LatLng undefined 初始中心;与 zoom 同时给才生效
zoom number undefined 初始层级
minZoom / maxZoom number undefined 不设时从已加载的 GridLayer 自动推导
layers Layer[] [] 初始化时加入的图层
maxBounds LatLngBounds null 限制可视范围,越界回弹
renderer Renderer undefined(即 SVG) 矢量渲染器;2.0 用它替代已删除的 preferCanvas

minZoom / maxZoom 的自动推导逻辑值得单说:给地图加一个 maxZoom: 18 的底图图层,地图的最大层级就变成 18;再加一个 maxZoom: 20 的图层,地图会放宽到 20。图层被移除时会重新计算并触发 zoomlevelschange。

4.2.2 动画与性能类选项

选项 默认值 说明
zoomAnimation true 缩放动画总开关
zoomAnimationThreshold 4 缩放跨度超过 4 级时不播动画,直接跳
fadeAnimation true 瓦片与弹窗淡入
markerZoomAnimation true marker 参与缩放动画;关掉后动画期间 marker 会消失
transform3DLimit 8388608 CSS 位移的最大值,2 的 23 次方,32 位浮点精度边界

transform3DLimit 取 2 的 23 次方,源码注释是 Precision limit of a 32-bit float。地图平移靠 CSS transform 位移整个 pane,位移超过约 838 万像素后浮点精度丢失、图层错位;Leaflet 的对策是在 moveend 检测位移并重置视图。正常项目碰不到,SimpleCRS 的超大平面图上连续 panBy 可能触发。

4.2.3 交互类选项

选项 默认值 说明
dragging true 拖动平移
inertia true 拖动惯性
inertiaDeceleration 3400 惯性减速度,单位 px/s²
inertiaMaxSpeed 1400 惯性最大速度,单位 px/s
easeLinearity 0.2 惯性缓动曲线强度
worldCopyJump false 跨世界副本平移时无缝跳回
maxBoundsViscosity 0.0 maxBounds 的”硬度”,1.0 为完全拖不动
boxZoom true Shift + 拖拽框选放大
doubleClickZoom true 双击放大;可传 'center' 强制以视图中心为缩放原点
scrollWheelZoom true 滚轮缩放;同样支持 'center'
wheelDebounceTime 40 滚轮事件节流,单位毫秒
wheelPxPerZoomLevel 60 每 60 像素滚轮量 = 1 级缩放
tapHold true 长按触发 contextmenu(2.0 起全平台默认开启)
tapTolerance 15 判定为点击允许的最大手指位移,单位像素
pinchZoom true 双指缩放;1.x 里叫 touchZoom
bounceAtZoomLimits true 捏合超出缩放范围时回弹
trackResize true 自动跟随容器尺寸变化

三个数值的来由:inertiaMaxSpeed 在 1.x 是 Infinity,2.0 改为 1400,更新日志给的理由是”快速一甩会把地图扔出好几屏”;wheelDebounceTime: 40 限频、wheelPxPerZoomLevel: 60 决定滚多少算一级,触控板用户抱怨太灵敏时调大后者(如 90)比调前者有效;maxBoundsViscosity 只在配了 maxBounds 时有效,业务上”不允许拖出行政区”就用 1.0。

4.2.4 键盘、控件与本地化

选项 默认值 说明
keyboard true 方向键平移、加减号缩放
keyboardPanDelta 80 方向键每次平移的像素数
ariaKeyShortcuts false 是否给容器设 aria-keyshortcuts;2.0 默认关闭,因为该属性只对美式键盘正确
zoomControl true 自动加缩放控件
attributionControl true 自动加版权控件
closePopupOnClick true 点击地图关闭弹窗
translate 恒等函数 2.0 新增,用于翻译控件标题与无障碍标签

translate 是 beta.2 加入的,签名是 translate(text) => string | undefined:

const dict = {
  'Zoom in': '放大',
  'Zoom out': '缩小',
  'Close popup': '关闭'
};

const map = new LeafletMap('map', {
  center: [30.27, 120.15],
  zoom: 12,
  translate: text => dict[text]
});

返回 undefined 时保留英文。注意文档说明的时机:文本是在渲染时翻译的,因此要在添加控件、打开弹窗前设置好。

4.3 视图控制:五组方法

4.3.1 直接设定

map.setView([31.23, 121.47], 13);       // 中心 + 层级
map.setZoom(14);                        // 只改层级,保持中心
map.setZoomAround([31.23, 121.47], 15); // 以指定经纬度为中心缩放

2.0 有一个行为改进值得记住:这些调用可以叠加,且以”动画目标”为基准。beta.3 更新日志的说法是 zoomIn(), zoomOut(), setZoom(), panTo() and panBy() build on the view a running animation is heading to。连点两次放大按钮会真的放大两级,而不是像 1.x 那样第二次打断第一次。同理 panTo(c).panBy(o) 的落点是 c + o。

但 getCenter() / getZoom() 返回的仍然是”当前实际视图”,不是动画目标。做状态同步时这一点很关键:不要在 zoomIn() 之后立刻读 getZoom() 期望拿到新值,要监听 zoomend。

4.3.2 相对移动

map.panBy([200, 0]);                 // 向右平移 200 像素
map.panTo([30.5, 120.5]);            // 平移到经纬度
map.zoomIn(2);                       // 放大 2 级(默认 zoomDelta)
map.zoomOut(0.5);                    // 缩小半级,需要 zoomSnap <= 0.5

zoomIn(delta) 的参数是”级数”,不是”次数”。而是否允许半级,取决于 zoomSnap。

4.3.3 分数级缩放:zoomSnap 与 zoomDelta

默认 zoomSnap: 1,地图层级会被吸附到整数。要做”丝滑缩放”或地图应用的精细定位,需要成对调整:

const map = new LeafletMap('map', {
  center: [30.27, 120.15],
  zoom: 12,
  zoomSnap: 0.25,     // 允许 12.0 / 12.25 / 12.5 这样的层级
  zoomDelta: 0.5,     // 按钮与键盘每次变化 0.5 级
  wheelPxPerZoomLevel: 90
});

map.on('zoomend', () => console.log(map.getZoom())); // 12.5 这类小数

三个参数的分工:zoomSnap 决定”允许的最小粒度”,zoomDelta 决定”每次操作变多少”,wheelPxPerZoomLevel 决定”滚轮滚多少像素算一级”。zoomSnap: 0 表示完全不吸附,但在小数层级下瓦片会明显模糊(因为浏览器把相邻层级的瓦片放大显示),视觉上是否可接受要实测。

4.3.4 飞行与适配

map.flyTo([39.9, 116.4], 10, {duration: 2});        // 带平滑飞行的视图切换
map.fitBounds([[30.1, 120.0], [30.4, 120.4]]);      // 按 bounds 自动定层级
map.fitBounds(bounds, {padding: [40, 40], maxZoom: 16});
map.flyToBounds(bounds, {duration: 1.5});
map.fitWorld({maxZoom: 3});

fitBounds 的 padding 有两个形式:全局 padding: [x, y],或分别设 paddingTopLeft 与 paddingBottomRight。后者在”左侧有固定侧边栏”的布局里必须用,否则自动缩放的区域会被侧边栏挡住一块。

flyTo 不是简单插值:它先算目标视图,再按当前视口大小决定中间层级,让镜头”拉远—平移—拉近”。2.0 尊重系统的 prefers-reduced-motion,开启时退化为直接跳转。

4.3.5 限制与守护

map.setMaxBounds([[29.0, 119.0], [32.0, 122.5]]);
map.setMinZoom(9);
map.setMaxZoom(17);
map.panInside([30.27, 120.15]);          // 把该点纳入视野,必要时平移
map.panInsideBounds(bounds);             // 让当前视图落进 bounds 内

setMaxBounds 除了限制拖动,还会影响 fitBounds 的结果——目标范围超出允许范围时,结果会被裁剪。

4.4 坐标换算 API

这是 Map 最容易被低估的部分。任何”自定义渲染、屏幕空间判断、把 DOM 坐标转经纬度”的需求都要用到它:

方法 输入 → 输出 典型用途
map.project(latlng, zoom?) LatLng → 绝对像素 Point 算两点像素距离、给自定义图层做投影
map.unproject(point, zoom?) 绝对像素 → LatLng 反算
map.latLngToLayerPoint(latlng) LatLng → 相对像素原点 给 pane 内元素定位
map.layerPointToLatLng(point) 相对像素 → LatLng 从元素位置反算
map.latLngToContainerPoint(latlng) LatLng → 相对容器左上角 判断要素是否在可视区
map.containerPointToLatLng(point) 相对容器像素 → LatLng 鼠标位置转经纬度
map.pointerEventToLatLng(e) PointerEvent → LatLng 从原始事件取坐标
map.getPixelOrigin() — → Point 自定义图层必备
map.getPixelBounds() — → Bounds 计算可见瓦片范围

2.0 有一处精度变化:latLngToLayerPoint 与 latLngToContainerPoint 返回小数像素,1.x 是四舍五入后的整数。迁移文档把它列为静默变更。需要整数就自己 .round():

const p = map.latLngToContainerPoint(latlng).round();
console.log(p.x, p.y);   // 整数

这条变化对”把元素对齐到像素网格”的自定义图层有实际影响:以前自动取整,现在可能出现 0.5px 的偏移,元素边缘发虚。

4.5 尺寸与生命周期

4.5.1 invalidateSize:什么时候必须调

地图只在三种时机知道容器尺寸变了:窗口 resize(trackResize 开启时)、ResizeObserver 回调(2.0 新增,能感知父容器变化)、以及你手动调 invalidateSize。

典型必须手动调的场景:

// 场景一:侧边栏折叠
sidebar.addEventListener('transitionend', () => map.invalidateSize());

// 场景二:Tab 切换后显示地图
tabs.addEventListener('shown', () => map.invalidateSize({pan: false}));

// 场景三:容器初始隐藏(display:none),显示后
panel.hidden = false;
map.invalidateSize(true);   // true 等价于 {animate: true}

invalidateSize(true) 是 {animate: true} 的简写;默认 animate: false, pan: true。trackResize 在 2.0 里由 ResizeObserver 派发 resize,再用 requestAnimationFrame 合并成一次 invalidateSize({debounceMoveend: true})——所以改完尺寸立刻读 map.getSize() 可能还是旧值。

4.5.2 remove 与资源释放

map.remove();   // 移除所有图层与事件监听,触发 'unload'

单页应用切路由时必须调 remove(),否则 DOM 残留、ResizeObserver 继续监听、全局事件不释放;2.0 会同时取消待执行的动画帧与过渡监听。

4.6 pane 与控件

pane 的层级已在第 3 章讲过,这里只补操作 API:

// 新建 pane(第二参可传容器名,默认继承 mapPane)
map.createPane('labels', map.getPane('tilePane'));

// 取 pane 元素,改 z-index 与指针行为
const pane = map.getPane('labels');
pane.style.zIndex = 620;
pane.style.pointerEvents = 'none';

// 一次性拿全部 pane
console.log(Object.keys(map.getPanes())); // mapPane/tilePane/overlayPane/...

控件的挂载与卸载:

import {LeafletMap, ScaleControl} from 'leaflet';

const scale = new ScaleControl({position: 'bottomleft', imperial: false});
scale.addTo(map);      // 等价于 map.addControl(scale)
map.removeControl(scale);

position 只有 'topleft' / 'topright' / 'bottomleft' / 'bottomright' 四个合法值,传别的值控件挂到 undefined 上抛异常——自定义控件第一坑。

4.7 事件

地图事件分六类,常用的列在下表:

类别 事件 触发时机
视图 movestart / move / moveend 平移开始 / 过程中(含动画每帧)/ 结束
视图 zoomstart / zoom / zoomend 缩放开始 / 过程中 / 结束
视图 viewreset / load / resize 需要重绘 / 首次渲染完成 / 尺寸变化
交互 click / dblclick / contextmenu 点击 / 双击 / 右键或长按
指针 pointerdown / pointermove / pointerup / pointerover / pointerout 2.0 用 Pointer Events 取代 mouse 系列
图层 layeradd / layerremove 图层加入 / 移除
弹窗 popupopen / popupclose / autopanstart 弹窗打开 / 关闭 / 自动平移开始
定位 locationfound / locationerror 地理定位成功 / 失败

2.0 的事件改名清单(旧名字会打一次 console.error 且监听器永不执行):

1.x 2.0
mousedown / mouseup / mousemove pointerdown / pointerup / pointermove
mouseover / mouseout pointerover / pointerout
keypress keydown
click / dblclick / contextmenu 不变

一个 2.0 的静默差异:pointermove 不只对应鼠标,手指拖动也会触发。如果某段逻辑只想响应鼠标,要检查 e.originalEvent.pointerType === 'mouse'。

4.8 地理定位

map.locate({setView: true, maxZoom: 14, enableHighAccuracy: true, timeout: 10000});

map.on('locationfound', e => {
  console.log(e.latlng, e.accuracy);          // 经纬度与精度(米)
  new Circle(e.latlng, {radius: e.accuracy}).addTo(map);
});

map.on('locationerror', e => {
  console.warn('定位失败:', e.message, e.code);
});

map.stopLocate();   // 停止 watch
选项 默认值 说明
watch false 是否持续监听位置变化
setView false 是否自动把视图移到用户位置
maxZoom Infinity 自动定位时的最大层级
timeout 10000 超时毫秒数,超时触发 locationerror
maximumAge 0 允许使用多久内的缓存位置
enableHighAccuracy false 是否要求高精度(更耗电)

生产环境两点:locate 依赖 HTTPS(Geolocation API 硬要求,localhost 除外);国内定位结果是 GCJ-02 偏移,直接画在 WGS-84 底图上会偏几百米,需业务层纠偏。

4.9 2.0 的 Map 行为变化

见 4.2 与 4.3 的表格:第三参改选项对象、touchZoom → pinchZoom、preferCanvas 删除、ariaKeyShortcuts 默认关闭、坐标换算返回小数、视图方法以动画目标为基准。另有两条静默变化:getCenter() 刚创建时不再吸附到整像素;控件点击后不再把焦点抢回地图。

4.10 排错清单

现象 原因 修法
地图区域全空、无瓦片请求 容器高度为 0,或只给了 center 没给 zoom 给容器高度;两个选项同时给
Map container not found 构造时 #map 还不存在 脚本放 </body> 前,或包在 DOMContentLoaded 里
切 Tab 后地图只显示一半 隐藏时尺寸算错 显示后调 map.invalidateSize()
连续两次 zoomIn() 只放大一级 用了 1.x 语义(2.0 已修复) 升级到 2.0;1.x 里用 setZoom
flyTo 报错 Invalid LatLng 坐标数组里有 undefined 或字符串 'null' 2.0 会校验;用 LatLng.validate() 过滤脏数据
地图拖出边界后回弹太软 maxBoundsViscosity 为 0 设成 1.0
滚轮缩放太灵敏 / 太迟钝 wheelPxPerZoomLevel 不合适 调大 = 更迟钝;配合 wheelDebounceTime

4.11 本章小结

要点 结论
构造必给两项 center 与 zoom 同时存在才会设视图
视图方法语义 2.0 起连续调用叠加,读值仍取”当前视图”而非动画目标
分数级缩放 zoomSnap 定粒度,zoomDelta 定步长,wheelPxPerZoomLevel 定滚轮灵敏度
尺寸变化 trackResize 覆盖窗口与父容器;隐藏→显示、Tab 切换后手动 invalidateSize()
坐标换算 给 pane 用 latLngToLayerPoint,判断可视区用 latLngToContainerPoint
事件改名 mouse 系列 → pointer 系列,keypress → keydown
需要删地图时 单页应用切路由必须 map.remove()