☰
Leaflet离线地图内网部署:从demo到实战的完整方案
2026/10/20 13:16:54 网站建设 项目流程

简介:面向Web GIS与前端开发者的LeafLet离线地图示例合集,系统梳理了从地图初始化、图层管理到离线瓦片加载、GeoJSON几何绘制的完整知识链路,涵盖TileLayer瓦片加载、基于localStorage与IndexedDB的缓存机制、离线地图数据预下载等关键技术点,适合需要在实际项目中落地离线地图功能的开发者对照学习。rar压缩包共723个文件,约14.06MB,以319个JavaScript示例脚本和175个HTML演示页面为主,辅以29个CSS样式、79个PNG图标以及Markdown文档说明,文件类型丰富,目录结构清晰,便于按知识点快速检索定位。目前已有1735人学习下载。示例覆盖标记与弹出窗口、事件处理、地理编码与反编码、自定义图标与地图样式等高频交互场景,并包含LeafLet.markercluster标记聚合、LeafLet.draw图形绘制等常用扩展插件的用法演示,可直接参考改造用于应急测绘、内网GIS展示、移动端离线导航等实际项目。

1. 内网系统接入地图:Leaflet离线地图demo合集的正确打开方式

把地图功能塞进一个没有任何外网权限的内网系统,很多人第一反应是凉了半截——在线瓦片服务统统不可用,CDN也可能被安全策略拦在外面。Leaflet离线地图demo合集的思路恰恰反着来:提前下载好瓦片图块,把Leaflet本体也放进本地目录,页面直接用相对路径加载。这样离线环境下的地图交互跟在线地图几乎没区别,拖拽、缩放、打点、画图层都能跑,这套资源包把离线地图从一个黑匣子变成了一份看得见、改得动的骨架。适合做内网设备巡检地图、厂区资产点位展示,以及想先跑通离线方案再定技术路线的场景。

2. 技术选型与目录结构:离线地图方案的骨架

2.1 为什么离线地图场景普遍选Leaflet

在离线地图这个特定场景里,Leaflet几乎是低成本起步的第一选择。它作为成熟的轻量级地图库,体积小、依赖少,不需要复杂的构建流程,一个HTML文件加一个js引用就能跑,这在内网环境里是巨大的省事。相比之下,功能更重的地图库往往需要处理更多GIS概念,学习和改造成本更高;依赖WebGL的渲染方案对老旧内网机器不够友好;而商用SDK的定位、授权逻辑天然绑定在线服务,离线场景基本不适用。

Leaflet的核心对象只有三个层级:L.map创建地图实例,L.tileLayer负责加载瓦片图层,L.control管理图上控件。理解这三者的关系,整个demo合集就能拆明白。L.map把DOM容器变成地图舞台;L.tileLayer决定地图里显示什么底色;控件则提供缩放按钮、比例尺、图层切换等交互入口。demo合集里所有示例,本质都是在给这三个对象堆配置。

2.2 解压后的目录结构与文件职责

资源包解压后一般是一个leaflet-demos目录,组织方式类似下面这样。不同合集的文件名和demo数量会有差异,但骨架不外乎这几块:

leaflet-demos/ ├── index.html ├── libs/ │ ├── leaflet.js │ ├── leaflet.css │ └── images/ │ ├── marker-icon.png │ └── layers.png ├── tiles/ │ ├── 0/ │ │ ├── 0/ │ │ │ └── 0.png │ │ └── 1/ │ │ └── 0.png │ ├── 1/ │ └── ... ├── demos/ │ ├── 01-basic-map.html │ ├── 02-markers.html │ ├── 03-popups.html │ ├── 04-layer-control.html │ ├── 05-draw.html │ └── 06-measure.html └── data/ └── sample-points.json

用文件清单表格列出来更直观:

目录/文件作用
index.htmldemo索引页,把所有示例聚合到一个入口
libs/Leaflet本体、CSS与必要的图片资源
tiles/离线瓦片库,按z/x/y三级目录存放
demos/每个HTML对应一个独立演示场景
data/模拟业务数据,通常是GeoJSON

这里最容易理解的是tiles目录的组织逻辑。z代表缩放级别,x代表列编号,y代表行编号,这是瓦片地图的标准组织方式。Leaflet的L.tileLayer在请求某张瓦片时,会拿着当前地图中心和缩放级别算出需要的x与y,然后填充到URL模板的占位符里。离线场景唯一的区别是模板不再指向外部服务器,而是指向本地tiles目录。

