第18章 - 实战案例与工程化集成
18.1 2.0 改变了集成方式
把 Leaflet 接进工程,在 2.0 要重写 1.x 时代的三条常识:
- 它只发布 ES module。
dist/leaflet.js是 ESM,用普通<script src>加载会得到语法错误。要全局L得用dist/leaflet-global.js。 - 没有默认导出。
import L from 'leaflet'报错,必须import * as L from 'leaflet'或具名导入import {LeafletMap, TileLayer} from 'leaflet'。 - 没有工厂函数。
L.map()、L.marker()、L.geoJSON()全部移除,一律new。Map导出为LeafletMap(避免遮蔽 JS 内建的Map),但具名导入{Map}也可用。
18.2 三种接入形态
18.2.1 npm + 打包器
npm install leaflet@beta
// src/map.js
import {LeafletMap, TileLayer, Marker} from 'leaflet';
import 'leaflet/styles.css'; // 2.0 新增的样式别名
export function createMap(el, options = {}) {
const map = new LeafletMap(el, {center: [39.9042, 116.4074], zoom: 12, ...options});
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], {alt: '北京'}).addTo(map);
return map;
}
两点要注意:leaflet/styles.css 与 leaflet/dist/leaflet.css 都可用,除此之外的深层导入(leaflet/src/...)在 2.0 已不再解析。CSS 必须引入,否则地图高度、pane 定位、控件布局全部错乱,而 JS 不会报任何错。
配置了严格导出映射的打包器(Vite、Rollup、webpack 5)都能正常处理。2.0 已完全 tree-shakeable:只 import 地图和瓦片层的最小应用,官方给出的 gzip 体积是 25.6 kB(1.x 是 38 kB)。体积来自”按需引入”,别再 import * 只为少写几个名字。
18.2.2 CDN + import map
没有构建步骤时的标准写法:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@2.0.0-beta.3/dist/leaflet.css">
<script type="importmap">
{
"imports": {
"leaflet": "https://cdn.jsdelivr.net/npm/leaflet@2.0.0-beta.3/dist/leaflet.js"
}
}
</script>
<script type="module">
import {LeafletMap, TileLayer} from 'leaflet';
const map = new LeafletMap('map').setView([39.9042, 116.4074], 12);
new TileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {maxZoom: 19}).addTo(map);
</script>
模块脚本里的变量不是全局的。页面其它脚本要用这个 map,得自己挂到 window 上。生产环境建议保留官方文档里的 integrity 与 crossorigin 属性,防止 CDN 内容被替换。
18.2.3 全局脚本
只能用普通 <script> 的遗留页面:
<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([39.9042, 116.4074], 12);
</script>
leaflet-global.js 把全部导出挂到 L 上,但不支持 AMD/CommonJS,也没有 L.noConflict()。不要在同一页面混用 ESM 与全局两种加载,会出现两份 Leaflet,instanceof LatLng 之类的判断在两边失效,表现为“marker 加得进去但不显示”。
18.3 框架集成的生命周期
React、Vue、Svelte 里集成地图只有一条铁律:地图实例的生命周期必须与组件挂载/卸载严格对应。地图是命令式 DOM 对象,不受框架 diff 管理。
// React 风格的封装(核心逻辑与框架无关)
import {useEffect, useRef} from 'react';
import {LeafletMap, TileLayer} from 'leaflet';
import 'leaflet/styles.css';
export function MapView({center, zoom, onReady}) {
const containerRef = useRef(null);
const mapRef = useRef(null);
useEffect(() => {
// 1. 挂载:只创建一次
const map = new LeafletMap(containerRef.current, {center, zoom});
new TileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap contributors'
}).addTo(map);
mapRef.current = map;
onReady?.(map);
// 2. 卸载:必须 remove
return () => {
map.remove();
mapRef.current = null;
};
}, []); // 空依赖,创建一次
// 3. 属性变化:走命令式 API,不重建地图
useEffect(() => {
if (mapRef.current) { mapRef.current.setView(center, zoom); }
}, [center, zoom]);
return <div ref={containerRef} style={ {height: '100%'} } />;
}
三个高频错误:
- 依赖数组写成
[center, zoom],每次视图变化就new一个地图,旧地图没销毁,DOM 上留下Map container is already initialized.报错。 - 忘记
map.remove()。地图对象持有 DOM、ResizeObserver、全局指针监听,组件卸载后仍然存在,切路由几十次后内存明显上涨,并且监听器继续响应。 - 用
display: none隐藏地图。尺寸变 0,恢复时必须map.invalidateSize(),否则中心点与瓦片错位。React 里用 CSS 类切换显示/隐藏时尤其容易漏。
map.remove() 做的事情比想象中多:解绑容器的 DOM 事件(_initEvents(true))、断开 ResizeObserver、移除全部 panes 与控制容器、清空内部图层表。它不会替你清理业务代码里 map.on() 注册的监听器,所以封装组件时还要维护一个解绑清单。
18.4 多实例与容器
一个页面可以有多个地图,但每个容器只能被初始化一次。源码 _initContainer() 的两条错误信息值得记住:
Map container not found.
Map container is already initialized.
第一条是选择器没命中或脚本执行早于 DOM,第二条是重复初始化。容器用 _leaflet_id 标记占用,map.remove() 之后才能重新初始化。
多地图共享同一份数据时,注意事件监听不要互相污染:
const maps = [createMap('map-a'), createMap('map-b')];
// 同步两个地图的视图
let syncing = false;
for (const a of maps) {
for (const b of maps) {
if (a === b) { continue; }
a.on('moveend', () => {
if (syncing) { return; }
syncing = true;
b.setView(a.getCenter(), a.getZoom(), {animate: false});
syncing = false;
});
}
}
同步标志位是必需的,否则 A 触发 B、B 又触发 A,形成无限循环。更省事的做法是监听 move 但只在 syncIdle 后生效,或直接用 leaflet-sync 类插件。
18.5 TypeScript
2.0 自带类型,安装后要卸载 @types/leaflet,否则两份类型打架。
npm uninstall @types/leaflet
类型层面的主要差异:
1.x(@types/leaflet) |
2.0(自带) |
|---|---|
Control.LayersOptions |
LayersControlOptions |
TileLayer.WMSOptions |
WMSTileLayerOptions |
LeafletMouseEvent |
LeafletPointerEvent |
LeafletEventHandlerFnMap |
每个类有独立事件表,on() 自动推断 |
GeoJSON<MyProps> |
泛型是 options 与 events,回调参数自行标注 |
e.layer(类型里) |
e.propagatedFrom |
插件类型通常针对 @types/leaflet 编写,在 2.0 下会编译失败。临时方案是项目级 declare module 'leaflet' 增强,或对插件导入加 // @ts-expect-error 并集中隔离在一个文件里。
官方的插件类型增强写法:
declare module 'leaflet' {
interface Map {
editTools?: EditTools;
}
}
18.6 安全:弹窗里的 HTML
bindPopup(string) 会把字符串当 HTML 解析。属性数据来自后端或用户输入时,这是 XSS 入口。2.0 的类型注释明确写了这一点,给出两条安全路径:
import {Marker, DomUtil} from 'leaflet';
// 不安全:内容被当作 HTML
// marker.bindPopup(`<b>${userInput}</b>`);
// 安全 1:用 textContent 构造 DOM
function popupContent(value) {
const el = DomUtil.create('div');
const b = DomUtil.create('b', '', el);
b.textContent = value;
return el;
}
// 安全 2:先转义
const escapeHtml = s => s.replace(/[&<>"']/g, c => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
}[c]));
marker.bindPopup(`<b>${escapeHtml(userInput)}</b>`);
瓦片 URL 里的 token 也不要写进 attribution 或可见 DOM。tileLayer.setUrl() 会重构瓦片,切换底图服务时记得更新 attribution,否则会残留上一个服务商的版权声明(2.0.0-beta.1 修复过这个问题,1.x 项目要注意)。
18.7 部署与缓存
- CSS 与 JS 必须同版本。2.0 的 CSS 类名与 DOM 结构有变化(图层面板从
<section>改为<fieldset>),旧 CSS 配新 JS 会出现控件错位。 - CDN 用固定版本号,不要用
latest。beta 期间2.0.0-beta.1 → beta.3就改过事件行为。 - 瓦片服务走自己的域名或代理。直接用第三方公共瓦片在流量上来后会被限流甚至封禁,OSM 的使用政策对生产环境有明确要求。
- 开启 gzip/brotli。Leaflet gzip 后 25.6 kB,不压缩的传输体积约 3 倍。
- 地理定位、全屏、剪贴板等 API 要求安全上下文,HTTPS 是硬前提,本地开发用
localhost例外。
18.8 测试策略
地图的测试分三层,投入产出比从高到低:
- 纯函数单测:坐标转换、样式函数、数据过滤。这些不依赖 DOM,用 Vitest/Jest 直接测。
- 组件测试:在 jsdom 里挂载容器,断言
map.remove()被调用、图层数量正确。注意 jsdom 没有布局,getBoundingClientRect()恒返回 0,依赖尺寸的逻辑要 mock。 - 端到端:Playwright 里加载真实页面,拖动、缩放、点击 marker,断言弹窗出现与 URL 变化。Leaflet 自己的测试就是从 Karma 迁到了 Vitest + 浏览器模式。
一个容易漏的断言:map.remove() 之后,容器上不应再有 _leaflet_id,否则同一容器无法再次初始化。这正好能抓住“忘记销毁”的组件泄漏。
18.9 完整示例:可销毁的地图模块
把本章要点拼成一个可直接放进项目的模块:
import {LeafletMap, TileLayer, GeoJSON, LayersControl, ScaleControl, Canvas} from 'leaflet';
import 'leaflet/styles.css';
export function createAppMap(container, {center = [39.9042, 116.4074], zoom = 11, canvas = true} = {}) {
if (!container) { throw new Error('createAppMap: container is required'); }
const map = new LeafletMap(container, {
center,
zoom,
zoomSnap: 0.5,
renderer: canvas ? new Canvas({padding: 0.1}) : undefined
});
const base = 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 ScaleControl({metric: true, imperial: false, updateWhenIdle: true}).addTo(map);
const overlays = {};
const layers = new LayersControl({OSM: base}, overlays, {position: 'topright'}).addTo(map);
function setGeoJSON(featureCollection) {
for (const layer of Object.values(overlays)) {
layers.removeLayer(layer);
map.removeLayer(layer);
}
const gj = new GeoJSON(featureCollection, {
style: {weight: 1, color: '#3388ff', fillOpacity: 0.25}
}).addTo(map);
overlays['业务数据'] = gj;
layers.addOverlay(gj, '业务数据');
return gj;
}
function destroy() {
// 先摘业务引用,再销毁地图
for (const layer of Object.values(overlays)) { layers.removeLayer(layer); }
map.remove();
}
return {map, setGeoJSON, destroy};
}
使用与销毁:
const app = createAppMap(document.getElementById('map'));
const gj = app.setGeoJSON(featureCollection);
app.map.fitBounds(gj.getBounds());
// 路由离开时
app.destroy();
18.10 排错
Uncaught SyntaxError: Unexpected token 'export'。 用普通 <script> 加载了 ESM 版的 leaflet.js。改成 type="module" + import map,或换 leaflet-global.js。
The requested module 'leaflet' does not provide an export named 'default'。 写了 import L from 'leaflet'。改成 import * as L 或具名导入。
L.map is not a function。 工厂函数在 2.0 已移除,map 也不在全局上。用 new LeafletMap(...)。
地图容器高度为 0,只看到一片空白。 容器没有确定高度。Leaflet 不会自动给容器设高度,必须有 height(100% 需要父级也有高度)。
控件样式错乱、图层面板没有边框。 CSS 没引入或版本不匹配。import 'leaflet/styles.css',并确认 CSS 与 JS 是同一版本。
Map container is already initialized。 同一容器被初始化两次,或上一次的 map.remove() 没执行。检查框架组件的清理函数。
升级后 marker-icon.png 404。 2.0 的默认图标是内联 SVG,images/ 目录已不存在。1.x 时代那段 delete L.Icon.Default.prototype._getIconUrl 的修复代码要整段删掉,留着反而会破坏默认图标。
TypeScript 报 Property 'options' does not exist on type ...。 仍装着 @types/leaflet。卸载后重启 tsserver。
插件报 Cannot read properties of undefined (reading 'extend')。 插件是 1.x 写法。参考第 14 章的兼容性判断,降级 Leaflet 或换插件。
18.11 升级检查清单
从 1.x 迁到 2.0,按这个顺序改,报错最少:
- 加载方式:
<script>→ import map / 打包器;删掉全局L依赖。 L.xxx()工厂 →new Xxx(),L.map→new LeafletMap。- 嵌套类名 → 扁平名:
L.Control.Zoom→ZoomControl,L.CRS.EPSG3857→EPSG3857,L.TileLayer.WMS→WMSTileLayer。 - 事件名
mouse*→pointer*,keypress→keydown。 X.prototype.options→X.mergeOptions({...})。- 删掉默认图标修复代码与
@types/leaflet。 - 检查
preferCanvas→renderer: new Canvas()。 - 检查
circle(latlng, radius)→new Circle(latlng, {radius}),第二个参数传半径现在会抛错。 - 全量回归触屏与键盘操作,这两块的默认行为变化最多。
18.12 本章小结
| 要点 | 结论 |
|---|---|
| 包形态 | ESM only,无默认导出,无工厂函数,Map 导出为 LeafletMap |
| 引入方式 | 打包器 / import map / leaflet-global.js 三选一,不可混用 |
| 样式 | leaflet/styles.css 或 leaflet/dist/leaflet.css,必须引 |
| 体积 | tree-shaking 后最小应用 gzip 约 25.6 kB |
| 生命周期 | 挂载创建一次、卸载 map.remove(),属性变化走命令式 API |
| 多实例 | 容器只能初始化一次;remove() 后才可复用 |
| 类型 | 卸载 @types/leaflet,类型名为扁平形式 |
| 安全 | bindPopup 字符串按 HTML 解析,用户内容必须转义或传 DOM |
| 测试 | 纯函数单测 + 组件挂载/销毁断言 + Playwright E2E |
| 升级 | 先改加载方式与工厂函数,再处理事件名与 options,最后回归触屏与键盘 |