第01章 - Leaflet 概述与生态定位
Leaflet 是 2010 年 Volodymyr Agafonkin 写出的一个 JavaScript 地图库,十几年过去,它的 README 依然用同一句话定位自己:Leaflet is the leading open-source JavaScript library for building powerful interactive maps。这句话短,但把范围划得很死——它做的是”可交互地图”这一层,不做 GIS 平台,不做三维地球,也不做数据处理。
理解这个边界,比背 API 重要。因为 Leaflet 的大部分设计选择、以及与同类的取舍,都来自”只做地图交互”这个前提:零运行时依赖(package.json 里没有 dependencies 字段)、约 40 KB gzip 的 JS 加 2.4 KB gzip 的 CSS、BSD 2-Clause 许可证、源码按 src/{core,dom,geo,geometry,layer,map} 几个目录平铺,没有编译步骤。仓库 AGENTS.md 的原话是 Plain ES modules with JSDoc types; there is no compile step for src。
这一章解决三个问题:Leaflet 在 GIS 技术栈里站哪个位置;1.9.4 与 2.0 beta 该选哪个;以及为什么 2.0 的写法与你在网上抄到的 1.x 代码不兼容。
1.1 三条职责与三条边界
1.1.1 它实际负责的三件事
把 Leaflet 拆开看,它只做三件事,其余全部交给别的层。
第一件是经纬度到屏幕像素的换算。 链路是 LatLng → CRS.project() → 像素坐标 → 减去像素原点 → 给 DOM 元素设 transform。map.project()、map.latLngToLayerPoint()、map.getPixelOrigin() 这一组 API 就是这条链路的公开入口。
第二件是图层的生命周期。 任何图层都经历 beforeAdd → onAdd → onRemove 三个阶段,地图负责在合适的时机调用,并负责在缩放、平移时通知图层重算位置。
第三件是输入事件到地图语义的转换。 原始 pointerdown/pointermove 被翻译成 dragstart、zoom、click(带 latlng、containerPoint、layerPoint),再分发给地图、图层、控件。
地图瓦片从哪来、几何数据是什么格式、服务端用什么投影算面积——Leaflet 一概不问。它拿到 URL 模板就发 <img> 请求,拿到 GeoJSON 就转成 Path 对象,仅此而已。
1.1.2 它明确不做的四件事
下面四类需求经常被误派给 Leaflet,结果是要么写出一堆别扭代码,要么最后换库:
| 需求 | Leaflet 核心是否提供 | 现实做法 |
|---|---|---|
| 任意投影转换(如 CGCS2000 / UTM) | 否,内置只有 EPSG3857、EPSG4326、EPSG3395、SimpleCRS 等少数 CRS |
用 Proj4Leaflet,或在服务端出图 |
| 矢量瓦片渲染(MVT / PBF) | 否,核心只有栅格瓦片 GridLayer 体系 |
用 leaflet.vectorgrid 之类插件,或换 MapLibre GL JS |
| 三维 / 地形 / 倾斜 | 否 | Cesium |
| 空间分析(缓冲区、叠加、路径规划) | 否 | PostGIS、JTS/GEOS、turf.js,算完把结果当 GeoJSON 塞进来 |
这张表的意义在于决定架构:Leaflet 是展示层,计算和存储必须在它外面完成。把 PostGIS 的 ST_Buffer 结果当 GeoJSON 图层加载,比在浏览器里用 turf 循环算几万个点要快一到两个数量级,而且不用把原始数据全推到前端。
1.1.3 在整套 GIS 栈里的位置
一个典型的生产链路如下,Leaflet 只占最右边一格:
PostGIS / 文件数据
│ SQL / GDAL
▼
GeoServer / MapServer / TiTiler / 自研瓦片服务
│ WMS · WMTS · XYZ 瓦片 · GeoJSON API
▼
HTTP / CDN
│
▼
┌──────────────────────────────┐
│ 浏览器 │
│ ┌────────────────────────┐ │
│ │ Leaflet │ │
│ │ GridLayer (瓦片) │ │
│ │ Path / Marker (矢量) │ │
│ │ Popup / Tooltip │ │
│ └────────────────────────┘ │
└──────────────────────────────┘
这也解释了为什么第 6 章要专门讲 WMS——Leaflet 与 OGC 服务之间的接口只有 URL 参数这一层,参数拼错一个 version,服务端就会按另一套轴序返回图片,表现为”地图整体转了 90 度”。
1.1.4 一个单文件能做什么
先看结果,再谈原理。下面这个文件用 CDN + import map 加载 Leaflet 2.0,不经过任何构建工具:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@2.0.0-beta.3/dist/leaflet.css">
<style>
html, body { margin: 0; height: 100%; }
#map { height: 100%; }
</style>
<script type="importmap">
{"imports": {"leaflet": "https://cdn.jsdelivr.net/npm/leaflet@2.0.0-beta.3/dist/leaflet.js"}}
</script>
</head>
<body>
<div id="map"></div>
<script type="module">
import {LeafletMap, TileLayer, Marker, Circle} from 'leaflet';
const map = new LeafletMap('map').setView([39.9042, 116.4074], 11);
new TileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
new Marker([39.9042, 116.4074]).addTo(map)
.bindPopup('天安门').openPopup();
new Circle([39.95, 116.45], {radius: 3000, color: '#c00', fillOpacity: 0.15}).addTo(map);
</script>
</body>
</html>
注意三个细节,它们是 2.0 与 1.x 的分界:
- 模块脚本里用
import,不是<script src="leaflet.js">之后直接用全局L; - 用
new LeafletMap(...),不是L.map(...)。Map这个名字可以导入,但会遮蔽 JavaScript 内建的Map,所以官方推荐LeafletMap; <link>引入 CSS 不能省。少了它地图能出来,但瓦片拼接、控件样式全乱。
1.2 版本现状:1.9.4 与 2.0 beta
1.2.1 时间线
| 版本 | 发布时间 | 性质 |
|---|---|---|
| 1.0.0 | 2016-09-26 | 结束 0.7 系列,确立现在的 API 形态 |
| 1.9.0 | 2022-09-21 | 1.x 最后一个特性版本 |
| 1.9.4 | 2023-05-18 | 1.x 最后一版,官方文档标注为 the last 1.x release |
| 2.0.0-alpha | 2025-05-18 | 全面重写:ESM、工厂函数移除、Pointer Events |
| 2.0.0-beta.1 | 2026-11-02 | ES class 重写、自带 TypeScript 类型、可 tree-shake、内联 SVG 默认图标 |
| 2.0.0-beta.3 | 2026-11-10 | 链式视图调用可叠加、坐标校验、事件监听性能重写 |
一个容易踩的事实:现在 npm install leaflet 装到的仍然是 1.9.4。官方 docs/install.md 写得很直白:Leaflet 1.9.4 is the last 1.x release, and npm install leaflet installs it until 2.0.0 is out。要装 2.0 beta,命令是 npm install leaflet@beta。很多”照着官网示例写却报 L is not defined“的问题,源头就是装到了 1.9.4 却用了 2.0 的写法。
1.2.2 2.0 到底改了什么
官方迁移文档 MIGRATION.md 的开头一句概括得最准:Leaflet 2.0 is published as ES modules, uses classes instead of factory functions, and handles input with Pointer Events。展开成可操作的清单:
| 维度 | 1.9.4 | 2.0.0-beta.3 |
|---|---|---|
| 模块格式 | UMD,<script> 得到全局 L;也有 ESM 构建 |
只有 ESM(dist/leaflet.js);全局版单独发布为 dist/leaflet-global.js |
| 默认导出 | import L from 'leaflet' 可用 |
无默认导出,必须 import * as L 或具名导入 |
| 创建对象 | 工厂函数 L.marker()、L.map() |
new Marker()、new LeafletMap() |
| 类扩展 | L.Class.extend()、initialize() |
原生 class extends、constructor()、static defaultOptions |
| 事件模型 | mouse/touch 事件(mousemove 等) |
Pointer Events(pointermove 等) |
| 浏览器基线 | 兼容到 IE11 | Baseline 2022(Safari 15.4+),IE 与其他旧浏览器移除 |
| 打包体积 | 全量约 38 KB gzip | 含瓦片图层的地图 25.6 KB gzip(可 tree-shake) |
| TypeScript | 需要 @types/leaflet |
包内自带类型,且与 @types/leaflet 不兼容 |
| 默认图标 | 三个 PNG 文件(dist/images/) |
内联 SVG,25×46,可用 color 改色 |
| 深度导入 | leaflet/src/... 可用 |
只保留 leaflet/dist/leaflet.css 与 leaflet/styles.css |
其中对项目影响最大的是去掉工厂函数:2.0 里 L.marker() 这类小写函数全部消失,取而代之的是首字母大写的类名。曾经的嵌套类也被提到顶层,改名规则是”路径压平成名字”:
| 1.9.4 | 2.0 |
|---|---|
L.Control.Layers、L.control.layers |
LayersControl |
L.TileLayer.WMS、L.tileLayer.wms |
WMSTileLayer |
L.Icon.Default |
DefaultIcon |
L.CRS.EPSG3857 / L.CRS.Simple |
EPSG3857 / SimpleCRS |
L.Map.Drag 等 handler |
DragHandler 等 |
1.2.3 选版本的判断标准
不要按”新版本更好”选,按依赖链选:
| 场景 | 建议 | 理由 |
|---|---|---|
| 新项目、无历史插件包袱、现代浏览器 | 2.0 beta | 可 tree-shake、自带类型、默认图标问题已解决 |
| 依赖 markercluster、draw、heat 等成熟插件 | 先查插件是否声明支持 2.0 | 2.0 移除了 extend()/initialize(),老插件直接抛 not a function |
| 需要兼容旧浏览器 / 内网老 IE | 1.9.4 | 2.0 只支持 Baseline 2022 |
| 长期维护的政企内网系统 | 1.9.4 | beta 版本不应进生产;1.9.4 已稳定三年 |
| 严格 TypeScript 类型要求 | 2.0 beta | 1.x 依赖 DefinitelyTyped,类型结构与 2.0 不同 |
插件兼容性有官方标记:插件清单里声明了 compatible-v2: true 的,才会出现在官网 “Ready for Leaflet 2” 分类下。迁移文档还给了一个同时支持两版的写法(peerDependencies: "leaflet": "^1.9.0 || ^2.0.0-beta.1"),这意味着过渡期内插件作者可以两边都兼容——但对使用者来说,仍要逐个确认。
1.2.4 仓库长什么样
读源码是理解行为最快的路径。2.0 的源码结构非常扁平:
src/
├── Leaflet.js # 唯一的入口,只做 re-export 与 version 常量
├── leaflet.css # 全部样式(约 13 KB 未压缩)
├── types.ts # 手写的 TS 类型入口
├── core/ Class.js Events.js Handler.js Util.js
├── dom/ DomEvent.js DomUtil.js Draggable.js PosAnimation.js
├── geo/ LatLng.js LatLngBounds.js crs/ projection/
├── geometry/ Point.js Bounds.js LineUtil.js PolyUtil.js Transformation.js
├── layer/ Layer.js LayerBase.js LayerGroup.js FeatureGroup.js GeoJSON.js
│ DivOverlay.js Popup.js Tooltip.js BlanketOverlay.js
│ marker/ tile/ vector/
└── map/ Map.js handler/
两个值得记住的事实。第一,src/Leaflet.js 只有 3.7 KB,内容是 export const version = '2.0.0-beta.3' 加一长串 export {...} from './xxx.js'——它不包含任何实现,只负责把散落的类汇成一个公开入口。第二,源码就是最终产物,没有 Babel/TypeScript 编译步骤;npm run build 用 Rolldown 打包到 dist/,同时用 tsc 从 JSDoc 生成类型声明。这带来一个好处:浏览器调试器里看到的堆栈行号,与 GitHub 上的源码行号一致。
1.3 与同类的边界
Leaflet 与另外两个常被放在一起比较的库,差异是架构级的,不是功能多寡:
| Leaflet | MapLibre GL JS | OpenLayers | |
|---|---|---|---|
| 渲染方式 | DOM(<img> 瓦片 + SVG/Canvas 矢量) |
WebGL | Canvas 2D / WebGL(视构建) |
| 核心数据模型 | 栅格瓦片 + 矢量叠加层 | 矢量瓦片 + style 规范 | 通用 OGC 要素与图层 |
| 坐标系支持 | 内置 4 类 CRS,其余靠插件 | 主要是 Web Mercator | 内置多坐标系与重投影 |
| 学习成本 | 低,API 面小 | 中高,需理解 style/layer/source | 高,概念多 |
| 适合场景 | 业务系统内嵌地图、点位/区域展示、需要大量现成插件 | 矢量瓦片底图、样式化地图、高交互密度 | 多源 OGC 服务聚合、需要精确坐标系控制的专业 GIS |
选择时最实在的一个指标是数据形态:底图已经是 XYZ/WMTS 栅格瓦片、叠加层是几十到几万个点线面 → Leaflet 最省事;底图是 MVT 矢量瓦片、需要按 zoom 动态改样式 → MapLibre GL JS;需要在同一张图上叠 WMS、WFS、WMTS 且坐标系各不相同 → OpenLayers。这不是能力高低,是数据模型匹配度。
本站另有 MapLibre GL JS 教程、OpenLayers 教程 与 Cesium 教程,需要横向对比时可以直接对照阅读。
1.4 插件生态的规模与代价
Leaflet 官方插件清单收录了 470 余个插件(docs/_plugins/ 目录下每个 Markdown 文件对应一个条目,含一个模板文件),按 34 个类别组织,从底图源、聚类、编辑几何、热力图到打印导出都有。这既是 Leaflet 的优势,也是它最大的升级成本:核心不动,功能全靠插件,插件又深度依赖核心的内部结构。
2.0 把 L.Class.extend()、initialize()、L.Mixin.Events、Class.__super__ 全部移除,于是所有用 1.x 类系统写成的插件都需要改。官方在迁移文档里给了兼容两版的模板,并以两个插件作为范例:Leaflet.heat(短小完整)与 Leaflet.markercluster(体量大、还额外支持用 extend() 被子类化)。
判断一个插件能否用在 2.0 上,看三处:
- 有没有用
L.Class.extend/Control.extend—— 用了就要改; - 有没有
import L from 'leaflet'默认导入 —— 用了会直接报模块没有 default 导出; - 有没有依赖
dist/images/或Icon.Default的旧 workaround —— 2.0 里这两个都没了。
1.5 三种引入方式的最小示例
1.5.1 无构建:import map + CDN
即 1.1.4 的例子。要点在于 <script type="importmap"> 必须出现在任何 type="module" 脚本之前,浏览器解析 import 'leaflet' 时靠它解析裸模块名。生产环境应加上 SRI 校验,官方 docs/install.md 给了带 integrity 与 crossorigin 属性的完整片段。
1.5.2 有构建:npm + Vite
# 2.0 beta
npm install leaflet@beta
# 或锁定 1.9.4
npm install leaflet@1.9.4
import {LeafletMap, TileLayer} from 'leaflet';
import 'leaflet/styles.css';
const map = new LeafletMap('map').setView([31.2304, 121.4737], 12);
new TileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {maxZoom: 19}).addTo(map);
leaflet/styles.css 与 leaflet/dist/leaflet.css 指向同一个文件,前者是 2.0 新增的别名,写哪个都行。但 leaflet/src/... 这类深导入在 2.0 已被禁止,Vite 会报 Failed to resolve import。
1.5.3 只能用全局变量:leaflet-global.js
有些老系统不方便上模块,2.0 为此单独发布了 dist/leaflet-global.js:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@2.0.0-beta.3/dist/leaflet.css">
<script src="https://cdn.jsdelivr.net/npm/leaflet@2.0.0-beta.3/dist/leaflet-global.js"></script>
<script>
const map = new L.LeafletMap('map').setView([30.67, 104.06], 12);
new L.TileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {maxZoom: 19}).addTo(map);
new L.Marker([30.67, 104.06]).addTo(map);
</script>
它把模块导出的所有名字挂到 window.L 上,但不再支持 AMD、CommonJS 和 L.noConflict()。如果页面里同时有别的库占用 L,只能自己包一层 IIFE 隔离。
1.6 三个真实报错与修法
Uncaught SyntaxError: Unexpected token 'export'
用普通 <script src=".../leaflet.js"> 加载 2.0 的 ESM 构建。迁移文档第一句就是 dist/leaflet.js is now an ES module, so a plain <script> tag fails with a syntax error。修法:改 type="module" + import map,或换成 leaflet-global.js。
SyntaxError: The requested module 'leaflet' does not provide an export named 'default'
沿用了 1.x 的 import L from 'leaflet'。2.0 移除了默认导出。修法:import * as L from 'leaflet' 或改成具名导入。前者能保留 L.Marker 这类写法,迁移时改动最小。
TypeError: Cannot read properties of undefined (reading 'map') 或 L is not defined
页面同时加载了 1.9.4 的全局脚本与 2.0 的模块,或者只加载了模块但代码里写 L.map()。模块脚本不会定义全局 L,迁移文档明确写了这一点:The ES module doesn't define a global L。修法:统一到一种形态,二选一,不要混用。
1.7 生产环境的三条硬建议
锁定精确版本,不要用 leaflet@beta 上线。 @beta 是移动标签,beta.3 到正式版之间可能还有 beta.4;把 2.0.0-beta.3 写死,并配合 CDN 的 SRI 校验,才能保证半年后打开还是同一个文件。
瓦片服务与库版本解耦。 底图换成自建 GeoServer 或商业服务时,往往还要求固定的 maxNativeZoom、tms、subdomains。把这些参数写进配置对象而不是散在代码里,换服务商时只改一个 JSON。
插件清单在上线前跑一遍。 尤其是 import L from 'leaflet' 与 L.Class.extend 这两类模式,在 2.0 下 100% 出错,而且错误信息不一定指向插件本身。用构建工具的告警加上一次手工点检,比线上排查便宜得多。
1.8 本章小结
| 结论 | 依据 |
|---|---|
| Leaflet 只做”地图交互”这一层 | 零依赖、40 KB gzip、src/ 只有坐标/图层/事件三块 |
npm install leaflet 当前装到 1.9.4 |
docs/install.md 明示,2.0 需 leaflet@beta |
| 2.0 是破坏性重写 | ESM only、无默认导出、无工厂函数、Pointer Events、Baseline 2022 |
| 2.0 体积优势来自 tree-shaking | 含瓦片图层的地图 25.6 KB gzip,1.x 全量约 38 KB |
| 插件是升级的最大风险点 | extend()/initialize()/Class.__super__ 全被移除 |
| 选版本看依赖链,不看新旧 | 无包袱选 2.0 beta;有老插件或旧浏览器选 1.9.4 |