瓦片金字塔规则值得多说一句:zoom级别0时,整个地球是一张瓦片,也就是tiles/0/0/0.png存在;zoom级别1时,地球被切成4张瓦片,也就是tiles/1/0/0.png、tiles/1/1/0.png等;每放大一级,瓦片数量变成上一级的4倍。这也是为什么离线瓦片包通常体积不小,某个城市的完整瓦片在zoom 12到18之间动辄几万张。demo合集里的瓦片通常是示例级的小范围数据,但目录规律是通用的。

2.3 索引页与demo间的关系

index.html通常只是一个带若干链接的列表页,每个链接指向demos目录下对应的html文件。打开某个demo后,它和索引页没有共享状态,互相之间也不影响。调试时可以直接只改其中一个demo文件,不需要动整个目录。

这些demo从简单到复杂排列:先是基础地图展示,然后逐步加Marker、Popup、图层控制、绘制工具,最后是轨迹回放这类综合场景。如果只打算用其中某几个能力,直接复制对应HTML文件改造即可,这也是我拿到这套资源包时比较推荐的使用方式——把它当成一个代码模板库,而不是一个必须整体运行的应用。

3. 核心demo拆解:瓦片、标记与图层的实现链路

3.1 基础地图demo:L.map与L.tileLayer的配置

先看最基础的01-basic-map.html,打开后是一个全屏地图,可以拖拽和缩放。完整逻辑浓缩在十几行代码里:

<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <link rel="stylesheet" href="../libs/leaflet.css"> <style> html, body { margin: 0; height: 100%; } #map { height: 100%; } </style> </head> <body> <div id="map"></div> <script src="../libs/leaflet.js"></script> <script> // 创建地图实例,挂在id为map的div上 var map = L.map('map', { center: [0, 0], // 示例中心点,实际换成项目区域 zoom: 4, // 初始缩放级别 minZoom: 3, // 最小缩放级别,防止拉到看不到瓦片 maxZoom: 18 // 最大缩放级别,对齐瓦片层级 }); // 加载离线瓦片图层 L.tileLayer('../tiles/{z}/{x}/{y}.png', { maxZoom: 18, attribution: 'Offline Tiles' }).addTo(map); </script> </body> </html>

注意到两个细节。一是CSS、JS、瓦片路径全部是相对路径,确保资源包拷贝到任何位置都能跑;二是leaflet.css必须在leaflet.js之前加载,否则Marker默认样式会乱掉。

L.map的第一个参数是DOM容器id,第二个参数是一个配置对象。center是地图加载后自动定位的中心点;zoom是初始缩放级别;minZoom和maxZoom定义了用户能缩放的区间。这里的数值必须和tiles目录里已有的瓦片层级对应,否则缩放起来就会出现空瓦片区域。

L.tileLayer的第一个参数是瓦片URL模板,{z}/{x}/{y}三个占位符会被Leaflet自动替换成整数。比如当前坐标为[0,0]、zoom为4时,浏览器的Network面板里会看到tiles/4/某个x/某个y.png的请求。maxZoom参数在这里的作用是告诉Leaflet这个图层能支持到第几级,避免请求超出瓦片包范围的层级。

3.2 Marker与Popup:业务点位挂载

第二个核心demo是markers与popups,展示了怎么往地图上打点并弹出信息框。两个demo的主干逻辑是相通的,只是popups在marker的交互上加了bindPopup这一步。

// 创建地图与底图 var map = L.map('map', { center: [0, 0], zoom: 4 }); L.tileLayer('../tiles/{z}/{x}/{y}.png').addTo(map); // 定义一组业务点 var sites = [ { name: '点位A', lat: 0.01, lng: 0.02, desc: '设备编号 DEV-001' }, { name: '点位B', lat: -0.01, lng: 0.03, desc: '设备编号 DEV-002' }, { name: '点位C', lat: 0.02, lng: -0.01, desc: '设备编号 DEV-003' } ]; // 循环创建Marker sites.forEach(function (item) { var marker = L.marker([item.lat, item.lng], { title: item.name, riseOnHover: true // 鼠标悬停时把该Marker提到最上层 }).addTo(map); marker.bindPopup( '<b>' + item.name + '</b><br>' + item.desc ); });

这里用sites数组模拟业务数据,实际项目里这段数据通常来自后端接口。L.marker的第一个参数是经纬度数组,第二个参数是可选配置。title在鼠标悬停时以原生提示形式显示;riseOnHover在Marker重叠时能避免当前挂载的Marker被其他Marker遮挡。

