第02章 - 环境搭建与快速开始
Leaflet 1.x 时代,”环境搭建”这件事基本等于复制两行 CDN 标签:一个 <link> 引 CSS,一个 <script> 引 JS,然后就能用全局 L 了。2.0 把这条路堵死了——dist/leaflet.js 变成了 ES 模块,普通 <script> 标签加载它会直接抛语法错误。
所以这一章不是走流程的”安装说明”。它要回答的是:你的项目该用哪种形态引入 Leaflet,以及每种形态下第一个能跑起来的地图长什么样。三种形态——无构建的 import map、npm 加打包器、以及给老系统用的全局脚本——各自的坑完全不同,选错了会在后面每一章反复付出代价。
2.1 先决定形态,再动手
| 形态 | 适用场景 | 优点 | 限制 |
|---|---|---|---|
| CDN + import map | 演示页、原型、无法改构建链的 CMS 内嵌页 | 零构建,改完刷新即见 | 每个依赖都要写进 import map;离线不可用 |
| npm + Vite/Webpack/Rspack | 正式前端项目 | 可 tree-shake(含瓦片的地图 25.6 KB gzip)、类型完整、依赖可锁定 | 需要 Node 环境与构建流程 |
leaflet-global.js |
老系统、jQuery 时代页面、只允许改一个 HTML 文件 | 一个 <script> 就有全局 L |
无 tree-shaking,全量加载;不再支持 AMD/CommonJS |
自托管 leaflet.zip |
内网、涉密环境、不能访问公网 CDN | 完全离线可控 | 需要自己维护版本升级 |
一个常见的误判是”先用 CDN 跑通,以后再换打包器”。这件事在 2.0 里代价不小:CDN 写法用裸模块名 import ... from 'leaflet',靠 import map 解析;换到打包器后 import map 失效,所有裸模块名要改由 package.json 解析。如果你知道项目最终会上构建,第一天就上构建。
2.2 无构建方案:import map
2.2.1 完整可运行的单文件
把下面内容存成 index.html,用浏览器直接打开就能看到地图:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Leaflet 2.0 最小示例</title>
<!-- 1. 样式:少了它地图能出来,但瓦片边缘、控件、弹窗全错位 -->
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/leaflet@2.0.0-beta.3/dist/leaflet.css">
<!-- 2. import map:必须出现在所有 type="module" 脚本之前 -->
<script type="importmap">
{
"imports": {
"leaflet": "https://cdn.jsdelivr.net/npm/leaflet@2.0.0-beta.3/dist/leaflet.js"
}
}
</script>
<style>
html, body { margin: 0; height: 100%; }
/* 地图容器必须有高度,否则高度为 0,什么都看不见 */
#map { height: 100%; }
</style>
</head>
<body>
<div id="map"></div>
<!-- 3. 业务代码:module 脚本默认 defer,执行时 #map 一定已存在 -->
<script type="module">
import {LeafletMap, TileLayer} from 'leaflet';
const map = new LeafletMap('map', {
center: [31.2304, 121.4737], // 上海
zoom: 12
});
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);
</script>
</body>
</html>
这段代码里有四个必须先讲清楚的点。
第一,<link> 不能省。 Leaflet 的定位、层级、拖拽手感、控件外观全部由 leaflet.css 提供。省掉它,地图会”能出图但不对劲”:瓦片之间出现缝隙,marker 挤在左上角,弹窗没有箭头。这类问题最难查,因为控制台没有任何报错。
第二,importmap 的位置有硬约束。 浏览器在解析模块说明符(裸名字 'leaflet')时会查找当前文档的 import map;如果 module 脚本先执行、import map 后出现,浏览器直接报 Failed to resolve module specifier "leaflet"。把它放在 <head> 里最稳。
第三,容器必须有高度。 这是 Leaflet 新手最高频的问题。<div id="map"> 默认高度是内容高度,内容为空就是 0px。地图的逻辑尺寸取容器的 clientWidth/clientHeight,0×0 的容器算出来的像素范围是空的,一张瓦片都不会请求。
第四,module 脚本天然延迟执行。 type="module" 等价于加了 defer,所以不用担心 #map 还没解析就执行。但这也意味着 import 语句不能写在普通 <script> 里——那是很多”import 报错”的实际原因。
2.2.2 生产环境要加 SRI
CDN 脚本有被篡改的风险,生产页面应带 Subresource Integrity 校验:
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/leaflet@2.0.0-beta.3/dist/leaflet.css"
integrity="sha256-(此处填官方 install 页给出的哈希)"
crossorigin="anonymous">
<script type="importmap">
{
"imports": {
"leaflet": "https://cdn.jsdelivr.net/npm/leaflet@2.0.0-beta.3/dist/leaflet.js"
},
"integrity": {
"https://cdn.jsdelivr.net/npm/leaflet@2.0.0-beta.3/dist/leaflet.js": "sha256-(官方哈希)"
}
}
</script>
import map 里的 integrity 字段是浏览器较新的能力;不支持的浏览器会忽略它,但不会报错。哈希值不要自己算也不要抄博客,以官方 install 页为准——每次 beta 发版哈希都会变。
2.2.3 为什么不能双击打开 HTML
单文件页面可以 file:// 打开,但只要你的模块代码被拆成外部文件:
<script type="module" src="./app.js"></script>
浏览器会以 file:// 源发起模块请求,被 CORS 规则拦截,控制台报:
Access to script at 'file:///D:/demo/app.js' from origin 'null' has been blocked by CORS policy:
Cross origin requests are only supported for protocol schemes: http, https, chrome-extension.
修法是起一个本地 HTTP 服务:
# 方式一:Node 生态
npx serve .
# Serving! Local: http://localhost:3000
# 方式二:Python 3
python -m http.server 8000
# Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
2.3 有构建方案:npm + Vite
2.3.1 装哪个版本
# 2.0 beta(会装到 2.0.0-beta.3 这一类预发布版)
npm install leaflet@beta
# 1.9.4:1.x 最后一版
npm install leaflet@1.9.4
# 看实际装了什么
npm ls leaflet
关键提醒:不带标签的 npm install leaflet 现在装的仍是 1.9.4。官方 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 的示例代码(new LeafletMap(...))却装到了 1.9.4,运行时会得到 LeafletMap is not exported 之类的错误。
2.3.2 最小项目结构
my-map/
├── index.html
├── package.json
├── vite.config.js
└── src/
├── main.js # 入口:创建地图、挂图层
└── map-config.js # 底图配置:URL 模板、attribution、maxZoom
npm create vite@latest my-map -- --template vanilla
cd my-map
npm install leaflet@beta
npm run dev
2.3.3 入口代码
// src/main.js
import {LeafletMap, TileLayer, Marker, Popup} from 'leaflet';
import 'leaflet/styles.css'; // 2.0 新增的别名,等价于 leaflet/dist/leaflet.css
import {BASE_LAYERS} from './map-config.js';
const map = new LeafletMap('map', {
center: [39.9042, 116.4074],
zoom: 11,
zoomControl: true
});
for (const [name, layer] of Object.entries(BASE_LAYERS)) {
layer.addTo(map);
break; // 只加第一个当底图
}
new Marker([39.9042, 116.4074])
.addTo(map)
.bindPopup(new Popup({maxWidth: 260}).setContent('<b>北京</b><br>116.4074°E, 39.9042°N'));
// src/map-config.js
import {TileLayer} from 'leaflet';
const osmAttribution = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
export const BASE_LAYERS = {
osm: new TileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: osmAttribution
}),
// 示例:换服务商只改这一处
gaode: new TileLayer('https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}', {
subdomains: ['1', '2', '3', '4'],
maxZoom: 18,
attribution: '© 高德地图'
})
};
把底图配置单独抽出来,是在项目中后期回报最高的一件小事:换底图、加鉴权 token、改 maxNativeZoom,都只动一个文件,不用在组件树里翻。
2.3.4 构建产物核对
npm run build
ls -lh dist/assets/*.js
官方在 2.0.0-beta.1 的更新日志里给了量化对比:全量 1.x 约 38 KB gzip;2.0 因为可 tree-shake,”一张带瓦片图层的地图”打进包里是 25.6 KB gzip。如果你的产物体积接近 1.x 全量,通常是没有按需导入——比如 import * as L from 'leaflet' 虽然能跑,但它会让打包器保留所有导出,tree-shaking 效果打折。正式项目里优先用具名导入。
2.4 TypeScript 配置
2.4.1 先卸载旧类型包
npm uninstall @types/leaflet
2.0 从源码生成并随包发布类型(package.json 的 types 字段指向 dist/types/index.d.ts)。@types/leaflet 是按 1.x 写的,两套类型叠在一起会互相冲突,典型症状是 Type 'Map' is not assignable to type 'Map' 这种看起来自相矛盾的报错。
2.4.2 tsconfig 的关键项
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src"]
}
moduleResolution: "bundler" 让 TypeScript 按 package.json 的 exports 字段解析模块。Leaflet 2.0 的 exports 只暴露三个入口:"."(代码)、"./dist/leaflet.css"、"./styles.css"。用 node/node16 解析模式时,某些写法会报找不到模块。
2.4.3 类型名是扁平的
1.x 的用户自定义类型来自 @types/leaflet,命名是嵌套的;2.0 随类名一起拉平:
1.x(@types/leaflet) |
2.0(包内类型) |
|---|---|
Control.LayersOptions |
LayersControlOptions |
TileLayer.WMSOptions |
WMSTileLayerOptions |
LeafletMouseEvent |
LeafletPointerEvent |
GeoJSON<MyProps> |
回调参数上标注:onEachFeature(feature: Feature<Point, MyProps>, layer) |
事件回调 map LeafletEventHandlerFnMap |
已移除,每类各自有事件映射 |
2.5 自托管与离线部署
不能访问公网的环境,从 release 页下载 leaflet.zip。官方 install.md 列出的内容物只有五类:
leaflet.js Leaflet 的 ES module,已压缩
leaflet-global.js 同一份代码的经典脚本版,定义全局 L
leaflet.css 样式表,无需额外图片
leaflet.js.map 源映射
leaflet-global.js.map 源映射
注意 2.0 的 zip 里没有 images/ 目录。1.x 的默认图标是三张 PNG,2.0 改成内联 SVG data URI,所以不再需要拷图片,也不再需要 Icon.Default.imagePath 这类配置。
自托管目录示例:
/static/leaflet/
├── leaflet.js
├── leaflet.css
└── leaflet.js.map
<link rel="stylesheet" href="/static/leaflet/leaflet.css">
<script type="importmap">
{"imports": {"leaflet": "/static/leaflet/leaflet.js"}}
</script>
2.6 从”能显示”到”能交互”:三个渐进示例
2.6.1 第一层:地图 + 底图
import {LeafletMap, TileLayer} from 'leaflet';
const map = new LeafletMap('map').setView([30.2741, 120.1551], 12);
new TileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {maxZoom: 19}).addTo(map);
setView(center, zoom) 同时设置中心和层级并立即渲染,是”初始化即显示”的捷径。它返回 map 本身,所以能链式调用。
2.6.2 第二层:控件与图层
import {LeafletMap, TileLayer, ScaleControl, LayersControl, Marker} from 'leaflet';
const map = new LeafletMap('map').setView([30.2741, 120.1551], 12);
const osm = new TileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19, attribution: '© OpenStreetMap contributors'
});
const labels = new TileLayer('https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', {
subdomains: 'abc', maxZoom: 19, attribution: '© OpenStreetMap contributors, Tiles: HOT'
});
osm.addTo(map);
new ScaleControl({position: 'bottomleft'}).addTo(map);
new LayersControl({'标准底图': osm, '人道主义底图': labels}, {'示例标记': new Marker([30.2741, 120.1551])}).addTo(map);
LayersControl 的第一个参数是”单选”的基础图层组,第二个是”多选”的叠加层组,语义与 1.x 的 L.control.layers 完全一致,只是从工厂函数变成了类名。
2.6.3 第三层:事件与弹窗
import {LeafletMap, TileLayer, Popup, Marker} from 'leaflet';
const map = new LeafletMap('map').setView([30.2741, 120.1551], 12);
new TileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {maxZoom: 19}).addTo(map);
const popup = new Popup({maxWidth: 300, autoPan: true});
map.on('click', e => {
popup
.setLatLng(e.latlng)
.setContent('点击位置:' + e.latlng.lat.toFixed(5) + ', ' + e.latlng.lng.toFixed(5))
.openOn(map);
});
new Marker([30.2741, 120.1551])
.addTo(map)
.bindPopup('城市中心')
.openPopup();
这里的 e.latlng 是 Leaflet 把像素点击换算回经纬度后的结果。openOn(map) 与 addTo(map) 的区别是前者会关掉上一个由 openOn 打开的弹窗(受 autoClose 控制),后者允许同时存在多个——这个差异在第 9 章会展开。
2.7 六个真实报错与修法
| 报错 | 原因 | 修法 |
|---|---|---|
Failed to resolve module specifier "leaflet" |
有 type="module" 但没有 import map,或 import map 写在了 module 脚本之后 |
import map 放进 <head>,且位于所有 module 脚本之前 |
Unexpected token 'export' |
用普通 <script src> 加载 2.0 的 ESM 构建 |
改 type="module" + import map;或换 leaflet-global.js |
| 地图容器空白、无瓦片请求 | 容器高度为 0 | 给 #map 显式高度(height: 100% 需要父级也有高度) |
| 瓦片之间有 1px 缝隙、marker 位置偏移 | 漏了 leaflet.css |
补 <link>;SSR 场景下要在客户端注入 |
LeafletMap is not exported |
装的是 1.9.4,代码用了 2.0 写法 | npm install leaflet@beta,或把代码改回 1.x 写法 |
Cannot find module 'leaflet/styles.css' |
用了 1.x,该别名是 2.0 新增 | 1.x 改成 leaflet/dist/leaflet.css |
另外两个不算报错、但会让新手卡半天的现象:
- 地图出现了但拖不动、滚轮不缩放:检查是否给容器加了
pointer-events: none,或父级有覆盖层挡住了事件。Leaflet 的交互依赖容器接收到 Pointer 事件。 map.invalidateSize()什么时候用:容器尺寸在初始化之后才变化(侧边栏展开、Tab 切换、弹窗关闭)时,Leaflet 不知道新尺寸,需要手动调用一次让它重算。容器初始化时尺寸为 0 是最常见的触发场景。
2.8 迁移清单:从 1.x 项目切到 2.0
按这个顺序改,改一步跑一次,比一次改完再调试省时间:
- 装载形态:删掉全局
<script>,改 import map 或打包器导入;import L from 'leaflet'→import * as L from 'leaflet'(或全量改具名导入)。 - 工厂函数:
L.map→new LeafletMap;L.tileLayer→new TileLayer;L.marker→new Marker;L.control.layers→new LayersControl。 - 默认图标 workaround:删掉所有
marker-icon.png相关的delete L.Icon.Default.prototype._getIconUrl与mergeOptions补丁——2.0 里这些图片已经不存在了。 - 事件名:
mousemove/mouseover/mouseout→pointermove/pointerover/pointerout;keypress→keydown。旧名字会打一次控制台错误,监听器不会执行。 - 自定义类:
L.Class.extend({...})+initialize()→class X extends L.X+constructor()+static defaultOptions。 - Circle 半径:
L.circle(latlng, 200)→new Circle(latlng, {radius: 200})。2.0 传数字会直接抛错。 - DOM 工具:
DomUtil.addClass→el.classList.add;L.extend→Object.assign;L.stamp→Util.stamp。
第 3 章会解释第 5 条背后的类系统改造,那是 2.0 里最容易把老插件写崩的地方。
2.9 本章小结
| 事项 | 结论 |
|---|---|
| 默认安装到哪个版本 | npm install leaflet 装 1.9.4;2.0 用 leaflet@beta |
| 无构建怎么引 | <head> 里放 importmap + CSS <link>,代码写在 type="module" 脚本 |
| 有构建怎么引 | 具名导入 + import 'leaflet/styles.css',产物约 25.6 KB gzip |
| 类型从哪来 | 2.0 自带,必须卸载 @types/leaflet |
| 离线怎么部署 | leaflet.zip 只有 js/css/map 三类文件,无 images 目录 |
| 容器为什么必须给高度 | 地图按容器 clientWidth/clientHeight 计算可见瓦片范围 |
什么时候调 invalidateSize() |
容器尺寸在初始化之后发生变化时 |