第15章 - 插件开发与扩展机制

15.1 扩展点只有四类

Leaflet 的扩展模型非常收敛。翻完 src 会发现,能被外接的入口只有四类:

扩展方式 继承/调用 典型产物
新图层 class X extends Layer 自定义渲染层、数据源图层
新控件 class X extends Control 工具栏、图例、面板
新交互 class X extends Handler 自定义手势、键盘命令
给现有类打补丁 include() / mergeOptions() / addInitHook() 给 Polyline 加编辑能力、给 Marker 加默认值

2.0 与 1.x 在这四类上的差别不在“能做什么”,而在“怎么写”:L.Class.extend() 和 initialize() 被移除,全部改成原生 ES class;默认值从 prototype.options 挪到静态 defaultOptions;L.Control.extend({...}) 改成 class X extends Control。

15.2 Class 提供的三件工具

几乎所有 Leaflet 类都继承 Class,它只做三件事:默认选项、mixins、构造钩子。源码 src/core/Class.js。

15.2.1 默认选项与 mergeOptions

import {Class, Util} from 'leaflet';

class MyBox extends Class {
	static defaultOptions = {width: 1, height: 1};

	constructor(name, options) {
		super();
		Util.setOptions(this, options);
		this.name = name;
	}
}

class MyCube extends MyBox {
	static defaultOptions = {depth: 1};
}

const cube = new MyCube('red', {width: 10});
cube.options.width;   // 10(构造参数)
cube.options.height;  // 1(父类默认)
cube.options.depth;   // 1(子类默认)

默认值是“链式继承”的:子类的 defaultOptions 以父类的为原型(源码在 getDefaultOptions() 里用 Object.setPrototypeOf 链接),所以子类只需声明新增项。

改现有类的默认值用 mergeOptions():

import {Marker} from 'leaflet';

Marker.mergeOptions({riseOnHover: true});

1.x 的 Marker.prototype.options.icon = ... 在 2.0 会抛 Cannot set properties of undefined,因为 prototype.options 已经不存在。这是迁移插件时最先撞上的一堵墙。

15.2.2 include:加方法,不建子类

import {Polyline} from 'leaflet';

Polyline.include({
	getPointCount() {
		return this.getLatLngs().length;
	}
});

new Polyline([[39.9, 116.3], [39.9, 116.5]]).getPointCount(); // 2

include() 会把属性直接挂到 prototype 上(跳过 constructor 和 options),如果传入的对象带 options,会转调 mergeOptions。它对所有实例生效,包括已经创建的实例——这点和 addInitHook 不同。

15.2.3 addInitHook:在别人的构造函数里插一段

import {Polyline} from 'leaflet';

Polyline.addInitHook(function () {
	this._createdAt = Date.now();
});

规则有两条,容易记错:

  • 钩子对该类及其所有子类生效,每个实例只跑一次,父类的钩子先跑。
  • 对 Leaflet 自己的类,钩子在构造函数全部结束之后运行,所以在 Layer 上加钩子能拿到完整的 Polygon。对你自己继承 Leaflet 的类,钩子跑在你的构造函数体之前。如果钩子需要你构造完的字段,给类加 static initHooks = true,并在构造函数末尾调 this.callInitHooks():
class MyLayer extends Layer {
	static initHooks = true;

	constructor(options) {
		super();
		Util.setOptions(this, options);
		this._items = [];
		this.callInitHooks();
	}
}

源码里 Class 的构造函数第一件事就是检查 this.initialize,存在就抛错:

Polyline: initialize() is not called in Leaflet 2. Rename it to constructor() and call super() at its start.

看到这条错误说明插件是 1.x 写法,不需要调试,直接改代码。

15.3 写一个自定义图层

图层的本质是“跟着地图移动的 DOM 或绘制指令”。最小骨架要实现 onAdd(map) 和 onRemove(map),需要跟随视图变化时再实现 getEvents()。

import {Layer, DomUtil, DomEvent} from 'leaflet';

class CornerLabel extends Layer {
	static defaultOptions = {text: '', className: 'corner-label'};