bindPopup的作用是把一段HTML字符串绑定到Marker上,用户点击Marker后Popup才会打开。如果你希望页面加载后自动弹出,可以在bindPopup后面链式调用openPopup()。我一般还是让用户点击触发,因为自动弹出Popup在点位密集时会互相遮挡操作,体验反而差。

3.3 图层控制与绘制工具:可交互的地图画布

demo合集里比较能体现实用价值的是layer-control,它同时加载了两个底图和一组覆盖层,用户可以通过右上角的图层控件切换显示。

// 两个底图:一个普通瓦片,一个带标注的瓦片(示例里为不同目录) var baseLayer1 = L.tileLayer('../tiles/{z}/{x}/{y}.png', { maxZoom: 18 }); var baseLayer2 = L.tileLayer('../tiles-labels/{z}/{x}/{y}.png', { maxZoom: 18 }); // 覆盖层:标记图层和线图层 var markerLayer = L.layerGroup().addTo(map); var lineLayer = L.layerGroup().addTo(map); // 往覆盖层里加元素 L.marker([0.01, 0.02]).addTo(markerLayer); L.polyline([[0, 0], [0.01, 0.02], [0.02, 0.01]], { color: '#2f80ed' }).addTo(lineLayer); // 构建控件 var baseMaps = { '离线底图': baseLayer1, '标注底图': baseLayer2 }; var overlayMaps = { '设备标记': markerLayer, '巡检路线': lineLayer }; L.control.layers(baseMaps, overlayMaps, { position: 'topright' }).addTo(map);

L.control.layers接受三个参数:baseMaps、overlayMaps、options。baseMaps是互斥的底图列表,同一时间只能显示一个;overlayMaps是可叠加的图层列表,可以同时打开多个。这个机制在做底图切换和数据显隐时非常有用,比如巡检路线的显隐开关就能用overlay实现,不需要自己写一堆checkbox逻辑。

绘制工具的demo也值得单独看。它用Leaflet的draw插件画点、线、面,并把画出来的图形收集到一个featureGroup里:

var editableLayers = L.featureGroup().addTo(map); var drawControl = new L.Control.Draw({ position: 'topright', draw: { polygon: { showArea: true }, rectangle: true, circle: true, marker: true, polyline: true }, edit: { featureGroup: editableLayers, remove: true } }); map.addControl(drawControl); map.on(L.Draw.Event.CREATED, function (event) { editableLayers.addLayer(event.layer); });

L.Control.Draw里的draw字段控制哪些绘制工具出现在工具栏上;edit.featureGroup指定可编辑的图层组,remove为true表示允许删除图形。绘制完成会触发L.Draw.Event.CREATED事件,需要在回调里把event.layer加入editableLayers,否则画出来的图形只是临时显示,刷新或与其他图层交互时就会丢失。

3.4 轨迹回放demo:polyline与动态Marker

轨迹回放是设备巡检、车辆监控里最常见的需求。demo合集的trail场景一般是这么做的:用一组带时间戳的坐标点,先画一条L.polyline展示完整轨迹,再用一个Marker模拟设备当前位置。

// 模拟一段轨迹点数组 var trailPoints = [ [0, 0], [0.01, 0.02], [0.02, 0.03], [0.03, 0.02], [0.04, 0.04] ]; // 画完整轨迹线 var trailLine = L.polyline(trailPoints, { color: '#fe7c23', weight: 4, lineCap: 'round' }).addTo(map); // 移动Marker实现轨迹回放 var movingMarker = L.marker(trailPoints[0]).addTo(map); var index = 0; setInterval(function () { index = (index + 1) % trailPoints.length; movingMarker.setLatLng(trailPoints[index]); }, 1000);

这里的polyline在样式上做了两个细节:weight控制线宽,lineCap设为round避免线段连接处出现尖角。setInterval每秒把点位索引向后挪一位,再用setLatLng更新Marker位置,看起来就是设备沿着轨迹在走。真正的项目里这套逻辑通常放在requestAnimationFrame里做,并配合时间参数计算插值点,让移动更平滑。

4. 跑通全部demo:本地服务、瓦片路径与浏览器验证

4.1 启动本地HTTP服务:一条命令搞定

首先确认一点:即使demo是纯静态的HTML,也不要直接双击打开。Leaflet页面在file://协议下会由于浏览器安全限制,无法从本地目录读取瓦片。最常见的现象是页面骨架出来了,地图区域灰白一片。启动方式很简单,在资源包解压后的根目录执行下面任一命令即可。

