第08章 - 标记 Marker 与图标体系
Marker 是 Leaflet 里唯一”绑定单个地理点 + 可交互 + 可拖拽”的图层。它的实现比看上去复杂:一个 marker 在 DOM 里最多对应两个元素(图标与阴影),位置靠 CSS transform 平移,层级由纬度实时计算,还要处理键盘可达性与拖拽状态互斥。
2.0 对 Marker 做了两处结构性改动,都值得单开一节:默认图标从三张 PNG 换成一份内联 SVG,以及新增了旋转能力。前者解决了困扰 1.x 多年的”打包后 marker 图标 404”问题,后者把过去要靠 leaflet-rotatedmarker 插件做的事收进了核心。
8.1 构造与定位
8.1.1 最小用法
import {LeafletMap, TileLayer, Marker} from 'leaflet';
const map = new LeafletMap('map').setView([30.27, 120.15], 13);
new TileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {maxZoom: 19}).addTo(map);
const marker = new Marker([30.27, 120.15]).addTo(map);
marker.bindPopup('默认图标');
new Marker(latlng, options?) 的第一个参数可以是 [lat, lng] 数组、{lat, lng} 对象或 LatLng 实例。2.0 起坐标会严格校验:
new Marker(['30.27', 120.15]); // 数字字符串可以,会被转成数字
new Marker([null, 120.15]); // 抛 Invalid LatLng object
new Marker([undefined, 120.15]); // 抛 Invalid LatLng object
1.x 会把 null、''、布尔值转成 0 或 1,结果是脏数据被画到几内亚湾(0, 0)。2.0 改成在构造时就抛错,这对数据来源不可控的业务是好事——错误暴露在数据入口,而不是渲染层的诡异位置。要批量过滤脏数据用 LatLng.validate():
const raw = [[30.27, 120.15], [null, 120.15], [30.30, 120.20]];
const points = raw.filter(p => LatLng.validate(p));
points.forEach(p => new Marker(p).addTo(map));
8.1.2 定位链路
marker 的屏幕位置计算可以拆成四步:
LatLng
│ map.latLngToLayerPoint(latlng) 转成相对像素原点的坐标
▼
layerPoint(可能带小数,2.0 起不再取整)
│ _setPos() → DomUtil.setPosition
▼
CSS: transform: translate3d(x, y, 0)
│ 再叠加 iconAnchor 的负 margin(由 _setIconStyles 设置)
▼
图标的"尖端"落在经纬度上
关键在最后一步。iconAnchor 不是”图标的中心”,而是”图标上要对准经纬度的那个点”,用负 margin 实现:
// src/layer/marker/Icon.js
if (!isNaN(anchor.x)) { style.marginLeft = (-anchor.x) + 'px'; }
if (!isNaN(anchor.y)) { style.marginTop = (-anchor.y) + 'px'; }
所以默认图标的 iconAnchor: [12, 41] 表示”图标内 (12, 41) 这个像素对准坐标”——对一个 25×46 的图钉来说,就是底部尖端偏上 5px 的位置。
8.1.3 层级:由纬度决定
Marker 的 z-index 不是全局排序,而是按纬度实时计算:越靠南(纬度越小)的 marker 越靠前,这样在俯视地图上”近处遮挡远处”的视觉才自然。
// 想让某个 marker 永远在最上层
const important = new Marker([30.27, 120.15], {zIndexOffset: 1000}).addTo(map);
// 悬停时抬升(默认关闭)
new Marker([30.30, 120.20], {riseOnHover: true, riseOffset: 250}).addTo(map);
| 选项 | 默认值 | 说明 |
|---|---|---|
zIndexOffset |
0 |
在纬度序基础上加的偏移,正数更大 |
riseOnHover |
false |
悬停时临时抬升 |
riseOffset |
250 |
抬升的量 |
2.0 增加了一条:获得键盘焦点的 marker 永远会被提到最前,不受 riseOnHover 控制。beta.3 更新日志的原话是 riseOnHover only controls hover: focused markers always come to the front。如果业务上依赖 zIndexOffset 做严格分层,要注意焦点会打破它。
8.2 图标体系:Icon / DefaultIcon / DivIcon
8.2.1 三个类的关系
Icon 基础类:iconUrl + iconSize + iconAnchor + shadow*
├── DefaultIcon 默认蓝色图钉,内联 SVG data URI,支持 color 选项
└── DivIcon 用 <div> 代替图片,可放任意 HTML
Icon 的必填项只有 iconUrl,其余都可选:
| 选项 | 默认值 | 说明 |
|---|---|---|
iconUrl |
null |
必填,图标图片地址 |
iconRetinaUrl |
null |
高分屏图标地址 |
iconSize |
null |
图标尺寸(像素),数字表示正方形 |
iconAnchor |
null |
对准经纬度的点;有 size 时默认居中 |
popupAnchor |
[0, 0] |
弹窗相对 iconAnchor 的偏移 |
tooltipAnchor |
[0, 0] |
tooltip 的挂载点偏移 |
shadowUrl |
null |
阴影图片,不给就没有阴影 |
shadowSize / shadowAnchor |
null |
阴影的尺寸与锚点 |
className |
'' |
加到图标与阴影元素上的类名 |
crossOrigin |
false |
跨域属性,读取像素数据时必须设 |
8.2.2 自定义图片图标
import {Icon, Marker} from 'leaflet';
const pin = new Icon({
iconUrl: '/assets/icons/pin.png',
iconRetinaUrl: '/assets/icons/pin@2x.png',
iconSize: [32, 40],
iconAnchor: [16, 40], // 底部尖端
popupAnchor: [0, -40], // 弹窗在图标上方
tooltipAnchor: [16, -20],
shadowUrl: '/assets/icons/pin-shadow.png',
shadowSize: [40, 40],
shadowAnchor: [16, 40]
});
new Marker([30.27, 120.15], {icon: pin}).addTo(map);
四个坐标值要成套调:iconSize 决定元素尺寸,iconAnchor 决定”哪个点贴地”,popupAnchor 与 tooltipAnchor 决定附属 UI 的落点。改了一个不改其他,最常见的结果是”弹窗盖住图标”或”图标悬在半空”。
8.2.3 2.0 的默认图标:内联 SVG
1.x 的默认图标是三张 PNG(marker-icon.png、marker-icon-2x.png、marker-shadow.png),放在 dist/images/。打包器不会自动处理 CSS 里的图片引用,于是”图标 404”成了 1.x 最著名的社区问题,衍生出一堆 L.Icon.Default.prototype._getIconUrl 补丁。
2.0 直接把这个问题的前提去掉了:默认图标是一份内联的 SVG data URI,源码在 src/layer/marker/DefaultIcon.js:
const defaultOptions = {
iconUrl: pin('%23126FC6', '%23107BC2', '%232E6C97', '%233883B7'),
iconSize: [25, 46],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
tooltipAnchor: [16, -28],
color: null
};
三个直接后果:
- 不再有
dist/images/目录,leaflet.zip里也没有图片要拷; - 1.x 的补丁代码必须删掉,迁移文档明确要求删掉
delete L.Icon.Default.prototype._getIconUrl与Icon.Default.mergeOptions({...})那一段——因为Icon.Default与图片文件都不存在了; - 可以用
color改颜色:
import {DefaultIcon, Marker} from 'leaflet';
new Marker([30.27, 120.15], {
icon: new DefaultIcon({color: 'crimson'})
}).addTo(map);
color 传任意 CSS 颜色值,内部把 # 转义成 %23 后拼进 SVG。注意 2.0 的默认图标尺寸是 25×46(1.x 是 25×41),因为阴影画进了同一张图里,不再有独立的 shadow 元素。如果你的代码里写死了 1.x 的尺寸,要一起改。
全局换默认图标用 mergeOptions:
Marker.mergeOptions({icon: new DefaultIcon({color: '#2e7d32'})});
8.3 DivIcon:用 HTML 做图标
DivIcon 用 <div> 代替 <img>,忽略 iconUrl 与所有 shadow 选项:
| 选项 | 默认值 | 说明 |
|---|---|---|
html |
false |
内部 HTML;也可传 HTMLElement |
iconSize |
[12, 12] |
尺寸,也可完全交给 CSS |
bgPos |
null |
背景图偏移,配合 CSS 雪碧图 |
className |
'leaflet-div-icon' |
元素类名 |
8.3.1 文本标签
import {DivIcon, Marker} from 'leaflet';
const label = new DivIcon({
className: 'poi-label',
html: '<span>西湖</span>',
iconSize: [60, 24],
iconAnchor: [30, 12]
});
new Marker([30.24, 120.15], {icon: label}).addTo(map);
配套 CSS 建议写在站点的样式表里(本章正文不内联样式):
.poi-label {
background: #fff;
border: 1px solid #888;
border-radius: 12px;
text-align: center;
line-height: 22px;
font-size: 12px;
}
8.3.2 传 DOM 元素而非字符串
html 传字符串时会走 innerHTML,内容来自用户数据时有 XSS 风险。传 HTMLElement 更安全:
const el = document.createElement('div');
el.className = 'badge';
el.textContent = untrustedName; // 纯文本,不会被解析成 HTML
new Marker(latlng, {icon: new DivIcon({html: el, iconSize: [80, 24]})}).addTo(map);
DivIcon 的另一个高频用途是编号图标(1、2、3…)与状态标记(红/黄/绿圆点),这类需求用 CSS 类切换比准备一堆图片省事得多。
8.3.3 DivIcon 的定位细节
DivIcon 与 Icon 共享 iconSize / iconAnchor 的定位逻辑(同一段 _setIconStyles),但有一点不同:DivIcon 没有阴影,createShadow 返回 null。所以给 DivIcon 传 shadowUrl 不会产生任何效果。
8.4 旋转
2.0 新增的能力,选项名是 rotation(角度,顺时针),默认 null 表示不旋转:
const car = new Marker([30.27, 120.15], {
rotation: 45,
icon: new DivIcon({html: '<span class="car">→</span>', iconSize: [24, 24]})
}).addTo(map);
car.setRotation(90); // 运行时改
console.log(car.getRotation()); // 90
car.setRotation(null); // 取消旋转
源码注释里写了一条容易被忽略的规则:旋转过的 marker 没有阴影,即使角度是 0°。原因是阴影是竖直图钉的投影,跟着图标歪掉会很难看。所以如果显式传了 rotation: 0,也会得到”无阴影”的 marker。
迁移相关:如果项目用了 leaflet-rotatedmarker 插件,2.0 下它不工作,rotationAngle 选项被静默忽略。把 rotationAngle 换成 rotation 即可。
8.5 拖拽
const draggable = new Marker([30.27, 120.15], {
draggable: true,
autoPan: true,
autoPanPadding: [50, 50],
autoPanSpeed: 10
}).addTo(map);
draggable.on('dragstart', () => console.log('开始拖'));
draggable.on('drag', e => console.log('拖动中', e.latlng));
draggable.on('dragend', e => console.log('结束于', e.target.getLatLng()));
// 运行时开关
draggable.dragging.disable();
| 选项 | 默认值 | 说明 |
|---|---|---|
draggable |
false |
是否可拖拽 |
autoPan |
false |
拖到地图边缘时自动平移 |
autoPanPadding |
[50, 50] |
距边缘多少像素开始自动平移 |
autoPanSpeed |
10 |
每次自动平移的像素数 |
draggable 的实现是一个独立的 Handler(MarkerDrag),挂在 marker.dragging 上,可以在运行时 enable() / disable()。2.0 beta.3 修了两个拖拽相关的 bug:拖拽中调用 setIcon 会中断拖拽、重新加入地图后 draggable 状态错误地恢复。如果遇到这类表现,先确认版本不低于 beta.3。
键盘可达性:可拖拽的 marker 获得焦点后,方向键每次移动 10 像素,按住 Shift 每次 30 像素。这是 2.0 新增的,1.x 只能靠鼠标。
8.6 事件
Marker 与 Path 一样是 Interactive layer,但默认 不冒泡:
| 选项 | 默认值 | 说明 |
|---|---|---|
interactive |
true |
是否响应指针事件 |
bubblingPointerEvents |
false |
事件是否冒泡到地图 |
keyboard |
true |
是否可 Tab 聚焦、Enter/Space 触发点击 |
title |
'' |
浏览器原生 tooltip 文本 |
alt |
'Marker' |
图标的 alt 属性文本 |
autoPanOnFocus |
true |
键盘聚焦时自动平移到可见范围 |
const m = new Marker([30.27, 120.15], {
title: '点击查看详情', // 悬停显示
alt: '监测站 A', // 屏幕阅读器读这个
keyboard: true
}).addTo(map);
m.on('click', e => {
console.log('点击', e.latlng);
e.originalEvent.stopPropagation(); // 需要阻止冒泡时手动处理
});
无障碍相关的方法都在 2.0 补齐了:可交互 marker 的元素带 role="button"、tabIndex=0;有弹窗时还会设 aria-expanded 与 aria-controls;Enter/Space 可以触发点击。alt 的默认值 'Marker' 会走地图的 translate 选项,可以用来做中文翻译。
8.7 排错清单
| 现象 | 原因 | 修法 |
|---|---|---|
| 图标 404 / 显示为破图 | 还在用 1.x 的 dist/images/ 路径,或保留了 1.x 的补丁代码 |
删掉补丁;2.0 用内联 SVG,或给 Icon 传自己图片的绝对路径 |
| 图标位置偏了半个身位 | 只设了 iconSize 没设 iconAnchor |
一起设置,iconAnchor 取”贴地点” |
| 弹窗盖住图标 | popupAnchor 未调整 |
一般设 [0, -iconHeight] 让弹窗在图标上方 |
Invalid LatLng object |
坐标含 null / undefined / 非法字符串 |
用 LatLng.validate() 过滤 |
| 悬停时 marker 不抬升 | riseOnHover 默认 false |
显式设 true |
某个 marker 被别的盖住,调 zIndexOffset 无效 |
焦点抬升优先于 zIndexOffset |
避免依赖严格层级;或用 pane 分组(见第 3 章) |
rotation 无效 |
用了 leaflet-rotatedmarker 的 rotationAngle |
换成 rotation;插件本身在 2.0 不可用 |
| 旋转后阴影消失 | 设计如此:旋转的 marker 不画阴影 | 需要阴影就自己用 CSS 加 |
marker 在 display:none 的容器里位置错乱 |
隐藏时尺寸为 0,投影计算出错 | 显示后调 map.invalidateSize()(见第 4 章) |
8.8 生产建议
用 DivIcon 承载业务标签,用 Icon 承载图形符号。 前者改文案只需改字符串,后者需要美术资源。数量大时 DivIcon 的 DOM 节点更多,注意配合聚类插件。
坐标进 Marker 之前先校验。 2.0 会抛错,这比 1.x 静默画到 (0, 0) 好,但异常会中断整个渲染循环。在数据入口统一过滤:if (!LatLng.validate(c)) continue;。
不要在 onAdd 之外的时机改 icon 选项。 运行时换图标用 setIcon(newIcon),它会重建 DOM 并保留位置与弹窗绑定;直接改 marker.options.icon 不会立即生效。
上千个 marker 考虑聚类。 Leaflet 核心不带聚类,需要 leaflet.markercluster 之类的插件;2.0 下要选声明支持 2.0 的版本。纯展示场景也可以退一步:把点渲染进 Canvas 或服务端预聚合成网格。
8.9 本章小结
| 要点 | 结论 |
|---|---|
| 定位 | latLngToLayerPoint → transform 平移;iconAnchor 通过负 margin 实现”贴地” |
| 层级 | 按纬度排序,zIndexOffset(默认 0)微调;2.0 起焦点 marker 永远最前 |
| 默认图标 | 2.0 改为内联 SVG,25×46,支持 color;1.x 的 PNG 补丁必须删除 |
| 自定义图标 | Icon 需 iconUrl + iconSize + iconAnchor 成套设置 |
| DivIcon | HTML 做图标,无阴影,默认 iconSize: [12, 12];传 HTMLElement 避免 XSS |
| 旋转 | rotation(2.0 新增),旋转后无阴影;旧插件 rotationAngle 无效 |
| 拖拽 | draggable(默认 false)+ marker.dragging 运行时开关;键盘可用方向键移动 |
| 事件 | 默认 bubblingPointerEvents: false,与 Path 相反 |