上一轮项目验收时,甲方在评审会上随口提了一句“能不能像手机地图那样,点一下就把街道图换成影像图”,我当时心想这不就是给map.basemap换个值的事。真动手才发现,arcgis js 里的切换底图远比想象中磨人:内置底图走的是在线样式服务,换个网络环境直接白屏;想挂自建的瓦片服务,切片级别差一级,整张图就整体错位;影像底图和注记底图拆成两个图层之后,切换顺序没处理好,注记会被压在业务图层下面看不见。
这也是我把“arcgis js 切换底图”单独拎出来写一篇的原因。它看着是入门级操作,但要做到既能用官方内置底图,也能挂自建服务,切换时不闪、不卡、不堆内存,是要踩不少坑的。下面我按 ArcGIS Maps SDK for JavaScript 4.x 为主线、3.x 顺带做对照,把方案选型、核心概念、可直接复制的代码、以及我自己踩过的坑全摊开讲一遍。前端基础和 GIS 基础都不太扎实的读者也能跟着做,已经能跑通官方 Demo 但想把它做成生产可用组件的,也能在里面找到能直接拿走的东西。
1. 先把需求拆开:底图切换到底在切什么
1.1 三类典型业务场景,决定了你的实现路径
做底图切换之前,先想清楚你到底是在解决哪一类需求,因为不同场景对应的实现成本差得很远。
第一类是展示型切换,比如给领导看汇报系统,点一下按钮在街道图、影像图、深色图之间来回换,纯粹为了视觉效果和演示体验。这种场景下内置底图就够用,代码量极小,重点在于切换时的过渡动画和按钮交互做得顺不顺。
第二类是业务型切换,比如国土、水利、林业的作业系统,需要在自建的影像服务、矢量服务之间切换,用来看不同时期的影像,或者对照不同专题数据。这种场景下你多半要挂自己的瓦片服务,切片方案、坐标参考、服务地址这些细节一个都不能错,也是最容易出错的一类。
第三类是联动型切换,底图变了,业务图层的符号、标注颜色、透明度也得跟着变。比如从浅色底图切到深色影像底图,原本浅灰的边界线在影像上根本看不见,得自动换成亮色。这种场景的重点不在切换本身,而在于切换之后的一系列状态同步。
先分清自己属于哪一类,后面的技术选型会清楚很多。很多新手一上来就奔着最复杂的自建服务去,结果发现内置底图几个小时就能搞定的事,硬生生折腾了一个星期。
1.2 为什么不能简单地 removeLayer 再 addLayer
从 3.x 迁移过来的开发者最容易犯的错,就是把底图当成一个普通图层来对待:切底图的时候先map.removeLayer(oldLayer),再map.addLayer(newLayer)。这个写法在 3.x 里勉强能跑,但在 4.x 里会让整个地图结构变得很混乱。
原因在于 4.x 把底图抽象成了一个独立对象Basemap,它不直接挂在map.layers这个集合里,而是挂在map.basemap上。map.layers里放的是业务图层(operational layers),比如你加载的点线面要素、专题图层。底图和业务图层是两个平行的层级,底图永远在最下面,业务图层永远叠在上面。
这个设计带来的好处很直接:你不用关心底图在第几层,也不用担心业务图层加进来之后把底图顶掉。切换底图的时候只要换掉map.basemap这个引用,4.x 内部会自动处理旧底图的销毁和新底图的加载,图层顺序永远是对的。
反过来说,如果你绕过basemap属性,手动往map.layers最底部塞一个瓦片图层当底图用,那就得自己维护层级、自己控制销毁、自己处理切换时的闪烁。能用,但属于给自己挖坑。
1.3 4.x 和 3.x 的机制对照
3.x 和 4.x 在这块的设计思路是两代人,如果你的项目还在 3.x 上,千万别照着 4.x 的文档抄。我把关键差异整理成一张表,方便你快速对号入座。
| 对比项 | 3.x(ArcGIS API for JavaScript) | 4.x(ArcGIS Maps SDK for JavaScript) |
|---|---|---|
| 底图入口 | map.basemap(字符串,来自esri/basemaps) | map.basemap(字符串 ID 或Basemap实例) |
| 自定义底图 | esri/basemaps注册自定义对象 | new Basemap({ baseLayers: [...] }) |
| 瓦片图层类 | ArcGISTiledMapServiceLayer、WebTiledLayer | TileLayer、WebTileLayer |
| 底图库组件 | esri/dijit/BasemapGallery | esri/widgets/BasemapGallery |
| 快速切换组件 | esri/dijit/BasemapToggle | 无独立组件,用BasemapGallery或自建按钮 |
| 图层销毁 | 手动map.removeLayer+layer.destroy() | 替换basemap时自动处理 |
| 层级控制 | 依赖map.addLayer(layer, index) | 底图与业务图层自动分层 |
有个细节值得单独说:3.x 的BasemapGallery在自定义底图时,格式是{ title, thumbnailUrl, layers: [...] }这样的普通对象数组;而 4.x 要求source里放的是Basemap实例或者LocalBasemapSource。两者长得像,但混用一定报错,迁移的时候这里是高频翻车点。
2. 核心概念:Basemap、baseLayers 与切片方案
2.1 Basemap 对象内部到底装了什么
Basemap这个类看着简单,里面其实有两层结构:baseLayers和referenceLayers。
baseLayers是底图的“底”,通常是影像或者矢量瓦片,负责铺满整个地图。referenceLayers是底图的“面”,通常是路网标注、地名注记、边界线这些叠加信息,它画在baseLayers之上、业务图层之下。
拿官方内置的hybrid来说,它本质上就是一张影像baseLayers加一层注记referenceLayers。这个拆分不是随便设计的,它解决了一个很实际的问题:你想把影像和注记分开控制的时候,不用去拼 URL,直接改referenceLayers的可见性就行。
构造一个自定义Basemap的写法大概是这样:
import Basemap from "esri/Basemap"; import WebTileLayer from "esri/layers/WebTileLayer"; const imgLayer = new WebTileLayer({ id: "tdtImg", urlTemplate: "https://t{subDomain}.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={level}&TILEROW={row}&TILECOL={col}&tk=你的KEY", subDomains: ["0", "1", "2", "3", "4", "5", "6", "7"], tileInfo: tdtTileInfo }); const ciaLayer = new WebTileLayer({ id: "tdtCia", urlTemplate: "https://t{subDomain}.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={level}&TILEROW={row}&TILECOL={col}&tk=你的KEY", subDomains: ["0", "1", "2", "3", "4", "5", "6", "7"], tileInfo: tdtTileInfo }); const tdtBasemap = new Basemap({ id: "tianditu-img", title: "天地图影像", thumbnailUrl: "https://你的域名/thumb/tdt-img.png", baseLayers: [imgLayer], referenceLayers: [ciaLayer] });注意:
baseLayers和referenceLayers接收的都是图层实例,同一个图层实例不能同时塞进两个不同的Basemap里,否则切换时会出现“图层已被销毁”的报错。
2.2 baseLayers 与业务图层的层级关系
很多人会问一个问题:业务图层怎么保证永远在注记之上?答案是根本不用管,4.x 已经帮你分好了。
渲染顺序从下到上是:baseLayers→referenceLayers→map.layers里的业务图层。你往map.layers里加多少东西,都不会跑到底图下面去。
这个规则有个例外情况需要注意:如果你在业务图层里用了elevationInfo或者把图层设成了地面图层,那它是贴在地表上的,视觉上会被底图“压住”一部分,这不是层级问题,是三维场景的地形贴合逻辑。二维场景下不用操心这个。
另一个容易绕晕的点是Map和WebMap。WebMap是从门户加载的地图配置,它同时包含了底图定义和业务图层定义。如果你用的是WebMap再map.basemap = xxx去覆盖底图,改的是内存里的实例,不会写回门户。这个特性在“同一个 WebMap 给多个项目复用、各自换不同底图”的场景里非常好用。
2.3 tileInfo 为什么决定成败
这一节是全文最硬核的部分,也是自建底图错位问题的根源所在。
瓦片服务的本质是把地图切成一层层金字塔,每一层叫一个 Level。每个 Level 对应一个分辨率(resolution)和比例尺(scale),同时还规定了瓦片的**原点(origin)**在哪、每张瓦片多少像素、坐标系是什么。这一整套描述,在 4.x 里就叫TileInfo。
问题在于,不同厂商对 Level 的编号起点不一样。Esri 自己的服务从 Level 0 开始,而天地图这类 WMTS 服务的TILEMATRIX从 Level 1 开始。如果你直接用WebTileLayer的默认tileInfo,它的 Level 从 0 起算,URL 里的{level}就会从 0 开始往外发请求,而服务端只认从 1 开始,结果就是要么请求 404,要么每一级瓦片都对不上,看起来像是地图整体向左上角偏移了一个瓦片的位置。
解决办法就是自己定义一套TileInfo,把 LOD 数组的 Level 从 1 开始编号。Web Mercator(WKID 3857)下,第 n 级的分辨率计算公式是:
resolution(n) = 156543.03392800014 / 2^n scale(n) = 591657527.591555 / 2^n其中156543.03392800014是赤道处 Level 0 的米/像素,591657527.591555是 Level 0 的比例尺分母。这两个常数背下来不现实,但可以用代码生成:
const lods = []; for (let i = 1; i <= 18; i++) { lods.push({ level: i, resolution: 156543.03392800014 / Math.pow(2, i), scale: 591657527.591555 / Math.pow(2, i) }); }原点固定用 Web Mercator 左上角的(-20037508.342787, 20037508.342787),DPI 用 96,瓦片尺寸 256×256。这套参数拼出来的TileInfo就能和天地图的w投影矩阵集严丝合缝地对上。
提示:LOD 不是越多越好。天地图公开服务一般提供到 18 级左右,你写到 20 级虽然不报错,但用户放大到那一级时会一直转圈。宁可少写几级,也别让用户看到一片空白。
3. 实操:从内置底图到自定义底图的完整切换实现
3.1 内置底图:一行代码就能切
先说最简单的。4.x 从 4.15 版本开始,map.basemap可以直接赋字符串 ID,内部会自动解析成对应的Basemap实例:
// 初始化的时候 const map = new Map({ basemap: "topo-vector" }); // 切换的时候 view.map.basemap = "satellite";常用 ID 我列几个实际项目里高频使用的:
streets-vector:矢量街道图,浅色,适合做业务叠加;topo-vector:地形图,带等高线,适合户外和规划类;gray-vector:灰色底图,做专题图时最能突出主题色;dark-gray-vector:深灰底图,做大屏和夜间模式首选;satellite:纯影像;hybrid:影像加注记,实际项目里用得最多;osm:开放街图,不依赖在线样式服务。
这里有个版本坑必须说:4.29 之后,内置底图的取图方式改走 Basemap Styles Service,矢量样式需要携带 API Key 或者开启公共访问,服务地址形态变成https://basemapstyles-api.arcgis.com/arcgis/rest/services/styles/v2/styles/arcgis/streets。如果你的项目在内网环境,或者用的账号没有对应权限,直接赋字符串会出现底图一片空白但地图容器正常的情况。遇到这种情况,要么配apiKey,要么老老实实挂自建服务。
注意:
map.basemap = "satellite"和map.basemap = new Basemap({...})可以混用,但不要在切换的瞬间把旧的Basemap实例做destroy(),4.x 内部会处理旧实例的释放。手动销毁反而会触发“对象已被销毁”的连锁报错。
3.2 按钮组切换:最贴合业务习惯的做法
官方组件是底图库式的,点开一个面板选。但国内很多项目的交互习惯是页面上直接排几个按钮,点一下立刻切。这个自己做比改组件省事得多。
HTML 部分:
<div id="basemapBar"> <button>#basemapBar { position: absolute; top: 16px; left: 60px; z-index: 10; display: flex; gap: 6px; background: rgba(255, 255, 255, 0.9); padding: 6px; border-radius: 4px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); } #basemapBar button { border: 1px solid #ddd; background: #fff; padding: 4px 12px; cursor: pointer; font-size: 13px; border-radius: 3px; } #basemapBar button.active { background: #0079c1; color: #fff; border-color: #0079c1; }JS 部分:
import Map from "esri/Map"; import MapView from "esri/views/MapView"; const map = new Map({ basemap: "hybrid" }); const view = new MapView({ container: "viewDiv", map: map, center: [116.397, 39.908], zoom: 11 }); const bar = document.getElementById("basemapBar"); bar.addEventListener("click", (e) => { const btn = e.target.closest("button"); if (!btn) return; const id = btn.dataset.basemap; if (map.basemap && map.basemap.id === id) return; view.map.basemap = id; bar.querySelectorAll("button").forEach((b) => b.classList.remove("active")); btn.classList.add("active"); });这段代码有几个细节值得说。第一,e.target.closest("button")是为了防止你以后往按钮里塞图标元素导致e.target变成图标而不是按钮。第二,切换前先判断map.basemap.id是否已经是目标底图,避免重复加载,这个判断在用户手快连点的时候能省下大量请求。第三,高亮状态用类名控制,而不是直接改style,方便后续做主题切换。
3.3 BasemapGallery:官方组件怎么用才不出错
如果你需要的是“点开面板,看到缩略图再选”的交互,那就用官方的BasemapGallery。最简用法:
import BasemapGallery from "esri/widgets/BasemapGallery"; import Expand from "esri/widgets/Expand"; const basemapGallery = new BasemapGallery({ view: view }); const expand = new Expand({ view: view, content: basemapGallery, expandIcon: "basemap", group: "top-right" }); view.ui.add(expand, "top-right");默认情况下,BasemapGallery会去门户拉取底图列表,也就是说它依赖网络和门户权限。在国内很多内网项目里,这一步会卡很久或者直接拉空。这时候必须自定义source:
import Basemap from "esri/Basemap"; import WebTileLayer from "esri/layers/WebTileLayer"; const customBasemaps = [ new Basemap({ id: "tdt-vec", title: "天地图矢量", thumbnailUrl: "./thumbs/vec.png", baseLayers: [new WebTileLayer({ /* 矢量 URL */ })] }), new Basemap({ id: "tdt-img", title: "天地图影像", thumbnailUrl: "./thumbs/img.png", baseLayers: [new WebTileLayer({ /* 影像 URL */ })] }) ]; const basemapGallery = new BasemapGallery({ view: view, source: customBasemaps });thumbnailUrl我强烈建议用本地图片,不要图省事直接用服务地址。原因有两个:一是内网环境下外链图片加载不出来,面板里全是破图;二是外链图片的加载速度不可控,底图库打开时会有一瞬间的空白跳变。本地放几张 80×80 的 PNG,整个面板打开速度会快很多,体验完全是两个层次。
参数上还有个小细节:BasemapGallery的source也可以传LocalBasemapSource对象,它的好处是能声明式地配置本地底图列表,配合代理服务处理跨域和 Key 隐藏,适合做多环境部署。如果你的项目要在开发、测试、生产三套环境用不同的服务地址,用LocalBasemapSource会省掉大量条件判断。
3.4 挂自建瓦片服务:天地图 WMTS 完整实战
这是整篇内容里最有价值的一段,因为网上大部分教程要么只贴了 URL 没讲TileInfo,要么参数写错导致错位。
完整的TileInfo构造:
import TileInfo from "esri/layers/support/TileInfo"; import SpatialReference from "esri/geometry/SpatialReference"; import Point from "esri/geometry/Point"; const lods = []; for (let i = 1; i <= 18; i++) { lods.push({ level: i, resolution: 156543.03392800014 / Math.pow(2, i), scale: 591657527.591555 / Math.pow(2, i) }); } const tdtTileInfo = new TileInfo({ dpi: 96, rows: 256, cols: 256, compressionQuality: 0.9, origin: new Point({ x: -20037508.342787, y: 20037508.342787, spatialReference: new SpatialReference({ wkid: 3857 }) }), spatialReference: new SpatialReference({ wkid: 3857 }), lods: lods });然后是图层和底图的拼装:
const TK = "你申请到的KEY"; const makeTdtLayer = (layerName, id) => new WebTileLayer({ id: id, urlTemplate: `https://t{subDomain}.tianditu.gov.cn/${layerName}_w/wmts` + `?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=${layerName}` + `&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles` + `&TILEMATRIX={level}&TILEROW={row}&TILECOL={col}&tk=${TK}`, subDomains: ["0", "1", "2", "3", "4", "5", "6", "7"], tileInfo: tdtTileInfo, copyright: "天地图" }); const tdtVec = new Basemap({ id: "tdt-vec", title: "天地图矢量", baseLayers: [makeTdtLayer("vec", "tdtVecBase")], referenceLayers: [makeTdtLayer("cva", "tdtVecRef")] }); const tdtImg = new Basemap({ id: "tdt-img", title: "天地图影像", baseLayers: [makeTdtLayer("img", "tdtImgBase")], referenceLayers: [makeTdtLayer("cia", "tdtImgRef")] }); // 切换 view.map.basemap = tdtImg;这里有个跨域问题必须提前处理。直接用WebTileLayer请求瓦片,浏览器会因为画布污染(canvas tainted)在某些操作上报安全错误,比如导出图片、截图、叠加自定义 WebGL 图层的时候。生产环境的做法是把瓦片请求走一层自己的服务端代理,既解决跨域,又能把 Key 藏在服务端不暴露在前端代码里。内网部署时这一步几乎是标配,千万别把 Key 硬编码在打包后的 JS 里,反编译一下就能拿到。
另外,URL 里我用了https而不是http。如果你的页面是 https 的,混用 http 请求会被浏览器直接拦掉,表现为请求都发出去了但一张图都没回来,控制台里一堆 Mixed Content 警告。这个坑我在三个项目里都遇到过,每次都是排查半天才发现是协议问题。
3.5 还在 3.x 的项目怎么写
如果项目锁在 3.x 上,写法完全不一样。3.x 里切换底图的思路是换图层:
require([ "esri/map", "esri/basemaps", "esri/layers/WebTiledLayer", "esri/layers/ArcGISTiledMapServiceLayer", "esri/dijit/BasemapToggle", "dojo/domReady!" ], function (Map, esriBasemaps, WebTiledLayer, ArcGISTiledMapServiceLayer, BasemapToggle) { var map = new Map("mapDiv", { basemap: "streets", center: [116.397, 39.908], zoom: 11 }); // 注册一个自定义底图 esriBasemaps.tdtImg = { baseMapLayers: [{ url: "https://你的服务地址/MapServer" }], title: "自定义影像底图" }; // 一键切换 map.setBasemap("tdtImg"); // 或者用官方切换组件做双底图来回切 var toggle = new BasemapToggle({ map: map, basemap: "hybrid" }, "BasemapToggle"); toggle.startup(); });3.x 用WebTiledLayer挂外部瓦片的时候,麻烦程度比 4.x 高不少,因为它要求你同时提供tileInfo、extent、initialExtent一整套参数,少一个就可能不显示。而且 3.x 的${level}是从 0 开始还是 1 开始,取决于你自己传的tileInfo里lods的定义,没有默认值兜底。
如果你的团队还在维护 3.x 项目,我的建议是把自定义底图的服务地址统一收敛到一个配置对象里,切换逻辑封装成一个函数,将来升级到 4.x 的时候,改动的范围能控制在两三个文件之内。
4. 常见问题与排查技巧实录
4.1 切完底图一片白,容器大小正常
这是出现频率最高的一个问题,排查顺序我总结成了固定动作。
第一步看控制台 Network,筛选瓦片请求。如果请求根本没发出去,说明底图对象没构造成功,大概率是模块导入路径写错或者Basemap实例被重复销毁。如果请求发出去了但返回 403、401,那是 Key 或者权限问题;返回 404,那是 URL 拼接错了,重点检查{level}{row}{col}这三个占位符有没有被正确替换。
第二步看返回的图片本身。把某一条瓦片请求的 URL 复制到浏览器地址栏直接打开,如果浏览器能显示图片,说明服务没问题,那就是前端参数的问题;如果浏览器也打不开,问题在服务端,别在前端死磕。
第三步看spatialReference是否匹配。视图的坐标系如果是wkid: 4326,而底图是 Web Mercator 的 3857,那就是底层不兼容,地图要么不显示,要么显示在一个莫名其妙的位置。这种问题在混用地理坐标系和投影坐标系的项目里特别常见。
提示:调试阶段可以给
WebTileLayer加一个on("layerview-create-error", ...)监听,把错误信息打到控制台。默认情况下瓦片加载失败是静默的,不报错也不提示,全靠你自己去 Network 面板里翻。
4.2 瓦片能出来但整体错位
错位是最典型的TileInfo问题,表现是地图内容整体向左上或右下偏移固定的距离,而且越放大偏移越明显。
判断方法很直接:把地图缩到最小级别,看第一级瓦片能不能对齐。如果第一级就是歪的,那是原点(origin)错了;如果第一级正常、放大之后开始歪,那是 LOD 数组的分辨率算错了,通常是 Level 编号起点差了 1。
前面讲的天地图场景,绝大多数错位都是 Level 从 0 开始而不是 1。改法就是把lods生成循环里的i从 1 开始,同时resolution除数改成Math.pow(2, i)而不是Math.pow(2, i - 1)。
还有一个隐蔽的错位源:dpi参数。有的服务是按 90.7 DPI 切的,你按 96 算,缩放到一定级别后会出现几个像素的累积误差。这种情况不多见,但在做高精度测量的时候会影响结果。如果项目对精度敏感,建议在切片方案上和提供服务的一方对齐 DPI 参数。
4.3 注记被业务图层压住或者干脆看不见
这个问题的根源是把注记当成了业务图层加进map.layers。正确的做法是把它放进Basemap的referenceLayers里,让 4.x 自动管理层级。
如果注记已经放进referenceLayers还是看不见,检查两个地方。一是referenceLayers里的图层有没有设visible: false,二是影像底图本身太亮导致白色注记看不清。后者属于视觉问题不是技术问题,解决办法是在影像上加一层半透明遮罩,或者把注记图层的opacity调到 0.8 左右做轻微柔化。
还有一种情况是注记和影像来自不同时期的数据,边界对不上。这不是切换底图的技术问题,属于数据源问题,得回去找数据提供方对齐版本,前端这边怎么调都调不好。
4.4 反复切换之后内存一路涨
频繁切换底图是最容易暴露内存问题的操作。每次map.basemap = xxx,旧底图的图层会被释放,但如果你在代码里自己持有Basemap实例的数组,同时又在别的地方引用了同一个WebTileLayer实例,就可能出现图层被释放了但引用还在的情况,destroy之后再次访问就报错。
我的做法是构造一次、全局复用。在应用初始化的时候把所有底图实例建好,放在一个对象里,切换的时候只做引用替换:
const basemapPool = { "tdt-vec": tdtVec, "tdt-img": tdtImg, "gray": "gray-vector" }; function switchBasemap(key) { const target = basemapPool[key]; if (!target) return; if (map.basemap === target) return; map.basemap = target; }注意这里判断的是===而不是比较id,因为字符串 ID 和Basemap实例混用的时候,比较id会出现“明明切过去了但判断成立”的情况。
另外,切换的时候最好加一点节流。用户连点五下按钮就发五轮瓦片请求,浏览器缓存再好也扛不住。我一般用一个 300 毫秒的防抖,切换动作只在用户停下来之后执行一次。
4.5 问题速查表
把上面这些整理成一张表,出问题的时候直接对号入座,能省下不少翻文档的时间。
| 现象 | 最可能的原因 | 快速验证方式 | 处理办法 |
|---|---|---|---|
| 底图区域全白,无请求 | 底图对象未构造成功 | 控制台断点看map.basemap | 检查模块引入与实例销毁时机 |
| 请求返回 403 | Key 失效或权限不足 | 浏览器直开瓦片 URL | 换 Key 或走服务端代理 |
| 请求返回 404 | URL 占位符拼接错误 | 检查 Network 里的完整 URL | 核对{level}{row}{col}拼写 |
| 瓦片整体偏移 | TileInfo的 LOD 起点错误 | 缩到最小级看第一级 | LOD 从 1 开始编号 |
| 放大后渐渐偏移 | 分辨率公式算错 | 对比官方 LOD 数值 | 用2^n而不是2^(n-1) |
| 注记不显示 | 放进了map.layers | 查看图层所在集合 | 移到referenceLayers |
| HTTPS 页面加载不出图 | 混合内容被拦截 | 控制台 Mixed Content 警告 | 瓦片地址统一改 https |
| 切换多次后报错 | 图层实例被重复销毁 | 打印报错堆栈 | 实例构造一次、全局复用 |
| 面板缩略图破图 | 外链图片不可达 | 直开图片地址 | 改用本地 PNG 缩略图 |
5. 工程化封装:让切换底图这件事可维护
5.1 把底图配置抽成 JSON
底图的服务地址、Key、缩略图路径这些东西,散在代码里是最难维护的。多环境部署的时候,开发环境的服务地址和生产的完全不一样,硬编码就意味着每次上线都要改代码、重新打包。
我的做法是抽一个basemaps.json:
{ "default": "tdt-img", "list": [ { "id": "tdt-vec", "title": "矢量底图", "type": "wmts", "service": "vec", "thumbnail": "./thumbs/vec.png", "reference": "cva" }, { "id": "tdt-img", "title": "影像底图", "type": "wmts", "service": "img", "thumbnail": "./thumbs/img.png", "reference": "cia" }, { "id": "gray", "title": "灰色底图", "type": "builtin", "service": "gray-vector" } ] }然后写一个工厂函数,根据type分支生成对应的Basemap。builtin类型直接返回字符串 ID,wmts类型走前文的makeTdtLayer逻辑。这样新增一个底图,只需要在 JSON 里加一条记录,代码一行都不用动。
这套做法在做过三个以上地图项目的团队里基本是共识,因为底图服务变更的频率远高于业务逻辑,把易变的部分隔离出来,维护成本能降一个数量级。
5.2 切换时的加载状态与过渡处理
底图切换不是瞬间完成的,尤其是影像底图,瓦片量大、单张体积也大,视觉上会有明显的空白期。不做处理的话,用户会看到地图闪一下变成白屏,再一块一块地填满,体验很差。
我通常做两件事。第一,把正在加载的底图先设一层较低的opacity,等layerview-create事件触发后再过渡到 1。第二,在切换的时候弹一个轻量的加载指示器,几百毫秒内就消失,用户心理感受会好很多。
async function switchBasemapSmooth(key) { const loading = showLoading(); try { map.basemap = basemapPool[key]; await view.whenLayerView(map.basemap.baseLayers.getItemAt(0)); } catch (err) { console.error("底图切换失败:", err); } finally { hideLoading(loading); } }whenLayerView返回的是一个 Promise,图层视图创建完成后才 resolve。用它做加载状态的开关,比用固定时长的定时器靠谱得多——网络快的时候不拖沓,网络慢的时候也不会提前关掉。
5.3 底图切换与业务图层的联动
最后一件事,也是很多项目上线之后才发现的体验问题:底图变了,业务图层没跟着变,结果就是“深色底图上画深色线”这种灾难性的视觉效果。
我的处理方式是在底图配置里预留一个theme字段,值可以是light或者dark,切换底图的时候同步派发一个事件,业务图层去监听这个事件调整自己的符号:
// 底图切换后派发 document.dispatchEvent(new CustomEvent("basemap-change", { detail: { id: key, theme: currentTheme } })); // 业务图层侧监听 document.addEventListener("basemap-change", (e) => { const color = e.detail.theme === "dark" ? [255, 235, 59] : [30, 30, 30]; boundaryLayer.renderer = createRenderer(color); });用自定义事件而不是直接调用,是为了解耦。底图模块不需要知道有哪些业务图层,业务图层也不需要知道底图是怎么切的,双方只依赖一个事件名和一个数据格式。将来加新图层的时候,只要监听同一个事件就行,不用回头改底图切换的代码。
这里有个性能细节要注意:renderer重新赋值会触发整个图层的重绘,如果图层要素有几万个,会卡顿一两秒。优化办法是把符号颜色做成visualVariables里的字段驱动,切换时只改一个全局变量再刷新,而不是重建整个 renderer。或者在图层数量大的时候,干脆不做实时换色,只在用户手动触发“适配主题”的时候才执行一次。
我个人在这块的经验是:底图切换的联动不要做太“聪明”,自动换色听起来很美好,但实际项目里符号颜色的业务含义很重,自动改容易改出问题。更稳妥的做法是把主题适配做成一个显式按钮,让用户自己决定什么时候切。