如果机器上有Python 3:

python -m http.server 8080

如果你用Node环境:

npx serve -p 8080

启动后终端会处于阻塞状态,看到类似Serving HTTP on 0.0.0.0 port 8080的输出就说明服务已经起来了。浏览器访问 http://localhost:8080/ ,应该能看到index.html的demo列表。

端口选择上,8080被占用时换8081、8082都行,但浏览器访问的端口必须与启动端口一致。另外,如果你的操作系统有防火墙,监听在0.0.0.0时局域网内其他机器也可以通过http://你的IP:8080访问,这对内网共享demo很方便,但要注意访问权限控制。

注意:如果浏览器打开页面后还是空白,先检查终端有没有报错,再确认访问地址的端口是否与启动端口一致。这俩问题是启动服务阶段最常见的翻车点。

4.2 瓦片放置与相对路径规则

瓦片放置的规则其实就一条:URL模板里的路径,必须能从HTML文件所在位置按相对路径访问到tiles目录。举三个典型情况:

  • 页面放在demos/01-basic-map.html,瓦片在项目根tiles/,模板应该是../tiles/{z}/{x}/{y}.png
  • 页面放在项目根,模板直接写tiles/{z}/{x}/{y}.png
  • 页面嵌套在更深的子目录,就写../../tiles/{z}/{x}/{y}.png

如果不确定当前页面的基准位置,可以在开发者工具Console里执行document.baseURI查看页面基础地址,再去对照瓦片路径的层级。