	onAdd(map) {
		const div = this._container = DomUtil.create('div', this.options.className);
		div.textContent = this.options.text;
		map.getPane('overlayPane').append(div);
		this._update();
		return this;
	}

	onRemove() {
		this._container.remove();
		this._container = null;
	}

	getEvents() {
		return {zoomend: this._update, moveend: this._update};
	}

	_update() {
		// 固定在容器左上角 10px 处
		DomUtil.setPosition(this._container, this._map.containerPointToLayerPoint([10, 10]));
	}
}

new CornerLabel({text: '数据更新于 2026-11'}).addTo(map);

几个必须遵守的约定:

  1. onAdd 里不要用 this._map 之前的引用。图层构造时还不知道地图,只有 onAdd 被调用时 this._map 才有值。
  2. getEvents() 返回的监听会自动绑定到地图。Layer 基类在加入地图时用 map.on(this.getEvents(), this),移除时 off,不需要手写清理。
  3. DOM 要挂到 pane 上。默认 pane 是 'overlayPane',可用 options.pane 换到 markerPane、tooltipPane 等。直接 append 到容器会破坏层级与缩放动画。
  4. 位置用 DomUtil.setPosition。2.0 把位置存在内部 WeakMap 里,不再写 _leaflet_pos 属性;读回用 DomUtil.getPosition(el)。

需要跟缩放动画同步时,用 zoomanim 事件做变换,而不是在 zoomend 里跳变:

getEvents() {
	return {zoomanim: this._animateZoom, viewreset: this._update};
}

_animateZoom(e) {
	const scale = this._map.getZoomScale(e.zoom);
	const offset = this._map._latLngToNewLayerPoint(this._latlng, e.zoom, e.center);
	DomUtil.setTransform(this._container, offset, scale);
}

setTransform(el, offset, scale) 是 2.0 的公开工具,内部拼 translate3d + scale。

15.4 写一个自定义控件

控件比图层简单:onAdd(map) 返回一个 HTMLElement,框架负责插入四角容器。

import {Control, DomUtil, DomEvent} from 'leaflet';

class CounterControl extends Control {
	static defaultOptions = {position: 'bottomright'};

	constructor(options) {
		super(options);
		this._count = 0;
	}

	onAdd(map) {
		const box = DomUtil.create('div', 'leaflet-bar counter');
		const btn = DomUtil.create('a', '', box);
		btn.href = '#';
		btn.setAttribute('role', 'button');
		btn.setAttribute('aria-label', '记录一次点击');
		btn.textContent = '0';

		DomEvent.disableClickPropagation(box);
		DomEvent.on(btn, 'click', e => {
			e.preventDefault();
			btn.textContent = String(++this._count);
		});

		this._btn = btn;
		return box;
	}

	onRemove() {
		this._btn = null;
	}
}

new CounterControl().addTo(map);

要点:onAdd 必须 return 元素;交互元素要 disableClickPropagation,否则点击会传给地图;图标按钮必须有 aria-label。

15.5 写一个自定义交互处理器

Handler 是“看不见的交互逻辑”,只有两个钩子:

import {Handler, DomEvent, Point} from 'leaflet';

class TiltPanHandler extends Handler {
	addHooks() {
		DomEvent.on(window, 'deviceorientation', this._tilt, this);
	}

	removeHooks() {
		DomEvent.off(window, 'deviceorientation', this._tilt, this);
	}

	_tilt(e) {
		if (e.gamma == null || e.beta == null) { return; }
		// 1 度倾斜 ≈ 3 像素平移
		this._map.panBy(new Point(-e.gamma * 3, -e.beta * 3), {animate: false});
	}
}

// 挂到所有地图实例上,并成为 map.tilt
import {LeafletMap} from 'leaflet';
LeafletMap.addInitHook('addHandler', 'tilt', TiltPanHandler);

// 创建时自动启用
new LeafletMap('map', {tilt: true}).setView([39.9, 116.4], 12);

// 运行时开关
map.tilt.disable();
map.tilt.enabled(); // false

