十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

高德地图JS SDK离线部署实战:内网环境下的完整解决方案

高德地图JS SDK离线部署实战:内网环境下的完整解决方案 1. 项目缘起为什么我们需要一个离线的高德JS SDK最近在做一个政府内网项目客户现场的网络环境是严格物理隔离的别说访问外网了连个U盘都插不进去。项目里有个地图展示模块最初的设计是直接调用高德地图的在线JavaScript API这在开发环境里跑得飞快一切正常。但到了部署环节我们傻眼了——地图页面一片空白控制台里全是网络请求失败的红色错误。这其实是一个典型的“离线部署”需求场景远不止于内网项目。比如一些对数据安全和稳定性要求极高的金融、能源企业内部系统一些部署在偏远地区、网络信号时好时坏的移动端应用甚至是一些需要将地图作为基础能力打包进桌面客户端的产品都会面临同样的问题如何让依赖在线服务的Web地图在完全或间歇性断网的环境下稳定工作高德地图的官方JavaScript API本质上是一个动态加载的、强依赖其云端服务的SDK。当你引入一个https://webapi.amap.com/maps?v2.0key你的key这样的脚本时它会在运行时去加载更多的地图瓦片、样式、字体、搜索、路径规划等模块资源。一旦网络断开这个链条就断了。所以“高德JS离线部署”的核心不是简单地把一个amap.js文件下载下来就完事了而是要构建一个完整的、可独立运行的本地地图服务环境。这涉及到资源下载、本地化改造、服务代理和缓存策略等一系列工程化问题。接下来我将基于一个真实的项目实践拆解从零构建一套高德JS离线部署方案的完整过程包括踩过的坑和最终验证有效的解决方案。2. 离线资源获取官方工具、逆向分析与合法边界实现离线化的第一步也是最关键的一步就是获取所有必要的静态资源。这里我们必须先明确一个重要的前提任何对在线地图服务的离线化使用都必须严格遵守高德开放平台的服务条款仅用于授权的、非商业化的内部项目绝对禁止用于任何形式的二次分发或商业用途。在合规的前提下我们主要有以下几种资源获取思路。2.1 官方渠道MapDownloader 工具的局限性高德开放平台为移动端原生开发提供了“离线地图”下载功能主要面向Android和iOS SDK可以下载指定城市的矢量或影像地图数据包。然而对于Web端JS API所需的资源官方并没有提供一个官方的、完整的打包下载工具。我们尝试过使用官方的“自定义地图”样式编辑器导出样式JSON文件。这确实能得到地图的视觉样式配置但它只是一个“配方”而不是“食材”。这个JSON文件里引用的精灵图sprite、字体glyphs、瓦片tiles模板等资源地址依然指向高德的在线服务器。仅仅拥有这个JSON在离线环境下是无法渲染出地图的。所以官方直接提供的工具无法满足Web JS API的完整离线化需求我们不得不转向其他方法。2.2 核心资源剖析一个在线地图页面加载了什么要“打包”一个东西首先得知道它里面有什么。我们打开浏览器开发者工具的网络面板Network清空缓存后访问一个使用了高德JS API的标准地图页面。你会观察到一系列关键的请求它们构成了离线化的目标清单主SDK脚本 (amap.js)这是入口文件通常版本号固定相对容易获取。地图样式JSON文件定义了地图所有图层的颜色、图标、文字样式等。请求地址通常包含style.json或styles关键字。精灵图 (Sprite)一个包含所有地图图标如POI标记、道路符号等的大图文件PNG及其对应的索引文件JSON。请求路径通常包含/sprite/。字体文件 (Glyphs)地图上用于渲染文字如路名、地名的字体切片。通常是很多个小的PBFProtocol Buffer Binary Format文件请求路径包含/fonts/。地图瓦片 (Tiles)这是数据量最大的部分。根据地图缩放级别zoom level和位置x, y动态请求成千上万的图片栅格瓦片或矢量数据块矢量瓦片。路径模式通常为/{z}/{x}/{y}.png栅格或/{z}/{x}/{y}.pbf矢量。注意高德地图目前主推的是矢量地图其瓦片和数据格式可能涉及复杂的编码和协议。直接下载并本地化矢量瓦片在技术和合规层面都异常复杂通常不是离线部署的首选。更务实的方案是使用栅格瓦片图片虽然数据量更大但处理起来更直接。2.3 实践方法基于浏览器缓存的资源抓取在合规和项目授权的前提下一种可行的技术方案是“缓存收割”。其原理是在联网环境下通过程序化操作如使用Puppeteer无头浏览器模拟用户行为系统地浏览需要离线化的地理区域让浏览器自然地发出所有资源请求并缓存下来然后我们从缓存存储如Chrome的IndexedDB或Cache Storage中将这些资源导出。这个过程可以大致分为以下步骤编写爬取脚本使用Node.js和Puppeteer库启动一个浏览器实例。加载地图并设定范围导航到你的地图页面通过JS API控制地图移动和缩放确保覆盖你业务需要的所有地理区域和缩放级别。例如你需要北京五环内从缩放级别10到18的瓦片就需要让地图遍历这个范围内的所有位置。监听与存储在页面中注入脚本监听所有网络请求特别是匹配瓦片URL模式的请求。可以将请求的URL和返回的响应体图片或数据直接存储到本地文件系统或数据库中。处理特殊资源对于sprite和glyphs它们的URL通常比较固定可以直接根据在样式JSON中发现的URL模板进行批量请求和下载。这个方法的挑战在于规模控制。一个城市中等缩放级别的瓦片数量可能是百万级的全量下载不现实。因此必须根据项目的实际展示需求精确界定地理边界和缩放级别范围做一个最小可用集合的离线包。// 一个非常简化的Puppeteer思路示例实际代码复杂得多 const puppeteer require(puppeteer); const fs require(fs).promises; const path require(path); (async () { const browser await puppeteer.launch(); const page await browser.newPage(); // 监听并拦截响应 await page.setRequestInterception(true); page.on(request, interceptedRequest { const url interceptedRequest.url(); // 只处理瓦片请求 if (url.includes(/maps/vt?) url.includes(x) url.includes(y) url.includes(z)) { // 允许请求继续但我们会在‘response’事件中处理它 interceptedRequest.continue(); } else { interceptedRequest.continue(); } }); page.on(response, async response { const url response.url(); if (url.includes(/maps/vt?)) { const buffer await response.buffer(); // 从URL中解析出z, x, y参数 // ... 解析逻辑 ... const filePath path.join(__dirname, tiles, z${z}, x${x}, y${y}.png); await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, buffer); console.log(Saved: ${filePath}); } }); // 加载你的地图页面 await page.goto(http://your-internal-page-with-map.html); // 通过evaluate执行地图平移、缩放操作触发瓦片加载 // ... 地图操作逻辑 ... // 等待足够长时间确保瓦片加载完毕 await new Promise(resolve setTimeout(resolve, 30000)); await browser.close(); })();3. 本地化改造让高德JS SDK指向你的服务器获取到资源只是拥有了“食材”下一步是修改“菜谱”让高德JS SDK不再去高德的厨房找食材而是来我们自己的本地厨房。这需要对SDK的请求行为进行重定向。3.1 修改地图样式Style JSON下载到的地图样式JSON文件里面包含了所有资源的基础URLspriteglyphssources中的瓦片模板tiles。我们需要将这些URL全部替换成本地服务器的地址。例如原样式中可能有一项sprite: https://webapi.amap.com/restapi/static/sprite/2.0, glyphs: https://webapi.amap.com/restapi/static/fonts/{fontstack}/{range}.pbf, sources: { amap-tiles: { type: raster, tiles: [https://webapi.amap.com/tile?x{x}y{y}z{z}langzh_cnsize1scale1style7] } }我们需要将其改为指向本地服务的地址假设我们的本地服务器运行在http://localhost:8080/offline-amapsprite: http://localhost:8080/offline-amap/sprite/sprite, glyphs: http://localhost:8080/offline-amap/fonts/{fontstack}/{range}.pbf, sources: { amap-tiles: { type: raster, tiles: [http://localhost:8080/offline-amap/tiles/{z}/{x}/{y}.png] } }注意sprite的URL去掉了版本号并且通常需要同时提供.png和.json文件所以这里指向的是基础路径spriteSDK会自动去请求sprite.png和sprite.json。3.2 拦截与代理SDK动态请求仅仅修改样式文件还不够因为高德JS SDK在初始化时以及运行过程中还会动态请求一些配置、版本信息、服务接口等。这些请求的域名是硬编码在SDK里的。我们无法修改编译后的amap.js文件但可以在前端层面进行请求拦截。我们可以在加载amap.js之前注入一段全局脚本重写浏览器原生的fetch和XMLHttpRequest方法对指向高德域名如webapi.amap.com,restapi.amap.com的请求进行拦截并将其URL重写为本地代理地址。// 在引入 amap.js 的script标签之前插入此脚本 (function() { var originalFetch window.fetch; var originalXHROpen XMLHttpRequest.prototype.open; var originalXHRSend XMLHttpRequest.prototype.send; // 代理配置将在线地址映射到本地地址 var proxyRules [ { online: https://webapi.amap.com, local: http://localhost:8080/offline-amap/proxy }, { online: https://restapi.amap.com, local: http://localhost:8080/offline-amap/proxy } // 可以添加更多规则 ]; // 重写 fetch window.fetch function(resource, init) { var url typeof resource string ? resource : resource.url; var proxiedUrl url; for (var rule of proxyRules) { if (url.startsWith(rule.online)) { proxiedUrl url.replace(rule.online, rule.local); // 可以在这里添加自定义请求头用于本地服务器识别 if (init) { init.headers { ...init.headers, X-Original-Url: url }; } else { init { headers: { X-Original-Url: url } }; } break; } } console.log([Proxy] Fetch:, url, -, proxiedUrl); return originalFetch.call(this, proxiedUrl, init); }; // 重写 XMLHttpRequest.open XMLHttpRequest.prototype.open function(method, url, async, user, password) { this._originalUrl url; // 保存原始URL var proxiedUrl url; for (var rule of proxyRules) { if (url url.startsWith(rule.online)) { proxiedUrl url.replace(rule.online, rule.local); break; } } console.log([Proxy] XHR Open:, url, -, proxiedUrl); // 调用原始的open方法但使用代理后的URL return originalXHROpen.call(this, method, proxiedUrl, async, user, password); }; // 重写 XMLHttpRequest.send在发送前可以设置自定义头 XMLHttpRequest.prototype.send function(body) { if (this._originalUrl) { for (var rule of proxyRules) { if (this._originalUrl.startsWith(rule.online)) { this.setRequestHeader(X-Original-Url, this._originalUrl); break; } } } return originalXHRSend.call(this, body); }; })();这段代码的作用是“偷梁换柱”。所有发往高德在线服务的请求在发出前都会被我们修改目标地址指向本地的代理服务器http://localhost:8080/offline-amap/proxy。同时我们将原始的请求URL通过自定义请求头如X-Original-Url传递给代理服务器这样代理服务器就知道客户端原本想要请求什么资源。4. 构建本地地图服务Nginx配置与资源组织现在我们有了本地资源也修改了前端的请求指向。接下来需要一个本地服务器来响应这些请求并提供正确的资源。Nginx是一个轻量且高效的选择它非常适合做静态资源服务和简单的路由代理。4.1 资源目录结构规划首先将我们抓取到的资源按照一定的规则组织在服务器目录下例如/offline-amap/ ├── index.html # 你的应用主页面 ├── js/ │ └── amap.js # 高德JS SDK主文件需提前下载固定版本 ├── styles/ │ └── custom-style.json # 修改后的本地样式文件 ├── sprite/ │ ├── sprite.png │ └── sprite.json ├── fonts/ │ ├── Noto Sans Regular/ │ │ ├── 0-255.pbf │ │ └── ...更多字体切片 │ └── ...其他字体 └── tiles/ # 瓦片目录按z/x/y组织 ├── 10/ │ ├── 100/ │ │ ├── 200.png │ │ └── ... │ └── ... ├── 11/ └── ...4.2 Nginx 关键配置解析Nginx的配置核心是两件事一是正确响应静态文件请求如瓦片、精灵图、字体二是将SDK的动态API请求代理到我们准备好的静态文件或进行逻辑处理。以下是一个关键的Nginx配置片段server { listen 8080; server_name localhost; root /path/to/your/offline-amap; # 你的资源根目录 index index.html; # 1. 静态资源服务直接映射URL到文件系统 location ~ ^/(sprite|fonts|tiles|styles|js)/ { try_files $uri $uri/ 404; # 设置正确的MIME类型尤其对于.pbf字体文件 location ~ \.pbf$ { add_header Content-Type application/x-protobuf; } location ~ \.json$ { add_header Content-Type application/json; } location ~ \.(png|jpg)$ { expires 1y; # 长期缓存 add_header Cache-Control public, immutable; } } # 2. 动态请求代理处理被前端脚本重写的API请求 location /offline-amap/proxy/ { # 这个位置接收所有被前端重写过来的请求 # 请求头中包含原始的URL: X-Original-Url: https://webapi.amap.com/... # 2.1 首先尝试将其视为静态资源请求进行匹配 # 例如如果原始URL是地图瓦片我们将其路径模式转换为本地路径 # 假设原始瓦片URL模式为https://webapi.amap.com/tile?x100y50z10... if ($http_x_original_url ~* ^https://webapi\.amap\.com/tile\?.*x(\d)y(\d)z(\d)) { set $tile_x $1; set $tile_y $2; set $tile_z $3; # 重写到本地的瓦片文件路径 rewrite ^ /tiles/$tile_z/$tile_x/$tile_y.png break; } # 2.2 处理精灵图请求 if ($http_x_original_url ~* ^https://webapi\.amap\.com/restapi/static/sprite/(.*)) { rewrite ^ /sprite/$1 break; } # 2.3 处理字体请求 if ($http_x_original_url ~* ^https://webapi\.amap\.com/restapi/static/fonts/(.*)) { rewrite ^ /fonts/$1 break; } # 2.4 对于其他无法静态化的API请求如地点搜索、路径规划在离线环境下本应失效 # 我们可以返回一个预设的、空的或模拟的响应确保SDK不报错阻塞 default_type application/json; return 200 {status:0, info:OFFLINE_MODE, data:[]}; # 状态码0通常在高德API中表示请求失败我们通过自定义的info字段说明是离线模式。 } # 3. 主应用入口 location / { try_files $uri $uri/ /index.html; } }这个配置的精髓在于/offline-amap/proxy/这个location块。它利用Nginx的$http_x_original_url变量对应请求头X-Original-Url获取前端想要请求的真实高德地址然后通过一系列正则匹配和rewrite规则将在线URL模式“翻译”成本地静态文件的路径。对于在离线环境下无法实现的动态服务如搜索我们直接返回一个无害的模拟响应让前端SDK能够继续执行而不崩溃。5. 前端集成与初始化让离线地图跑起来服务端准备好后前端页面需要正确集成。这里有几个至关重要的细节直接决定了离线方案能否成功。5.1 正确的脚本加载顺序你的HTML文件头部脚本的加载顺序必须是请求拦截脚本首先注入我们写的那个重写fetch和XMLHttpRequest的脚本。必须保证它在高德SDK加载之前执行才能成功拦截SDK发出的所有请求。高德SDK脚本然后加载我们本地保存的固定版本的amap.js。绝对不要使用高德官方的动态加载链接因为那个链接本身可能就会请求在线配置。应该将amap.js下载到本地然后通过相对路径引用。你的业务逻辑脚本最后加载你自己写的地图初始化代码。!DOCTYPE html html langzh-CN head meta charsetUTF-8 title离线高德地图Demo/title style#container {width: 100%; height: 600px;}/style /head body div idcontainer/div !-- 1. 首先注入代理脚本 -- script // 将上一节中的请求拦截代码完整地放在这里 (function(){ /* ... 拦截代码 ... */ })(); /script !-- 2. 加载本地化的高德SDK -- script src./js/amap.js/script !-- 注意这里不需要再写key参数因为所有请求都被我们代理了key校验在离线环境下已失效 -- !-- 3. 你的地图初始化代码 -- script window.onload function() { // 使用本地样式文件初始化地图 var map new AMap.Map(container, { viewMode: 2D, // 使用2D模式3D模式可能需要更多离线资源 zoom: 11, center: [116.397428, 39.90923], // 北京中心点 mapStyle: http://localhost:8080/offline-amap/styles/custom-style.json // 指向本地样式 // 注意这里不能使用amap://styles/...这种在线样式ID必须使用完整的URL指向本地文件。 }); // 添加一个标记测试基础功能是否正常 var marker new AMap.Marker({ position: [116.397428, 39.90923], title: 离线地图测试点 }); map.add(marker); console.log(离线地图初始化完成); }; /script /body /html5.2 初始化配置的避坑点在离线环境下初始化AMap对象时有几个参数需要特别注意mapStyle这是最重要的配置项。必须设置为你的本地样式JSON文件的完整URL路径。不能使用高德官方提供的样式ID字符串如amap://styles/light因为SDK会试图根据这个ID去在线查询。viewMode建议优先使用2D。3D模式可能会触发加载更多3D相关的资源如地形、建筑模型这些资源的离线化更为复杂。在2D模式下我们准备的栅格瓦片和精灵图通常就够用了。features可以显式指定需要关闭一些离线环境下无法使用的功能如[bg, point, road]等但通常不是必须的。AMap.plugin离线环境下绝大部分需要网络调用的插件都无法使用如AMap.ToolBar、AMap.Scale等控件本身可能可用但其依赖的图标资源需要确保在本地sprite中。而AMap.PlaceSearch地点搜索、AMap.Driving驾车导航等数据服务插件加载了也无法使用建议不要在离线环境中加载。6. 实测中的挑战与解决方案在实际部署和测试过程中我们遇到了几个颇具代表性的问题它们的解决方案是这套离线方案能否稳定运行的关键。6.1 跨域问题 (CORS) 的终极解决这是离线部署中最常见也最棘手的问题之一。当你从http://localhost:8080加载页面而页面中的JavaScript试图通过fetch或XHR去请求http://localhost:8080/offline-amap/tiles/...时如果响应头中没有正确的CORS策略浏览器会阻止请求。我们的解决方案是在Nginx层统一添加CORS响应头。修改上面的Nginx配置在服务静态资源的location块和代理location块中都添加如下头部location ~ ^/(sprite|fonts|tiles|styles|js)/ { # ... 其他配置 ... add_header Access-Control-Allow-Origin * always; add_header Access-Control-Allow-Methods GET, OPTIONS always; add_header Access-Control-Allow-Headers DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,X-Original-Url always; if ($request_method OPTIONS) { return 204; } } location /offline-amap/proxy/ { # ... 其他配置正则匹配、重写等... add_header Access-Control-Allow-Origin * always; add_header Access-Control-Allow-Methods GET, POST, OPTIONS always; add_header Access-Control-Allow-Headers DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,X-Original-Url always; if ($request_method OPTIONS) { return 204; } }关键点在于always参数确保即使Nginx返回404或错误码时也携带CORS头以及处理OPTIONS预检请求。Access-Control-Allow-Headers里一定要包含我们自定义的X-Original-Url。6.2 瓦片路径与URL模板的匹配陷阱高德在线瓦片的URL参数非常灵活可能包含x,y,z,lang,size,scale,style等多个参数。我们在Nginx中用正则表达式if ($http_x_original_url ~* ^https://webapi\.amap\.com/tile\?.*x(\d)y(\d)z(\d))来提取核心的x,y,z。但这里有个隐患参数顺序可能变化或者使用amp;实体。更健壮的做法是使用Nginx的map指令或lua模块来解析查询字符串或者在前端拦截时就将参数标准化。一个更简单的实践方案是在抓取瓦片时就按照{z}/{x}/{y}.png的目录结构保存文件。然后在前端请求拦截层不仅替换域名还直接将完整的查询字符串URL重写为这种目录路径格式再发给Nginx。这样Nginx的配置就简化为直接按路径查找文件更加可靠。6.3 字体文件 (Glyphs) 请求的404问题地图在渲染文字时会根据需要动态请求字体切片例如/fonts/Noto Sans Regular/0-255.pbf。如果你本地字体目录的结构或命名与SDK期望的不一致就会导致404地图上的文字无法显示。解决方案确保你下载的字体文件目录结构和文件名与样式JSON中glyphsURL模板以及SDK实际发出的请求完全匹配。通常你需要下载一整套完整的字体范围例如0-255, 256-511, ...。可以使用专门的工具如node-fontnik或glyphhanger来生成和转换字体切片但更直接的方法是从浏览器缓存中抓取时确保抓全了所有被请求的.pbf文件并保持其原始路径。6.4 离线环境下SDK的“锁死”与超时高德SDK在初始化时可能会尝试调用一些在线服务来检查版本、配置或密钥有效性。在离线环境下这些请求会失败。如果SDK设计得不够健壮可能会陷入等待或报错导致整个地图对象无法创建。我们的应对策略是利用请求拦截进行“降级”和“模拟”。在拦截脚本中不仅重写URL还可以针对特定的、已知的“健康检查”或“配置请求”URL直接返回一个模拟的成功响应避免SDK进入错误状态。这需要对SDK的网络行为有一定的分析和归纳。例如如果发现SDK初始化时总会请求https://webapi.amap.com/loader我们可以在拦截逻辑中这样处理// 在拦截脚本的fetch或XHR重写逻辑中增加 if (url.includes(/loader)) { // 直接返回一个模拟的、简单的成功响应避免SDK等待或报错 return Promise.resolve(new Response(JSON.stringify({status: 1, version: 2.0}), { status: 200, headers: {Content-Type: application/json} })); }7. 方案优化与进阶思考完成基础离线化后可以考虑以下几个方向进行优化提升方案的健壮性和用户体验。7.1 资源缓存与更新策略离线资源包可能很大尤其是瓦片。如何管理增量更新设计一个版本管理机制。本地存储当前资源包的版本号。当在线环境有更新如地图样式、POI图标变更时可以提供一个差异包列表通过内部网络进行增量下载和替换。浏览器本地存储对于完全静态的内网应用可以考虑使用Service Worker将关键资源如SDK主文件、样式、核心精灵图缓存到浏览器端实现秒开甚至第二次访问的完全离线。压缩与懒加载对瓦片图片进行有损压缩如WebP格式可以显著减少包体积。对于非核心区域或高缩放级别的瓦片可以采用按需懒加载的策略而不是一次性打包全部。7.2 混合模式在线优先离线降级对于网络不稳定而非完全断网的环境可以设计一个智能的混合模式。健康检查页面加载时先尝试请求一个小的在线资源如一个轻量级的API接口检测网络连通性。动态切换如果在线则使用在线的SDK链接和样式如果超时或失败则自动切换到本地拦截脚本和本地资源路径。缓存在线资源在线模式下利用浏览器缓存或Service Worker积极缓存地图资源为可能的网络中断做准备。这种模式对前端代码的抽象能力要求较高需要将地图初始化、样式设置等操作封装成与数据源无关的接口。7.3 使用开源瓦片服务器替代如果你对地图数据源没有强制要求一个更彻底的方案是放弃高德SDK转而使用开源地图库如Leaflet、MapLibre GL JS搭配开源或自建的地图瓦片服务如OpenStreetMap的瓦片或使用GDAL等工具自制瓦片。这样整个技术栈都是可控、可离线的避免了逆向和代理在线服务的复杂性与合规风险。当然这意味着一套完全不同的技术选型和数据准备工作。回过头看高德JS离线部署方案是一个典型的“戴着镣铐跳舞”的工程实践。它不是在推荐一种标准做法而是在特定约束条件下必须使用高德、必须离线通过技术手段打通的一条路径。整个过程充满了细节上的挑战从资源获取的合法性探讨到请求拦截的精细操作再到服务配置的种种陷阱每一步都需要仔细验证。这套方案的成功运行最终让我们那个内网项目的地图模块在完全无网的环境下稳定展示达到了业务的基本要求。但它也像一座精心维护的“盆景”任何外部依赖如SDK版本的升级都可能需要重新适配。因此在项目启动之初如果有可能与客户或产品明确离线场景的具体边界和长期维护成本或许是比技术实现更优先的一步。
返回列表