简介面向GIS开发者与前端工程师这份压缩包系统汇集了LeafLet离线地图从基础到进阶的典型开发案例。资源共723个文件以JS、HTML、CSS源码为主辅以PNG、SVG图标、Markdown说明文档和LeafDoc注释整体体积仅14.08MB目前已有4147人学习使用便于本地查阅与二次开发。案例覆盖LeafLet核心功能基本地图显示、多图层管理、标记与弹出框、控件自定义、交互事件、地理编码、路径与多边形绘制、图层监听、自定义图层、投影切换、性能优化以及响应式设计每个Demo都是一个独立的学习点。通过动手实践开发者可快速掌握离线地图的瓦片加载、GeoJSON矢量数据展示与交互逻辑还能学会如何封装自定义控件、接入自定义瓦片源并根据不同投影与屏幕尺寸调整地图表现。这套资源既适合初学者按顺序逐个攻破也便于有经验者按需检索具体功能模块是搭建Web GIS应用时可直接参考的离线工具箱。1. 只给一份离线瓦片地图包能做的事远超你想象做前端或 GIS 开发的人迟早会遇到一个断了外网的现场。某单位的业务系统部署在内网机房上级要求三天之内上线一张可缩放、可拖拽、能定位到具体村口的地图页面——这场景我至少经历过五次。最尴尬的是地图服务依赖公网外网一断Leaflet 默认加载的在线瓦片全部白屏页面只剩一堆报错。「LeafLet离线地图案例demo集合非常详细」解决的正是这一类问题让一张页面在完全没有公网的环境下依然拥有与在线地图几乎一致的用户体验。它做的事可以拆成三块——提前把地图瓦片下载到本地服务器或静态资源目录写一个能识别本地瓦片地址的 Leaflet 配置然后把常用功能比例尺、标记点、图层切换、量测工具集成起来复用。适合的人群很明确做内网系统、应急指挥、野外作业、以及任何不允许流量出网的项目的开发者。这篇文章会带你从瓦片资源准备开始到最小示例跑通再到一套可以直接复制改写的完整案例集合最后把我在真实项目里踩过的坑全部摊开。这套流程你照着做一个下午就能把地图立起来。2. 离线地图的准备工作瓦片从哪里来坐标系统怎么统一2.1 离线瓦片的核心思路把地图切成小块再拼接如果你打开在线地图的开发者工具看网络面板会发现地图并不是一张完整的大图而是几十上百张小图拼起来的。Leaflet 根据当前视口的经纬度范围和缩放级别动态计算需要加载哪些小图按照网格拼接展示。离线的原理一模一样只是在本地准备这些网格图片。瓦片的命名规则决定了请求地址的形态。最常用的 XYZ 规则是这样的/{z}/{x}/{y}.png。z代表缩放级别通常是 0 到 18 级数字越大显示越细x和y是当前级别下的列和行编号。Leaflet 默认的L.tileLayer就是按这个规则去请求图片的。理解了命名规则你就知道离线地图最核心的工作变成了两件事把瓦片按z/x/y的目录结构存好然后让 Leaflet 的 URL 指向本地路径。看起来很朴素但我在很多项目里看到有人把瓦片下载成了文件夹但目录层级不对或者图片格式不对导致加载失败所以这块一定要确认清楚。常见做法是用地图下载器一次性框选区域级别选 1 到 18输出格式选 PNG 或 JPG。下载完成后检查目录结构tiles/ ├── 3/ │ ├── 1/ │ │ ├── 2.png │ │ └── 3.png │ └── 2/ │ └── 2.png └── 4/ └── 1/ └── 2.png这里tiles是瓦片根目录下面第一层是缩放级别第二层是x值第三层是y值加图片后缀。最常见的翻车就是层级嵌套多了一层比如tiles/3/1/2/2.png这种多套一层目录的写法在 Leaflet 里没法直接配。2.2 坐标系统一WGS84 和 GCJ02 千万别混用离线瓦片下载时如果你用的是国内地图服务通常下载下来的瓦片已经是 GCJ02 坐标系也就是俗称的国测局坐标。但 Leaflet 默认的坐标参考系是 WGS84。这两套坐标体系在同一个地图上看不明显但在叠加业务数据点的时候就会出现偏移——地图底图在正确位置但标记点位偏出去几十米甚至几百米。我的做法是下载瓦片时先确认坐标系。如果瓦片是 GCJ02那么在 Leaflet 初始化时为地图指定crs: L.CRS.EPSG3857不动但是在把所有业务坐标点来自 GPS 或测绘数据传入之前先做一个坐标转换将 WGS84 转成 GCJ02。代码上实现一个简单的转换函数// 简单 WGS84 转 GCJ02 函数适用于国内地图瓦片 function wgs84ToGcj02(lng, lat) { // 这里需要一个完整的转换算法我一般会用 gcoord 库 // 这是示意代码实际使用时请引入 gcoord 或自写完整算法 const result gcoord.transform( [lng, lat], gcoord.WGS84, gcoord.GCJ02 ); return result; } // 在地图上添加业务点 const point wgs84ToGcj02(116.404, 39.915); L.marker(point).addTo(map);坐标转换的库推荐gcoord它体积小支持 WGS84、GCJ02、BD09 之间的互相转换。参数gcoord.WGS84和gcoord.GCJ02是库内置的坐标系枚举你的项目里如果混用了不同数据源的坐标一定要统一入口。记住一句话所有外部数据进地图之前过一遍转换函数不要在渲染层零零散散地转。2.3 级别和范围怎么选覆盖范围大不代表级别深下载瓦片的时候很多人会犯一个错误区域框选得很大但缩放级别只选了 1 到 10结果放大到楼栋级别就糊了。正确的思路是倒推——你的业务场景需要在哪个级别看到什么细节。我在某个应急指挥 Demo 里业务方要求放大到 16 级时能看清地块边界那么下载级别至少要覆盖到 17 级留一级余量。范围上只下载实际业务区域周围 2 公里的缓冲区就够了。级别从 1 到 17 全下载数据量是恐怖的一个标准城市区域大概要几十 GB 的磁盘空间。经验做法是分级下载中心区域下载 15 到 17 级周边区域只下载 10 到 14 级。然后在地图配置里做多图层叠加而不是全部绞在一起。Leaflet 支持同一个地图挂多个瓦片图层可以在缩放级别切换时自动显示不同精度的底图。const layer1 L.tileLayer(./tiles/center/{z}/{x}/{y}.png, { maxZoom: 17, minZoom: 15 }); const layer2 L.tileLayer(./tiles/outer/{z}/{x}/{y}.png, { maxZoom: 14, minZoom: 3 });这里layer1和layer2分别对应不同目录下的瓦片minZoom和maxZoom是显示范围的限制。两层叠加在一起缩放级别在 15 以上时显示中心精细瓦片15 以下显示周边粗略瓦片。加载效果是流畅的因为浏览器只会去请求当前视口内对应的瓦片。3. 用 Leaflet 搭建离线地图的最小可跑示例3.1 项目目录结构和静态资源准备当你的瓦片已经准备好下一步是建一个最小可运行的项目。我习惯先做纯静态页面不引入构建工具保证任何一台机器上双击index.html就能看到地图效果后面再考虑迁移到 Vue 或 React 项目里。目录结构按我的经验是这样offline-map/ ├── index.html ├── tiles/ │ └── {z}/{x}/{y}.png ├── lib/ │ ├── leaflet.css │ └── leaflet.js └── data/ └── points.geojson注意lib里的 Leaflet 文件必须是本地副本不能是 CDN 链接。这一点经常有人忽略页面写得挺完整结果一断网样式全丢。Leaflet 的 JS 和 CSS 文件使用同一个版本号避免出现 JS 是 1.9 版本、CSS 是 1.6 版本导致的样式错乱。3.2 第一个能点击拖拽的离线地图下面是完整的index.html内容也是你的第一个离线地图。这个代码遵循一条链路先初始化地图对象再挂载瓦片图层最后把地图实例挂到全局以便调试。!DOCTYPE html html langzh-CN head meta charsetUTF-8 / title离线地图最小示例/title meta nameviewport contentwidthdevice-width, initial-scale1.0 / link relstylesheet href./lib/leaflet.css / style html, body, #map { height: 100%; margin: 0; } /style /head body div idmap/div script src./lib/leaflet.js/script script // 初始化地图如果 server 不传默认就是本地瓦片目录 const map L.map(map, { center: [34.5, 110.2], zoom: 12, zoomControl: true }); // 挂载离线瓦片 L.tileLayer(./tiles/{z}/{x}/{y}.png, { minZoom: 3, maxZoom: 17, attribution: 离线地图 }).addTo(map); // 加一个标记点验证效果 L.marker([34.5, 110.2]) .bindPopup(离线地图加载成功) .addTo(map); /script /body /html这里的L.map(map, { center: [34.5, 110.2], zoom: 12 })是地图初始化的核心center 决定默认视口中心zoom 决定默认缩放级别。我记得第一次跑这个示例时地图是空白的控制台报 404最后排查出来是瓦片目录名写成了{z}{x}{y}少了斜杠注意 URL 模板里每一层路径都必须带/。tileLayer的 URL 模板中的{z}、{x}、{y}是 Leaflet 的占位符它会在运行时替换为具体的瓦片编号。attribution属性是可选的但建议写上方便将来排查瓦片来源。3.3 从静态页面迁移到 Webpack 或 Vite 项目的 3 个改动最小示例跑通之后你可能会想把地图集成到现有的前端工程里。我经历过几次迁移这里给出最关键的三个改动点。第一个改动是瓦片目录的处理。在 Vite 项目中放在public/tiles下的文件会被原样复制到构建产物中访问路径写成/tiles/{z}/{x}/{y}.png。如果放在src/assets下那就需要动态导入不建议这么干瓦片文件太多会让打包变慢。第二个改动是 Leaflet 的引用方式。项目里用 npm 安装npm install leaflet然后在组件里引入import L from leaflet; import leaflet/dist/leaflet.css;这个时候要注意一个坑npm 包里的默认图标地址用的是相对路径打包后会找不到图片。解决办法是重新指定 icon 的 URL。我一般把 marker 图标放到public/marker-icon.png然后手动配置delete L.Icon.Default.prototype._getIconUrl; L.Icon.Default.mergeOptions({ iconUrl: /marker-icon.png, iconRetinaUrl: /marker-icon-2x.png, shadowUrl: /marker-shadow.png });第三个改动是地图初始化时机。框架组件要等 DOM 渲染完成后再初始化地图在 Vue 里放在onMounted里在 React 里放在useEffect依赖数组为空的那个回调里。初始化早于 DOM 挂载会导致map container is not initialized的报错。4. 让 Demo 集合覆盖真实项目常用功能的代码级拆解4.1 缩放级别与比例尺的联动配置真实项目里用户对比例尺的需求很明确要知道地图上 1 厘米对应实际多少米。Leaflet 提供了一个L.control.scale控件可以自动根据缩放级别计算比例尺。但在离线场景下这个控件有它的限制——它默认用米制且不显示前缀。我建议做一个定制版本用一个函数在每次缩放变化时手动刷新比例尺的文本。示例代码如下// 自定义比例尺显示带公里/米自动切换 function updateScaleText() { const zoom map.getZoom(); // 这里根据 zoom 级别推导比例尺数值 const metersPerPixel 156543.03392 * Math.cos((34.5 * Math.PI) / 180) / Math.pow(2, zoom); const scaleWidth 200; // 控件宽度 200px const realMeters metersPerPixel * scaleWidth; let text ; if (realMeters 1000) { text (realMeters / 1000).toFixed(1) km; } else { text realMeters.toFixed(0) m; } document.getElementById(scaleText).innerText text; } map.on(zoomend, updateScaleText); updateScaleText();156543.03392是 Web Mercator 投影下缩放级别 0 时的地面分辨率单位是米/像素这是固定值。乘以纬度的余弦是为了修正不同纬度下的实际距离差异。在演示项目里这个函数要跟zoomend事件绑定否则拖动地图不触发缩放比例尺不会更新。4.2 多个图层切换影像图、路网图、标注图随意换4. 让 Demo 集合覆盖真实项目常用功能的代码级拆解这一章你把每个功能模块当做一个个独立的 demo一个功能对应一段可复用的代码合起来就是你自己的案例集合。4.1 图层切换多种离线地图源并存一个较为完整的离线地图系统往往需要备好几套瓦片一套遥感影像一套路网地图一套带注记的标准地图。Leaflet 的图层控制控件L.control.layers可以直接支持底图切换。这里的核心操作是在baseLayers对象里并行挂多个L.tileLayer并保证每层瓦片路径各自独立。// 定义三个离线图层 const imgLayer L.tileLayer(./tiles/img/{z}/{x}/{y}.jpg, { maxZoom: 18, attribution: 影像来源 }); const roadLayer L.tileLayer(./tiles/road/{z}/{x}/{y}.png, { maxZoom: 18, attribution: 路网来源 }); const labelLayer L.tileLayer(./tiles/label/{z}/{x}/{y}.png, { maxZoom: 18, attribution: 注记来源 }); // 默认显示路网把三个图层都加入图层控制 let map L.map(map, { center: [31.23, 121.47], zoom: 12, layers: [roadLayer] // 默认图层在这里指定 }); L.control.layers({ 路网图: roadLayer, 影像图: imgLayer, 影像注记: L.layerGroup([imgLayer, labelLayer]) }).addTo(map);这段代码里比较关键的是L.layerGroup的用法。当选中“影像注记”时它同时激活两个图层labelLayer是半透明或文字居多的瓦片叠加在imgLayer上面形成完整的看图体验。在下载瓦片时最好在服务端把三个底图目录分清楚比如img下只放影像road下只放路网这样前端配置也干净。4.2 动态标记管理与弹窗复用离线地图不只是看更多场景需要把业务点标注上去。比如设备点位、巡检点位、人员定位。我习惯把所有点位数据统一存在一个 JSON 文件里页面加载时读取并批量渲染。这样更贴近实际项目中的数据与界面分离。// data/points.json 的内容与格式由数据源决定这里模拟读取 fetch(./data/points.json) .then((res) res.json()) .then((points) { points.forEach((pt) { const marker L.marker([pt.lat, pt.lng]).addTo(map); marker.bindPopup( b${pt.name}/bbr/状态${pt.status}br/更新时间${pt.updateTime} ); }); });这里fetch的地址可以是相对路径也可以是请求某个内网接口。字段name、status、updateTime根据你自己的数据格式改就行。需要注意的一点是如果点位数量超过几百个一次性全部渲染会对性能产生影响在地图拖拽时会有明显卡顿。可以采用L.markerClusterGroup插件做聚合或者只渲染当前视口范围内的点。4.3 量测工具在地图上画线并计算距离量测功能是离线地图中非常高频的需求应用场景包括查看两个设备之间的直线距离、量测一块地块的面积。最常用的方案是引入 Leaflet 的leaflet-measure-path插件或者自己基于绘图功能写一个简单的测距逻辑。// 开启测距模式 map.on(click, function (e) { if (!measuring) return; latlngs.push(e.latlng); if (latlngs.length 2) { // 计算两端点距离单位为米 const distance latlngs[latlngs.length - 1].distanceTo(latlngs[latlngs.length - 2]); totalDistance distance; L.polyline(latlngs, { color: #ff7800 }).addTo(map); L.marker(e.latlng).addTo(map); } });distanceTo是 Leaflet 内置方法计算两个经纬度点之间的球面距离返回单位是米。累加每一段距离得到总长。如果要实现面积量测通常的做法是把点击的坐标点连成多边形然后用L.polygon渲染面积计算要自己通过球面多边形公式实现或者使用turf.js库。如果项目的离线环境不允许额外引入大库手写简化公式也够用。5. 离线瓦片加载失败的排查清单五个高频坑5. 离线瓦片加载失败的排查清单五个高频坑5.1 页面白屏控制台报 404路径层级多了一层现象地图区域完全空白打开浏览器开发者工具 Network 面板看到大量 404 请求URL 形如你的域名/tiles/12/3405/1582.png但磁盘上文件是存在的。原因大部分下载器输出目录结构是tiles/12/3405/1582.png但有人会手动把瓦片再包一层文件夹变成tiles/12/3405/1582/1582.png或者 Leaflet 的 URL 模板多写了一个/{x}/{y}/{y}。本质就是路径匹配不上。解决先找出任意一个 404 的 URL去磁盘上手动定位到对应目录看真实的目录层级。如果文件是.../1582.pngURL 必须写成./tiles/{z}/{x}/{y}.png一层不能多。如果发现文件确实缺失回头用下载器重新补瓦片重点排查下载器是否完整导出。5.2 能显示地图但图片模糊检查 maxZoom 配置现象地图能缩放也能拖动但缩放到最大级别时图片像被拉伸过文字全是锯齿。原因瓦片下载的目录里最高只到第 16 级但前端 Leaflet 初始化时设置了maxZoom: 18用户缩放到了 17、18 级没有对应瓦片浏览器只能把 16 级的瓦片强行拉伸。解决把前端tileLayer的maxZoom设置为下载瓦片的真实最高级数。排查方法直接去tiles目录看子文件夹的数字最大值。5.3 标注点和地图位置对不上坐标系偏移现象底图正常显示但是业务人员反馈人员定位图标和实际位置差了几十米有的方向固定偏移。原因瓦片是 GCJ02 坐标系经纬度数据是 WGS84输入到 Leaflet 的时候没有做坐标转换。GPS 设备采集的基本都是 WGS84而国内互联网地图瓦片一般为 GCJ02 或 BD09。解决引入gcoord库在所有外部经纬度进入 Leaflet 之前统一走一次坐标转换。注意一套数据只转换一次不要反复转换否则偏移会加倍。5.4 瓦片加载很慢甚至卡死单次请求数量过多现象地图拖拽流畅度还行但每次松手后总是等一段时间才完整显示甚至局部区域灰块很久不加载。原因瓦片下载时机和并发请求数没有被控制。在 Leaflet 中tileLayer有一个参数updateWhenIdle和keepBuffer。如果并发请求太多或缓冲区域设得太大离线服务器的磁盘 IO 会成为瓶颈。解决调整tileLayer参数限制请求范围让浏览器只在需要的范围内请求瓦片。L.tileLayer(./tiles/{z}/{x}/{y}.png, { maxZoom: 18, tileSize: 256, updateWhenIdle: true, // 拖动结束后才加载新瓦片 keepBuffer: 2 // 视口外两侧多保留 2 个瓦片 });tileSize一般为 256 或 512要和下载器输出的瓦片尺寸吻合。updateWhenIdle设为false时地图拖动过程中持续加载瓦片配合弱性能服务器会卡所以我建议保持true。5.5 显示经纬度坐标的鼠标位置和功能正常但导出坐标不对现象点击地图上的点页面显示经纬度但导出到 Excel 或提交给后端后发现和现场使用时坐标不一致。原因大多数人写导出功能时直接把 Leaflet 的e.latlng拿来用但 Leaflet 内部返回的坐标是它在渲染时使用的坐标系。如果你的地图没有配crs默认是 EPSG3857 对应的地理坐标但瓦片本身可能是 GCJ02数据导出就偏了。解决在导出之前对坐标做一次和标记点一样的转换逻辑将 GCJ02 转回 WGS84 再导出。这两者互转的算法在gcoord里都有封装务必统一用一个工具函数来导出不要在多个地方散落转换代码。6. 从 demo 到可用系统数据更新、内存优化与缓存策略6.1 有进度反馈的瓦片批量替换6. 从 demo 到可用系统数据更新、内存优化与缓存策略项目交付之后最常遇到的情况是地图区域变了或者客户要求更新数据。每次重下一整套瓦片太费时间我一般会做三件事增量更新瓦片、清理浏览器缓存、验证关键位置。增量更新的意思是只重新下载变化区域的瓦片。比如客户新增了一个园区只需要把新园区所在经纬度范围框选出来设置和旧瓦片相同的级别重新下载即可。下载器和标准目录结构保持一致新瓦片直接覆盖到旧目录里的对应位置。这样做的好处是省流量但要注意新增区域不能和旧区域有重叠级别差异否则边界上会出现新旧瓦片混用的情况。清理浏览器缓存是容易被忽视的一步。用户第一次访问离线地图时浏览器会把瓦片缓存在本地。瓦片更新后如果文件名和路径没有变化浏览器会直接使用旧缓存导致用户看到的还是旧地图体验上就像地图没更新一样。静态服务器可以配置Cache-Control: no-cache来避免这个问题但如果是在纯静态目录下最简单粗暴的方式是更新时在tileLayer的 URL 后面加一个版本参数例如./tiles/{z}/{x}/{y}.png?v20240601。每次更新瓦片就更新版本参数浏览器会强制拉取新瓦片。最后是验证关键位置。更新后我习惯在代码里快速跳转到几个已知地标依次确认不同缩放级别下的显示效果。例如验证市中心道路是否清晰、园区边界是否完整、空白区域是否补全。用 Leaflet 的map.setView([lat, lng], zoom)方法几秒钟就可以扫一遍。6.2 大数据量点位渲染的内存优化如果点位数量超过几千个直接全部渲染会导致内存上涨明显地图卡顿。常见做法是使用leaflet.markercluster插件做聚合另一个更轻量的处理是用 Canvas 渲染。Leaflet 内置的L.canvas渲染器非常适合大量点位。把 marker 换成L.circleMarker然后设置preferCanvas: true所有矢量图形绘制到 Canvas 上不用每个点都操作一个 DOM 元素。这个过程能显著提升性能。核心代码只有两行改动const map L.map(map, { center: [31.23, 121.47], zoom: 12, preferCanvas: true // 所有矢量走 canvas 渲染 }); points.forEach((pt) { L.circleMarker([pt.lat, pt.lng], { radius: 6, color: #3388ff, fillColor: #3388ff, fillOpacity: 0.5 }).addTo(map); });这里L.circleMarker的radius是像素半径不随地图缩放变化。如果你需要更大范围的数据集可以结合第三方插件但preferCanvas是内置的无依赖方案。对于几千个点位的场景这个方案能保证地图拖拽基本流畅。6.3 离线地图的静态资源服务器配置与浏览器访问技巧所有离线地图最终都是通过一个 HTTP 服务器访问的不管是内网的 IIS、Nginx 还是 Node 服务都需要注意 MIME 类型和文件缓存的配置。我在某公司内网部署时踩过最尴尬的坑是服务器不认识.png、.jpg之外的瓦片格式或者对.pbf这类矢量瓦片返回了错误的 Content-Type导致 Leaflet 无法解析。如果是用 Nginx 作静态文件服务配置里加一下常规的 MIME 映射server { listen 8080; server_name localhost; root /opt/offline-map/; location /tiles/ { expires 1d; add_header Cache-Control public, immutable; } }这里的expires 1d表示浏览器缓存一天每次用户访问同一块区域时直接命中本地缓存。对于纯离线场景这个配置能显著提升加载速度。如果你使用 IIS需要在 MIME 类型里添加.png image/png、.jpg image/jpeg否则浏览器会直接下载而非渲染。浏览器访问技巧上客户端多使用 Chrome 或 Edge并且提前把所有瓦片文件放到本地目录后直接双击打开 HTML 文件多数情况下也能展示。但要注意很多 Leaflet 内置功能在file://协议下有限制比如中文标注、字体加载、浏览器安全限制导致的请求失败。所以我建议即使是本地演示也启一个简单的静态服务器用npx serve或python -m http.server 8080就可以。最后瓦片管理要养成分目录、带日期备份的习惯。瓦片文件以几十 GB 计整理成一致的目录结构新增时备份更换时保留旧版本这样排查问题时能快速回滚。做离线地图开发这几年我的体会是技术本身难度不大最大的坑永远是数据准备和路径配置这类不起眼的细节。希望这篇踩坑笔记能帮你把工作流程理顺少走几趟弯路。本文还有配套的精品资源点击获取