map.addHandler(name, HandlerClass) 会给实例挂上处理器,map[name].enable() / disable() / enabled() 由基类提供。构造选项里同名布尔值决定是否默认开启——这是所有内置交互(dragging、keyboard、scrollWheelZoom…)的实现方式。

15.6 扩展 GridLayer 与 TileLayer

瓦片类留了两个明确的扩展方法。

GridLayer.createTile(coords, done?) 返回任意 HTMLElement,异步时调 done(error, tile):

import {GridLayer, DomUtil} from 'leaflet';

class CoordinateGridLayer extends GridLayer {
	createTile(coords) {
		const tile = DomUtil.create('div', 'leaflet-tile');
		const size = this.getTileSize();
		tile.style.width = size.x + 'px';
		tile.style.height = size.y + 'px';
		tile.style.outline = '1px solid #ccc';
		tile.textContent = [coords.x, coords.y, coords.z].join(', ');
		return tile;
	}
}

new CoordinateGridLayer({tileSize: 256}).addTo(map);

TileLayer.getTileUrl(coords) 决定每张瓦片的 URL,2.0 起 coords.z 就是实际请求的缩放级别(1.x 读的是内部 _tileZoom,自定义级别会错):

import {TileLayer} from 'leaflet';

class TimestampTileLayer extends TileLayer {
	getTileUrl(coords) {
		const base = super.getTileUrl(coords);
		return `${base}&t=${this.options.timestamp}`;
	}
}

new TimestampTileLayer('https://tile.example.com/{z}/{x}/{y}.png', {
	timestamp: 1735689600,
	maxZoom: 18
}).addTo(map);

15.7 给现有类加每实例能力:三种写法

插件常需要“每个图层实例配一个编辑器”。三种实现方式各有代价:

写法 时机 代价
addInitHook 每次构造都执行 没用到的实例也付出成本;早于插件加载创建的实例没有
include 加方法 全局一次性 只适合方法,不适合每实例状态
懒加载 getter 首次访问时创建 对未使用的实例零成本,兼容旧实例

官方插件指南推荐第三种:

import {Polyline} from 'leaflet';

const editors = new WeakMap();

Object.defineProperty(Polyline.prototype, 'editor', {
	configurable: true,
	get() {
		let editor = editors.get(this);
		if (!editor) {
			editor = new PolylineEditor(this);
			editors.set(this, editor);
		}
		return editor;
	}
});

用 WeakMap 存实例数据,图层被释放时编辑器跟着回收,不会泄漏。注意这是“导入即产生副作用”的模块,必须在 package.json 的 sideEffects 里声明,否则打包器可能整段删掉。

15.8 同时支持 1.9 与 2.0

官方迁移指南给了一套可复制的做法,核心是“用两版都存在的 API”:

import * as L from 'leaflet';

class MyLayer extends L.Layer {
	constructor(options) {
		super();
		L.Util.setOptions(this, options);
	}
}

// 1.x 的默认值在 prototype.options 上,先复制一份再加,避免污染所有 Layer
if (L.version.startsWith('1.')) {
	MyLayer.prototype.options = Object.create(L.Layer.prototype.options);
}
MyLayer.mergeOptions({opacity: 0.5});

六条要点:

  1. 用 class X extends L.Layer + constructor(),不要用 extend / initialize。
  2. 默认值用 X.mergeOptions({...});2.0 的 static defaultOptions 在 1.x 不生效。
  3. 被改名的类通过命名空间兜底:L.ZoomControl ?? L.Control.Zoom。直接静态导入扁平名会在 1.x 报错。
  4. 一律用 new,不用工厂函数。
  5. 事件名按版本选:L.version.startsWith('1.') 决定用 mouseover 还是 pointerover,不要两个都绑。
  6. 读默认值用 L.Marker.defaultOptions ?? L.Marker.prototype.options。

peerDependencies 写 "leaflet": "^1.9.0 || ^2.0.0-beta.1"。注意 ^2.0.0 不匹配预发布版本,beta 期这样写在 npm 侧会被拒绝安装。

