第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()