第03章 - 核心架构与设计哲学
Leaflet 的地图能跑起来,靠的是一套极薄的对象模型:一个类系统(Class)、一个事件系统(Evented)、一个交互处理器系统(Handler),再叠上图层生命周期和地图坐标管线。整份源码约 1.6 万行(含大量 JSDoc 注释,src/ 下 70 个 JS 文件),没有虚拟 DOM,没有响应式框架,也没有构建期的编译步骤。
薄不等于简单。恰恰因为薄,每个抽象都直接暴露在 API 上:自定义图层要手动实现 onAdd,改默认值要走 static defaultOptions,弹窗能跟着 marker 走是因为注册了事件父级。不理解这些,写出来的东西能跑但会以奇怪的方式失败。
本章拆解类系统、对象树、options 继承、事件传播、图层生命周期,最后落到坐标与 pane 管线;这些是第 4 到第 9 章共用的底层。
3.1 三个基类撑起一切
打开 src/Leaflet.js,导出的类有四十多个,但继承链的起点只有三个:
Class 最底层:options 继承、mixin、构造钩子
├── Evented 加事件:on/off/once/fire/listens
│ ├── LeafletMap
│ ├── LayerBase
│ │ ├── Layer 加 popup/tooltip 绑定
│ │ │ ├── Marker
│ │ │ ├── Path → Polyline → Polygon → Rectangle
│ │ │ ├── GridLayer → TileLayer → WMSTileLayer
│ │ │ ├── LayerGroup → FeatureGroup → GeoJSON
│ │ │ ├── BlanketOverlay → Renderer → SVG / Canvas
│ │ │ └── ImageOverlay / VideoOverlay / SVGOverlay
│ │ └── DivOverlay → Popup / Tooltip
│ └── ...
└── Control Handler Icon → DefaultIcon / DivIcon
2.0 特有的架构变化:DivOverlay 不再继承 Layer,而是直接继承 LayerBase。1.x 里 popup instanceof L.Layer 为 true,迁移文档明确写了 Popups and tooltips are no longer instanceof Layer。它们仍支持 addTo()、openOn()、map.addLayer(),但不再共享 Layer 的能力。实际影响:插件若写 Layer.include({...}) 并指望弹窗获得该方法,2.0 下会失效。
3.1.1 Class:只做三件事
src/core/Class.js 只有 289 行,核心就三件事。
第一,options 继承。 每个类用静态字段声明默认值:
class MyLayer extends Layer {
static defaultOptions = {
color: 'red',
weight: 3
};
}
class MyFancyLayer extends MyLayer {
static defaultOptions = {weight: 5}; // 只覆盖 weight
}
const a = new MyFancyLayer({color: 'blue'});
console.log(a.options.color); // blue —— 构造参数优先
console.log(a.options.weight); // 5 —— 子类覆盖父类
第二,mixin(include)。 把对象方法挂到原型,同时把它的 options 合并进默认值:
const Selectable = {select() { this._selected = true; return this; }};
class Pin extends Marker {}
Pin.include(Selectable);
new Pin([30, 120]).select();
第三,构造钩子(addInitHook)。 插件注入行为的主要手段:
Marker.addInitHook(function () { this._createdAt = Date.now(); });
2.0 里钩子在整个构造链跑完之后执行一次,挂在 Layer 上的钩子看到的是构建完成的 Polygon;而自己的类继承 Leaflet 类时,钩子先于你的 constructor 主体执行。要”等构造完再执行”,在类上标 static initHooks = true 并在 constructor 末尾调 this.callInitHooks()。
必须知道的失败模式:类上还留着 initialize() 方法时,new 它会直接抛错,Class 构造器第一段就是 if (this.initialize) throw new Error(...),错误信息提示改名 constructor() 并在首行 super()。这是 1.x 插件在 2.0 下最常见的第一条报错。
3.1.2 Evented:事件是骨架不是装饰
Evented 提供 on / off / once / fire / listens 五个方法,以及两个事件父级方法 addEventParent / removeEventParent。它有两个实现细节值得记住。
监听器的数据结构是分层的。 同一类型监听器少于 32 个时,off 直接过滤数组;超过 32 个则建索引,查找降到常数时间(源码常量 INDEX_SIZE = 32)。2.0.0-beta.3 更新日志给出的效果:加 5 万个带弹窗的 marker,耗时从 15.6 秒降到 0.5 秒。
fire 只遍历开头那段数组。 源码注释写明 listeners added during fire are appended past length——事件回调里新加的监听器本轮不执行。不要在回调里”立刻依赖”新监听器的执行。
3.1.3 Handler:可开关的交互单元
Handler 是 Map 的交互插件系统,比 Evented 更简单,只有四个方法:
// src/core/Handler.js 的核心逻辑
enable() {
if (this._enabled) { return this; }
this._enabled = true;
this.addHooks(); // 子类在这里注册事件
return this;
}
disable() {
if (!this._enabled) { return this; }
this._enabled = false;
this.removeHooks(); // 子类在这里解绑事件
return this;
}
enabled() { return !!this._enabled; }
地图自带 7 个 handler,都是 map 的直接属性:
| handler 属性 | 类 | 对应地图选项 | 作用 |
|---|---|---|---|
map.dragging |
DragHandler |
dragging(默认 true) |
拖动平移、惯性 |
map.boxZoom |
BoxZoomHandler |
boxZoom(默认 true) |
Shift + 拖拽框选放大 |
map.doubleClickZoom |
DoubleClickZoomHandler |
doubleClickZoom(默认 true) |
双击放大 / Shift 双击缩小 |
map.scrollWheelZoom |
ScrollWheelZoomHandler |
scrollWheelZoom(默认 true) |
滚轮缩放 |
map.keyboard |
KeyboardHandler |
keyboard(默认 true) |
方向键平移、加号减号缩放 |
map.pinchZoom |
PinchZoomHandler |
pinchZoom(默认 true) |
双指捏合缩放 |
map.tapHold |
TapHoldHandler |
tapHold(默认 true,2.0 起全平台开启) |
长按触发 contextmenu |
运行时开关一行就够:
const map = new LeafletMap('map', {center: [30, 120], zoom: 12});
map.scrollWheelZoom.disable(); // 嵌在长页面里时,禁用滚轮缩放防止"页面被地图劫持"
map.dragging.enable();
console.log(map.dragging.enabled()); // true
这条建议在生产里很实用:地图占满一屏的页面无所谓;地图只占页面中部一块时,用户滚轮划过地图就被缩放截住,体验极差。此时 scrollWheelZoom: false 是正确默认值。
3.2 options 的三层继承
options 是 Leaflet 里最容易被误解的机制。它不是简单的浅拷贝,而是一条三层原型链:
Layer.defaultOptions ← 第 1 层:类声明的默认值
└── Marker.defaultOptions ← 子类 static defaultOptions,原型指向父类
└── markerInstance.options ← 第 2 层:Object.create(Marker.defaultOptions)
└── 构造参数 {icon: ..., zIndexOffset: 100} ← 第 3 层:直接赋值到实例 options
源码在 Util.setOptions 里做这件事:
export function setOptions(obj, options) {
if (!Object.hasOwn(obj, 'options')) {
const defaults = getDefaultOptions(obj.constructor) ?? obj.options;
obj.options = defaults ? Object.create(defaults) : {};
}
for (const i in options) {
if (Object.hasOwn(options, i)) {
obj.options[i] = options[i];
}
}
return obj.options;
}
Object.create 是关键:实例的 options 不复制默认值,而是把默认值对象放在原型上。所以运行时改默认值会影响所有没显式覆盖该选项的实例:
import {Marker, DefaultIcon} from 'leaflet';
// 全局换默认图标(2.0 推荐写法)
Marker.mergeOptions({icon: new DefaultIcon({color: 'crimson'})});
// 改所有 marker 的默认抬升行为
Marker.defaultOptions.riseOnHover = true;
反过来,下面这行在 2.0 会直接抛 Cannot set properties of undefined (setting 'icon'):
// 1.x 写法,2.0 已失效
L.Marker.prototype.options.icon = myIcon;
原因是 2.0 把默认值从 prototype.options 挪到了静态字段 defaultOptions,Marker.prototype.options 是 undefined。迁移文档给的标准替代就是 Marker.mergeOptions({icon: myIcon})。
mergeOptions 只在类自身上创建 defaultOptions 的副本,不会污染父类;源码注释是 adds or changes defaults of an existing class without touching the defaults of its parent。对插件作者重要:改 Marker 的默认值不应顺带改掉 Layer 的。
3.3 事件传播:从图层到分组再到地图
单个对象的 on / fire 没什么可讲的,真正需要理解的是冒泡。
3.3.1 三个字段的语义
每个事件对象都带 target、sourceTarget,传播时再加 propagatedFrom:
| 字段 | 含义 |
|---|---|
type |
事件名,如 'click' |
target |
当前正在派发这个事件的对象 |
sourceTarget |
事件最初由谁触发 |
propagatedFrom |
传播链上把事件传上来的那一层 |
FeatureGroup 在 addLayer 时给成员注册事件父级:
// src/layer/FeatureGroup.js
addLayer(layer) {
layer.addEventParent(this); // 成员的事件会冒泡到分组
super.addLayer(layer);
return this.fire('layeradd', {layer});
}
于是分组能统一处理成员事件:
import {LeafletMap, TileLayer, Marker, FeatureGroup} from 'leaflet';
const map = new LeafletMap('map').setView([30.27, 120.15], 12);
new TileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {maxZoom: 19}).addTo(map);
const markers = new Marker([30.27, 120.15]);
const group = new FeatureGroup([markers]).addTo(map);
group.on('click', e => {
console.log('点到了分组里的图层:', e.propagatedFrom.getLatLng());
});
这里 e.propagatedFrom 是被点的那个 Marker,e.target 是 FeatureGroup。2.0 迁移文档提到,1.x 在冒泡事件上有个 layer 属性,2.0 在运行时仍然保留它作为 propagatedFrom 的别名,但没有类型标注,官方建议新代码用 propagatedFrom。
3.3.2 冒泡的默认差异
Marker 的默认选项里 bubblingPointerEvents: false,点 marker 不会触发地图的 click;Path 的默认值则是 true。”点空白处关弹窗、点 marker 不关”靠的就是这个默认值;反过来需要点 marker 也触发地图逻辑,显式设为 true:new Marker(latlng, {bubblingPointerEvents: true}).addTo(map)。
3.4 图层生命周期
任何一个图层从”被 add 到 map”到”被移除”,只有两个阶段,外加一个前置钩子。Map.addLayer 的核心逻辑是:
addLayer(layer) {
if (!layer._layerAdd) { throw new Error('The provided object is not a Layer.'); }
const id = Util.stamp(layer);
if (this._layers[id]) { return this; } // 重复添加直接返回
this._layers[id] = layer;
layer._mapToAdd = this;
layer.beforeAdd?.(this); // ① 可选:拿地图设置渲染器等
this.whenReady(layer._layerAdd, layer); // ② 地图 Ready 后才真正 onAdd
return this;
}
_layerAdd 做的事,按 LayerBase 源码顺序:
_map = map
_zoomAnimated = map._zoomAnimated
若有 getEvents() → map.on(events, this),并把 off 挂到 layer 的 'remove'
onAdd(map) ← 子类在这里创建 DOM、挂到 pane
fire('add')
map.fire('layeradd', {layer})
移除方向对称:map.removeLayer 调用 layer.onRemove(map),删索引,触发 layerremove 与图层的 remove,最后把 layer._map 置空。LayerBase 用一个技巧保证事件一并清理:getEvents 返回的事件在附加时同时注册 this.once('remove', () => map.off(events, this))。
3.4.1 为什么强调 beforeAdd
beforeAdd 是”上屏之前按地图配置做准备”的钩子,两个真实用例:
// Path 在 beforeAdd 里确定用哪个 Renderer,因为 getEvents 的顺序依赖它
beforeAdd(map) {
this._renderer = map.getRenderer(this);
}
// GridLayer 在这里把最大最小缩放注册给地图,影响地图的缩放范围
beforeAdd(map) {
map._addZoomLimit(this);
}
需要读 map.options.crs 之类信息决定内部结构时写 beforeAdd:onAdd 执行时地图可能已完成一次视图计算。
3.4.2 一个最小自定义图层
import {Layer, DomUtil} from 'leaflet';
class CornerBadge extends Layer {
onAdd(map) {
const el = this._el = DomUtil.create('div', 'corner-badge');
el.textContent = '自定义图层';
el.style.cssText = 'position:absolute;top:8px;left:8px;padding:4px 8px;background:#fff;z-index:1000';
map.getPane('overlayPane').appendChild(el);
map.on('zoomend', this._update, this);
this._update();
}
_update() {
const p = this._map.latLngToLayerPoint([30.27, 120.15]);
DomUtil.setPosition(this._el, p);
}
onRemove(map) {
this._el.remove();
map.off('zoomend', this._update, this);
}
}
这段代码体现了生命周期契约的全部要求:onAdd 建 DOM 并挂事件,onRemove 必须把 DOM 和事件都清掉。漏掉 map.off 是最常见的泄漏来源。
3.5 地图管线:pane 与像素原点
Leaflet 把”图层画在哪一层”抽象成 pane。默认 7 个,z-index 写在 leaflet.css 里:
| pane 名 | z-index | 谁在用 |
|---|---|---|
tilePane |
200 | GridLayer、TileLayer、WMSTileLayer |
overlayPane |
400 | Path(通过 Renderer 的容器)、自定义矢量层 |
shadowPane |
500 | Marker 的阴影 |
markerPane |
600 | Marker 的图标 |
tooltipPane |
650 | Tooltip |
popupPane |
700 | Popup |
| 控件容器 | 800 | 四角控件 |
数字是连续设计过的:把标签瓦片放到 marker 之上,用 createPane 建一个 z-index 在 600 与 650 之间的 pane 即可:
map.createPane('labels');
map.getPane('labels').style.zIndex = 620;
map.getPane('labels').style.pointerEvents = 'none'; // 让标签不拦截点击
new TileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
pane: 'labels',
maxZoom: 19
}).addTo(map);
3.5.1 坐标的四级换算
地图上每一个像素位置都要经过同一条链:
LatLng (经纬度)
│ crs.project() 球面墨卡托 → 米
▼
投影坐标 (米)
│ crs.transformation._transform(projectedPoint, scale)
▼
像素坐标 (zoom 相关,随 zoom 翻倍)
│ 减去 getPixelOrigin()
▼
layerPoint (相对像素原点) → 加 pane 偏移 → DOM 上的 transform
对应的公开 API:
| 方法 | 输入 → 输出 | 用途 |
|---|---|---|
map.project(latlng, zoom?) |
LatLng → 绝对像素 Point |
计算两点屏幕距离、自定义渲染 |
map.unproject(point, zoom?) |
绝对像素 → LatLng | 反算坐标 |
map.latLngToLayerPoint(latlng) |
LatLng → 相对像素原点 | 给 DOM 定位 |
map.layerPointToLatLng(point) |
相对像素 → LatLng | 从 DOM 位置反算 |
map.latLngToContainerPoint(latlng) |
LatLng → 相对容器左上角 | 做 tooltip 定位、屏幕空间判断 |
map.getPixelOrigin() |
— → 像素原点 | 自定义图层必备 |
最容易错的是混用 latLngToLayerPoint 与 latLngToContainerPoint:前者相对像素原点(随平移变化),后者相对容器左上角(固定)。给 pane 里的元素设位置要用前者,与 pane 坐标系一致。
3.6 渲染器:默认 SVG,需要时换 Canvas
Path 自己不画图,它把绘制委托给 Renderer。地图的默认渲染器是 SVG:
const map = new LeafletMap('map', {
center: [30.27, 120.15],
zoom: 12,
renderer: new Canvas() // 全图矢量改走 canvas
});
1.x 有 preferCanvas: true 这个快捷选项,2.0 已经删除,改成显式传 renderer。迁移文档的原话是 preferCanvas: true is removed and logs an error. Use renderer: new Canvas() instead。
两种渲染器按数量级取舍:
| SVG | Canvas | |
|---|---|---|
| 适合要素数 | 几百到几千 | 几千到几万 |
| 每个要素是否独立 DOM | 是,可单独加 class、做 CSS 过渡 | 否,统一绘制 |
| 事件命中检测 | 浏览器原生 | Leaflet 自己做几何判断 |
| 高分屏 | 自动清晰 | 内部按 devicePixelRatio 放大画布 |
| 典型问题 | 要素多时 DOM 节点爆炸、重排卡顿 | 无法用 CSS 给单个要素上样式 |
2.0 把渲染器基类的一部分抽到了 BlanketOverlay(覆盖整个地图的元素基类),Renderer 继承它。这个抽象对使用者不可见,但解释了为什么 Renderer 的选项里会有 padding(默认 0.1)和 continuous——它们来自 BlanketOverlay。continuous 在渲染器上被强制为 false,源码注释给了原因:设为 true 时渲染器会在捏合缩放中错位。
3.7 设计哲学:三条可以验证的原则
第一条:单向依赖,核心不知道插件存在。 src/ 里没有任何插件的名字。功能想进核心必须满足”几乎所有地图都用”的门槛,否则做成插件——AGENTS.md 的原话是 consider whether a feature belongs in a plugin rather than core。
第二条:一切可覆盖,但覆盖点必须显式。 getTileUrl、createTile、onAdd / onRemove、getEvents、createPane 都是官方认可的扩展点,源码注释里标注为 Extension methods。不要改私有字段(下划线开头):2.0 大改时 _leaflet_events、_leaflet_disable_click、_leaflet_pos 全被移除。
第三条:不引入运行时依赖,也不引入编译期魔法。 没有 dependencies,没有装饰器,没有 JSX;src/ 里是可直接在浏览器运行的 ES 模块,JSDoc 同时承担类型生成与文档生成。代价是每个公开选项都要写完整 JSDoc,收益是调试与源码完全对应。
3.8 排错清单
| 现象 | 原因 | 修法 |
|---|---|---|
X: initialize() is not called in Leaflet 2 |
类里还留着 1.x 的 initialize() |
改名 constructor() 并在首行 super() |
Cannot set properties of undefined (setting 'icon') |
用 X.prototype.options.xxx = 改默认值 |
改 X.mergeOptions({...}) 或 X.defaultOptions.xxx = |
| 自定义图层移除后仍响应事件 / 报错 | onRemove 没解绑 map.on |
在 onRemove 里成对 map.off,或用 getEvents() 让框架代管 |
| 图层位置随平移漂移 | 定位用了 latLngToContainerPoint |
改用 latLngToLayerPoint |
点 marker 不触发地图 click |
marker 默认 bubblingPointerEvents: false |
显式设为 true,或在 marker 回调里调地图方法 |
| 事件监听器在回调里加了却没执行 | 同一轮 fire 内的新监听器不参与本次派发 |
不要在事件回调里”立刻依赖”新监听器的执行 |
3.9 本章小结
| 要点 | 结论 |
|---|---|
| 继承根 | Class → Evented → LayerBase → Layer;Control / Handler / Icon 直接继承 Class |
| 2.0 的树变化 | DivOverlay 改继承 LayerBase,弹窗与提示不再是 Layer |
| 默认值怎么改 | X.mergeOptions({...}) 或 X.defaultOptions.xxx;不能用 prototype.options |
| 监听器性能 | 同类型超过 32 个监听器自动建索引;beta.3 起 5 万 marker 从 15.6 s 降到 0.5 s |
| 图层契约 | beforeAdd → onAdd → onRemove,事件用 getEvents 代管可自动清理 |
| pane 层级 | tile 200 / overlay 400 / shadow 500 / marker 600 / tooltip 650 / popup 700 / controls 800 |
| 渲染器选择 | 默认 SVG;preferCanvas 已删除,改传 renderer: new Canvas() |