发布时同时提供模块与全局脚本两种构建:

// rolldown.config.js
export default [
	{input: './src/MyLayer.js', external: ['leaflet'],
	 output: {file: './dist/leaflet-my-layer.js'}},
	{
		input: './build/global.js',
		external: ['leaflet'],
		output: {file: './dist/leaflet-my-layer-global.js', format: 'iife', globals: {leaflet: 'L'}}
	}
];

build/global.js 只做一件事:window.L.MyLayer = MyLayer;。同一页面不要混用两种加载方式,否则会出现两份 Leaflet,instanceof 判断全部失效。

15.9 类型扩展

2.0 自带类型,插件通过模块增强补声明:

declare module 'leaflet' {
	interface Map {
		editTools?: EditTools;
	}
	interface MapEventMap {
		'edit:start': LeafletEvent;
	}
}

两条硬规则:增强 Map 而不是 LeafletMap(两者都增强会丢掉一个);控件或图层在 getEvents() 里自加的地图事件要写进 MapEventMap,否则 map.on('edit:start') 类型报错。

15.10 排错

X.mergeOptions is not a function。 类没有继承 Leaflet 的 Class 体系,或者从 1.x 的 L.Class.extend() 产物上调用。改成 class X extends L.Layer 后即可用。

插件加载了但完全没效果。 多半是打包器把“只产生副作用”的模块 tree-shake 了。检查 package.json 的 sideEffects 是否列了调 include() / mergeOptions() / prototype getter 的文件。

initialize() is not called in Leaflet 2。 构造了一个带 initialize 方法的 1.x 类。删掉 initialize,把逻辑搬进 constructor,L.setOptions(this, options) 换成 L.Util.setOptions(this, options)。

自定义图层在缩放后错位。 只监听了 zoomend,缩放动画期间没有变换。补 zoomanim 处理,或用 DomUtil.setTransform 在动画帧里更新。

钩子拿不到构造函数里设置的值。 2.0 的钩子对“自己继承 Leaflet 的类”跑在构造体之前。加 static initHooks = true 并在末尾 this.callInitHooks()。

1.x 下插件报 Cannot read properties of undefined (reading 'options')。 你在 1.x 上直接 mergeOptions,但类没有自己的 prototype.options。先 Object.create(L.Layer.prototype.options) 再合并。

15.11 发布清单

  • 命名 leaflet-xxx,仓库独立,README 写清支持的 Leaflet 版本、依赖、demo 链接、API 与事件。
  • 具名导出类,type: "module",不设全局变量;全局构建单独产出。
  • peerDependencies 而不是 dependencies 引入 leaflet。
  • sideEffects 精确声明:纯模块写 false,打补丁的写文件列表。
  • 类型随包发布,并在 declare module 'leaflet' 里补增强。
  • 提交到官方插件目录时,兼容 2.0 的条目加 compatible-v2: true,用户才能筛到。
  • 测试矩阵至少覆盖 Leaflet 1.9 与 2.0 beta 各一遍,用 "leaflet-v1": "npm:leaflet@^1.9.4" 做别名安装。

15.12 本章小结

要点 结论
四类扩展 Layer、Control、Handler、给现有类打补丁
写法变更 extend()/initialize() 移除,改 ES class + constructor()
默认值 静态 defaultOptions 链式继承;改现有类用 mergeOptions()
钩子 addInitHook 对子类生效,每实例一次;自己类需 static initHooks = true + callInitHooks()
图层 实现 onAdd/onRemove,getEvents() 自动绑定地图事件
控件 onAdd 返回元素,交互元素需 disableClickPropagation + aria-label
交互 Handler 只需 addHooks/removeHooks,经 addHandler 挂到地图
瓦片 createTile(coords, done) 造元素,getTileUrl(coords) 用 coords.z
双版本 ^1.9.0 || ^2.0.0-beta.1,命名空间兜底,事件名按版本选
发布 具名导出、peerDependencies、sideEffects、模块 + 全局双构建