常见的错误是把瓦片路径写成绝对路径,比如/tiles/{z}/{x}/{y}.png。这种写法在本地HTTP服务根目录下可能没问题,但一旦把资源包部署到某个子路径下(比如位置是http://server/gis-app/),路径就会失效。所以我一般建议demo里保持相对路径写法,部署时再根据实际环境统一替换。

提示:瓦片目录层级必须是{z}/{x}/{y}的结构,不要自作聪明改成{z}/{y}/{x}。Leaflet默认不做任何路径转换,模板怎么写,请求就怎么发。

4.3 用Network和Console验证地图是否正常

启动并打开页面后,按F12打开开发者工具,切到Network面板,刷新一次。筛选Img类型的请求,你会看到一批png瓦片请求,状态码应为200。如果出现大量404,说明瓦片路径配错了;如果请求根本不存在,说明tileLayer没有被成功加入地图。

还可以在Console里执行一段代码,直接看当前地图的缩放级别和中心点:

map.getZoom(); // 当前zoom map.getCenter(); // 当前中心LatLng

这两个方法在前后端联调时很常用。比如要拿当前可视范围去后端查询点位数据,用map.getBounds()取到矩形边界,再把东北角和西南角两个坐标拼进接口请求里。

5. 避坑指南:离线地图最常见的翻车现场与排查

5.1 页面打开后地图一片白

现象:目录结构没问题,HTML也能打开,但地图区域是灰白色,一张瓦片都不显示。

原因:大概率是直接双击HTML文件,以file://协议打开了页面。浏览器的同源策略阻止file协议下的JavaScript读取同目录之外的本地文件,瓦片请求被直接拦截。

解决:按第4章的方式启动一个本地HTTP服务,用http://localhost访问页面。这一个动作能解决七八成的离线地图空白问题。我第一次跑这类demo时也在这里卡了半天,当时还以为是瓦片包损坏,换了好几个解压工具重新解压,最后发现只是运行方式的问题。

5.2 点位偏移几百米:坐标系混用

现象:瓦片正常显示,但Marker点位和地图上的道路明显不重合,视觉偏移在几百米量级。

原因:坐标系不统一。瓦片数据如果是从国内图源下载的,坐标往往是GCJ-02坐标系,而GPS设备拿到的坐标是WGS-84坐标系。两者之间不是简单加一个固定数就能转换的,存在非线性偏移。

解决:拿到demo包后先确认瓦片来源的坐标系,再决定点位数据怎么处理。如果是GCJ-02瓦片,需要在打点前做坐标转换。我一般会单独维护一个coord-transform.js,把转换函数集中放一起,方便各处调用:

// WGS-84 转 GCJ-02,用于适配国内瓦片 function wgs84ToGcj02(lng, lat) { // 完整实现涉及经纬度偏移计算, // 这里用示意性的返回结构说明用法 return { lng: lng + parseFloat(offsetLng), lat: lat + parseFloat(offsetLat) }; }

上面的offsetLng、offsetLat在真实实现里是经过一系列三角运算算出来的,不是固定常量。实际项目中可以直接用现成的开源转换实现,但要注意测试坐标在转换前后的误差,我一般会用一组已知点比对,确认偏差在0.5米以内再投入业务。

5.3 缩放几级后瓦片变模糊或消失

现象:初始级别正常,缩放到某个级别后,瓦片开始变模糊,再放大直接没有内容。

原因:瓦片包只覆盖了有限的zoom层级,而L.tileLayer配置的maxZoom和瓦片实际层级不一致。比如瓦片只下载到zoom 12,地图却允许放大到maxZoom 18,超出12级之后就没有对应瓦片可加载。

解决:进入tiles目录,数一下有几个z编号的文件夹,最小值就是minZoom,最大值就是maxZoom,在页面配置里手动设置这两个值。也可以在Console里快速确认:

// 遍历瓦片目录层级,确认最大可用zoom var tileLayers = document.querySelectorAll('img.leaflet-tile'); tileLayers.forEach(function (img) { console.log(img.src); // 从src里能看出当前正在请求的z/x/y });

真实项目里我更倾向于直接限制maxZoom,因为补瓦片往往意味着重新走一遍下载流程,成本高,而业务上通常不需要无限放大。

5.4 Marker一多页面就卡

现象:数据量上了几百个Marker后,拖地图变得一顿一顿的。

原因:每个Marker在Leaflet里都是一个DOM元素,大量Marker会产生大量节点,浏览器渲染压力随之上升。

解决:引入markercluster插件做聚合。它的做法是把近距离的Marker合并成一个簇,根据zoom级别动态拆分。接入方式通常这样:

var markers = L.markerClusterGroup(); sites.forEach(function (item) { markers.addLayer(L.marker([item.lat, item.lng])); }); map.addLayer(markers);

聚合之后在zoom小的时候只渲染少量簇节点,性能改善非常明显。demo合集的libs目录里如果带了leaflet.markercluster.js,可以直接在页面里引;没带的话就得自己补一个到项目里。还有一个容易被忽略的点:Marker如果要显示不同图标,不要在一个循环里反复创建图标实例,应该把图标定义在循环外复用,不然内存占用会涨得很快。

5.5 瓦片请求过多导致拖拽卡顿

现象:拖拽地图时一直在发瓦片请求,松手后页面还要加载很久才能补全。

原因:Leaflet默认在拖拽过程中会持续根据当前视口请求瓦片,网络正常时问题不大,但在离线本地服务或低性能机器上,请求队列容易堆积。

解决:在tileLayer配置里设置updateWhenIdle: true,让地图在拖拽停顿后再更新瓦片,减少无谓的请求。同时用updateWhenZooming: false控制缩放手势结束前不重新加载瓦片,这两个参数组合起来能明显降低视觉碎片感。

6. 二次开发:把demo改造成自己的业务地图页面

6.1 用fetch替换固定点位数据

demo里的点位是硬编码的数组,真实项目中当然不能这么干。最简单的改造是把数据源换成fetch请求:

fetch('data/sample-points.json') .then(function (res) { return res.json(); }) .then(function (data) { data.forEach(function (item) { L.marker([item.lat, item.lng]) .bindPopup('<b>' + item.name + '</b><br>' + item.desc) .addTo(map); }); });

这里最关键的是让后端返回的JSON结构和demo里的sites数组保持一致,字段名对齐之后,页面代码一行都不用改。如果后端字段名不同,只需要在forEach回调里做一个字段映射,把接口字段转换成lat、lng、name、desc。

6.2 换瓦片源的两种方式

想换瓦片源,最直接的做法是用瓦片下载工具把新区域的瓦片按z/x/y格式下载到本地,整体替换tiles目录。其次,如果需要临时对比在线效果,也可以把tileLayer的模板临时换成在线URL,但记住这只能用于开发调试,不能进交付物。

最后说一个自己的教训:我最早接触这类demo包时,习惯性地把瓦片丢进目录就用file协议直接打开,结果被跨域问题卡了整整半天,后来才意识到离线地图的离线不只是数据离线,运行方式也要按离线环境来走——本地服务、相对路径、静态资源全部打包,缺一环都会翻车。从那以后我每次拿到新的地图资源包,都会先把目录结构和服务启动方式确认一遍,再动手改代码,跑通一个基础示例,然后才开始叠加业务逻辑。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询