做GIS前端这些年,有一个需求隔三差五就会冒出来:项目里要配一个地图底图,数据要尽量权威,显示上最好能一键切换“普通矢量地图”和“卫星影像”两种模式。做国内项目、对接自然资源业务的时候,天地图往往是最省事的底图来源,OpenLayers又是在线地图开发里最灵活的老牌框架。把这两者接到一起——加载矢量底图、加载影像底图、再做一层切换功能——几乎成了这类项目的标准动作。下面我就把这套完整链路拆开:从密钥申请、服务参数核对、代码实现,到切换交互设计、性能优化和踩坑记录,一次性讲透。适合已经会写基本HTML/JS、准备给项目接入天地图的前端读者,也适合刚接触地图开发、被WMTS这个词劝退的同学。
大概需要10分钟读完,读完你就能在自己项目里跑起来。
1. 为什么是OpenLayers+天地图:需求与方案选型
1.1 天地图在国产GIS项目里的定位
天地图是国家地理信息公共服务平台提供的在线地图服务,覆盖全国范围的多级瓦片数据,矢量底图、影像底图、地形晕渲、注记层都对外开放。在很多政务项目、自然资源项目、智慧城市项目里,底图“是否来自权威渠道”是一个硬指标。你用高德、百度虽然方便,但一旦涉及到和国土、规划、测绘数据叠加,数据来源和坐标基准就经常被单独拎出来过审。天地图天然带这个优势,它本身就是标准坐标框架下的公开地图服务,拿来当底图,省掉一多半解释成本。
但天地图也有它的问题:它的瓦片服务对前端开发者来说不如商业地图SDK那么“傻瓜”。没有现成的JS SDK给你一行new Tianditu.Map(),你得自己通过WMTS或XYZ接口拉瓦片。这就意味着你必须会一个地图框架,再把天地图的瓦片地址“喂”进去。于是OpenLayers成了很自然的选项。
1.2 矢量、影像、注记到底有什么区别
日常说“矢量底图”,并不是指在浏览器里渲染矢量数据,而是指天地图服务预先为我们切好的矢量风格瓦片。这类瓦片看起来像常规的电子地图:白色或浅灰色背景,有道路、行政边界、河流、绿地、地名等要素,视觉风格稳定,适合承载业务数据点、线、面的展示。
影像底图则是指卫星遥感影像瓦片,显示的是真实地表纹理,适合做巡检、核查、地物识别、外业底图。但影像图上的道路、地名不清晰,所以天地图还提供了一类“注记层”瓦片,专门叠加地名、路名、POI文字。这套体系是分开的:矢量底图配“矢量注记”,影像底图配“影像注记”。你切换底图的时候,注记层也必须跟着切,要不然影像上盖一套矢量风格的道路名称,视觉会很别扭。
1.3 地图框架为什么选OpenLayers
对比Leaflet、MapLibre GL、Cesium这些框架,OpenLayers最大的特点是“功能全且不挑数据源”。它的Layer-Source体系非常适合接瓦片服务:WMTS、WMS、XYZ、矢量瓦片、GeoJSON、聚合、热力都能归到统一模型里。天地图官方给的示例里也有OpenLayers版本,社区资料多,遇到问题基本都搜得到。
另外,OpenLayers内置了从EPSG:4326到EPSG:3857的投影转换能力,且默认的瓦片网格就是Web墨卡托标准,跟天地图的WMTS服务正好对上。如果只做二维底图浏览、数据叠加、图层切换,用OpenLayers属于“杀鸡用牛刀但刀很顺手”,没有明显短板。Leaflet当然也能做,但涉及到复杂多边形编辑、多图层叠加、后续要往三维扩展的时候,OpenLayers的过渡成本更低。
2. 接入天地图的第一步:密钥申请与服务参数核对
2.1 天地图开放平台的密钥获取全流程
天地图的瓦片服务并不是直接裸连就能用,所有请求都必须带一个tk参数,也就是访问密钥。
先去天地图官网注册账号,进入控制台或开发者中心,创建一个“应用”。创建应用的时候需要填应用名称、应用类型,一般选“浏览器端”就行。提交之后会拿到一个Key,有的地方叫“应用密钥”,这就是你要拼接在URL后面的tk值。
这里有一个容易忽略的点:天地图的Key通常和域名绑定,本地开发时用localhost或本机IP访问一般没问题,但部署到正式环境时,要提前把正式域名配置到应用的白名单里。否则上线之后遇到一片瓦片加载不出来的情况,八成就是域名没配好。我建议申请密钥之后,先在本地起一个简单的静态服务测试,确定Key可用,再去动业务代码。
2.2 WMTS服务地址结构解读
天地图的在线服务文档里,会给出标准的WMTS地址模板,典型格式长这样:
https://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=你的密钥拆开看:
vec_w中的vec表示矢量,_w表示Web墨卡托投影;如果看到vec_c,则是WGS84地理坐标系。img_w表示影像底图,img_c是WGS84投影的影像底图。cva_w是矢量注记层,cia_w是影像注记层。TILEMATRIXSET=w表示采用Web墨卡托瓦片矩阵,和_w对应。TILEMATRIX={z}是缩放层级,TILEROW={y}和TILECOL={x}是行列号。
新手最容易踩的坑是把vec_w和TILEMATRIXSET=w写混,比如用了vec_w服务,但TILEMATRIXSET写成c,结果瓦片全偏。还有一个坑是FORMAT,天地图的瓦片格式在WMTS接口里通常写tiles,有的同学照抄其他家写image/png,也可能导致服务不可用。
2.3 矢量与影像分别对应哪几组服务
整理一下,后面写代码要用到四组:
| 图层类型 | 服务名称 | 投影 | 用途 |
|---|---|---|---|
| 矢量底图 | vec_w | EPSG:3857 | 普通电子地图底图 |
| 矢量注记 | cva_w | EPSG:3857 | 叠加在矢量底图上的地名路名 |
| 影像底图 | img_w | EPSG:3857 | 卫星影像底图 |
| 影像注记 | cia_w | EPSG:3857 | 叠加在影像底图上的地名路名 |
如果项目统一用WGS84坐标,就把_w全部换成_c,同时TILEMATRIXSET改成c。但Web墨卡托是互联网地图的事实标准,前端展示我建议直接用_w系列。
3. 用OpenLayers加载天地图瓦片:核心代码解析
3.1 坐标投影与地图视图的初始化
先说一个经常被忽视的问题:坐标系必须前后一致。天地图_w系列瓦片是Web墨卡托投影,也就是EPSG:3857坐标系。你的OpenLayers地图视图也要用EPSG:3857做投影,否则地图会错位、旋转、甚至瓦片花屏。
初始化视图时,中心点坐标要用经纬度转成Web墨卡托坐标。OpenLayers提供了现成的转换方法fromLonLat,所以代码里可以这样写:
import Map from 'ol/Map'; import View from 'ol/View'; import { fromLonLat } from 'ol/proj'; const map = new Map({ target: 'map', view: new View({ projection: 'EPSG:3857', center: fromLonLat([116.39, 39.9]), zoom: 10 }) });这段代码的意思是:先把经纬度[116.39, 39.9]转成Web墨卡托坐标,再作为视图中心。如果你直接用经纬度坐标当视图中心,地图初始位置会跑到诡异的地方去。
3.2 用XYZ方式加载天地图:简单粗暴但有效
天地图的WMTS接口虽然是标准WMTS协议,但它的URL参数可以用{z}/{x}/{y}占位,所以直接用XYZ数据源加载也完全没问题。这也是我日常最常用的方式,因为少写很多配置代码。
import TileLayer from 'ol/layer/Tile'; import XYZ from 'ol/source/XYZ'; const tk = '你的天地图密钥'; const vecLayer = new TileLayer({ source: new XYZ({ urls: [ `https://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tk}`, `https://t1.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tk}`, `https://t2.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tk}` ], crossOrigin: 'anonymous' }) });这里用urls数组而不是单一url,是为了让OpenLayers轮询不同的天地图子域t0、t1、t2,把瓦片请求分散开。浏览器对同一域名的并发连接数是有限制的,如果只有一个子域,缩放地图时可能出现大量瓦片排队等待,看起来就是卡顿或局部白屏。
按同样的方式,再定义影像底图层、矢量注记层、影像注记层。注意四个图层都要用同一个tk密钥,且注记层的LAYER参数不能写错。
3.3 更标准的WMTS加载方式
如果你追求更标准的WMTS接入方式,也可以用OpenLayers的WMTS数据源,手动定义瓦片网格。这种方式的好处是语义清晰,缺点是代码量更大,容易写错参数。
import WMTS from 'ol/source/WMTS'; import WMTSTileGrid from 'ol/tilegrid/WMTS'; const resolutions = []; for (let i = 0; i <= 18; i++) { resolutions[i] = 156543.03392804097 / Math.pow(2, i); } const vecWmts = new TileLayer({ source: new WMTS({ url: `https://t0.tianditu.gov.cn/vec_w/wmts?tk=${tk}`, layer: 'vec', style: 'default', matrixSet: 'w', format: 'tiles', projection: 'EPSG:3857', tileGrid: new WMTSTileGrid({ origin: [-20037508.3427892, 20037508.3427892], resolutions: resolutions, matrixIds: resolutions.map((_, i) => String(i)) }), requestEncoding: 'KVP' }) });这里的origin是左上角坐标,resolutions是每一级地图比例尺对应的分辨率。天地图的标准瓦片规则和Google Map一致,第0级整张世界地图就是一张瓦片,然后逐级二分。实际上我很少手写这个网格,因为XYZ方式已经够用,但面试或写技术方案的时候,能说清楚WMTS的瓦片矩阵原理会加分很多。
3.4 把四个图层一起加到地图上
加载四个图层后,把它们全部add到地图里,默认情况下只开矢量底图和矢量注记,方便演示。
const imgLayer = new TileLayer({ source: ... }); const cvaLayer = new TileLayer({ source: ... }); const ciaLayer = new TileLayer({ source: ... }); vecLayer.setVisible(true); cvaLayer.setVisible(true); imgLayer.setVisible(false); ciaLayer.setVisible(false); map.addLayer(vecLayer); map.addLayer(cvaLayer); map.addLayer(imgLayer); map.addLayer(ciaLayer);图层的叠加顺序会影响显示效果:先放大底图,再放注记层。OpenLayers默认按添加顺序绘制,所以一定要把对应的注记层加在底图之后。
4. 矢量与影像切换功能的前端实现
4.1 切换的本质:四图层显隐控制
“切换功能”听起来高大上,本质就是控制四个图层的visible属性。这里不建议用removeLayer再addLayer的方式,因为那样会打断地图缓存的命中,每次切换都要重新拉瓦片,视觉上会闪一下。
更好的做法是预先创建好所有图层,一直挂在地图对象里,切换时只改显隐。
function switchToVector() { vecLayer.setVisible(true); cvaLayer.setVisible(true); imgLayer.setVisible(false); ciaLayer.setVisible(false); } function switchToImage() { imgLayer.setVisible(true); ciaLayer.setVisible(true); vecLayer.setVisible(false); cvaLayer.setVisible(false); }这段代码背后的逻辑是:setVisible(false)只是让OpenLayers不再绘制该图层,但已经加载的瓦片还留在缓存里。当你再切回来时,很多瓦片可以立刻显示,不需要重新请求,体感速度会快很多。
4.2 按钮交互与样式反馈
切换按钮可以用最简单的HTML实现,两个按钮分别绑定点击事件。用户点“矢量”时,按钮要有一个激活态,便于区分当前底图模式。
<div class="map-switch"> <button id="btn-vec" class="active">矢量底图</button> <button id="btn-img">影像底图</button> </div>.map-switch { position: absolute; top: 12px; right: 12px; z-index: 999; background: #fff; border-radius: 6px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); padding: 4px; } .map-switch button { border: none; background: transparent; padding: 8px 16px; cursor: pointer; border-radius: 4px; font-size: 14px; } .map-switch button.active { background: #2f6fed; color: #fff; }document.getElementById('btn-vec').addEventListener('click', function () { switchToVector(); this.classList.add('active'); document.getElementById('btn-img').classList.remove('active'); }); document.getElementById('btn-img').addEventListener('click', function () { switchToImage(); this.classList.add('active'); document.getElementById('btn-vec').classList.remove('active'); });按钮的激活态不是锦上添花,而是功能的一部分。用户切到影像模式后,如果没有激活态,过一会儿再回来很可能忘记当前处于什么模式,尤其是影像底图上再叠加业务图层时,视觉干扰很大。
4.3 地图状态在切换过程中的保持
切换过程中要注意地图的缩放级别和中心点不能重置。很多新手会误以为“切换底图”需要重新创建View对象,其实完全没必要。View只负责管理地图的观察位置,底图切换只是换了一层皮,位置应该保持不变。
function switchToImage() { imgLayer.setVisible(true); ciaLayer.setVisible(true); vecLayer.setVisible(false); cvaLayer.setVisible(false); // 不用动 map 的 view,中心和缩放级别天然保留 }这里有个经验:如果在切换前用户把地图缩放到很大级别,比如18级,影像底图在这个级别可能不如矢量底图清晰,会给人一种“切坏了”的错觉。实际不是坏了,是影像源本身的清晰度上限就在那里。遇到这种反馈,可以在切换时判断当前缩放级别,超过一定级别后自动缩回来:
const currentZoom = map.getView().getZoom(); if (currentZoom > 17) { map.getView().setZoom(17); }但这种强改用户缩放级别的行为要谨慎,最好只加在内部工具型项目里,避免在对外产品中惹恼用户。
4.4 切换后使用请求动画帧控制视觉平滑
快速连续点切换按钮时,图层显隐指令会频繁触发,地图可能在中间状态画出一两帧“没有注记层”的画面。一个简单的优化是,把切换逻辑包在requestAnimationFrame里,让所有图层显隐状态在同一帧之内完成切换:
let switching = false; function switchLayerSet(showVec, showImg) { if (switching) return; switching = true; requestAnimationFrame(() => { vecLayer.setVisible(showVec); cvaLayer.setVisible(showVec); imgLayer.setVisible(showImg); ciaLayer.setVisible(showImg); switching = false; }); }实际测试中,这种方式能明显减少切换时的一帧卡顿或闪烁。它的原理并不复杂:连续的操作被合并到同一个动画帧里,浏览器只重绘一次,地图不会中间状态。
5. 常见踩坑记录与性能调优建议
5.1 典型问题速查表
| 现象 | 常见原因 | 排查思路 |
|---|---|---|
| 所有瓦片都加载不出来 | tk密钥无效、未填、域名没加白名单 | 在浏览器直接访问瓦片URL看是否返回瓦片或错误信息 |
| 只有部分瓦片加载 | 子域轮询t0/t1/t2部分不可用 | 换成单一子域测试,确认是天图服务问题还是网络问题 |
| 地图偏移、有黑色锯齿状缺口 | 坐标系不匹配,用了vec_w但视图投影是4326 | 检查View的projection,以及TILEMATRIXSET参数 |
| 切换底图时注记层还是旧底图风格 | 切换逻辑没有同时控制注记层 | 用5.1中四图层分组管理方式统一控制 |
| 高DPI屏上文字发虚 | 没有启用天地图更高清瓦片 | 可叠加注记层做视觉补偿,或检查浏览器缩放比例 |
| 地图切换按钮遮挡业务控件 | 按钮层级和定位冲突 | 用独立容器自定z-index,避免纠缠到地图控件体系里 |
这张表里的每一个问题我都真实遇到过。尤其是第一个“所有瓦片加载不出来”,九成时候不是代码问题,而是密钥或域名白名单问题。我自己排查的时候有个习惯:先复制一条瓦片URL直接贴到浏览器地址栏,能打开就说明服务和密钥正常,打不开再顺着URL参数一项项查。
5.2 缓存、预加载与交互流畅度
地图瓦片加载的体验优化,可以从三个维度着手。
第一,设置图层的preload属性。preload表示提前加载视野范围外沿的瓦片,值越大预加载的圈数越多。设置为Infinity可以让地图平移时几乎无白屏,但第一次进入时请求数也会增加,需要权衡。
new TileLayer({ source: xyzSource, preload: Infinity });第二,让图层支持动画和交互过程中的瓦片更新。OpenLayers的TileLayer默认会在平移时暂停新瓦片加载,导致动起来边缘模糊。可以对业务地图开启updateWhileAnimating和updateWhileInteracting:
vecLayer.set('updateWhileAnimating', true); vecLayer.set('updateWhileInteracting', true);这个选项并不是所有场景都适用。如果地图上有大量业务矢量图层,开启后每次地图拖动都可能触发重绘,反而降低帧率。我的建议是:纯底图浏览项目可以开,业务编辑类项目别开。
第三,合理利用浏览器和天地图各自的HTTP缓存。天地图瓦片的URL是规范的{z}/{x}/{y}结构,浏览器会按URL缓存。所以代码里不要每次动态改URL参数顺序,参数顺序变了,同一个瓦片可能会被当成两个不同URL重复缓存,白白浪费请求。
5.3 投影和偏移问题:几乎每个人都会遇到一次
天地图发布过多种坐标系版本,老一点的项目文档里默认给的是WGS84地理坐标(即_c系列)。有些教程示例用的是_c,有些用的是_w,混着看就容易出事。
判断方法很简单:看瓦片URL里的_w还是_c,同时看TILEMATRIXSET参数。vec_w必须配matrixSet=w,vec_c必须配matrixSet=c。OpenLayers端的View投影用EPSG:3857对应_w,用EPSG:4326对应_c。
如果发现地图加载出来有轻微偏移,且所有瓦片都统一偏移,优先检查是否用了_w的瓦片却把View中心当成EPSG:4326经纬度来用。OpenLayers的fromLonLat默认把经纬度转到EPSG:3857,这个转换没问题,问题往往出在你自己写的坐标转换逻辑上。比如从后端拿到的是CGCS2000投影坐标,直接当成经纬度丢给前端,那偏移就大了。这种情况要先把数据坐标统一到EPSG:4326或EPSG:3857,再接瓦片。
5.4 更容易被忽略的法律与服务限制
天地图是公共平台,但不是无限免费。个人注册的开发者Key有访问量限制,生产环境如果日请求量很大,要注意控制台里的配额和使用量统计。另外,天地图的用户协议里对于数据使用、标注来源有明确要求,商业项目要提前确认合规。
在代码层面,最好给地图页面加一个异常兜底:当天地图瓦片加载失败时,给出一个友好的提示,而不是让用户面对一片灰色。OpenLayers可以监听图层的error事件来做统一处理。生产项目里我还会加一个“加载失败后自动重试”的逻辑,但重试次数控制在2到3次,避免无效请求把配额刷爆。
这整套方案我反复用过很多轮,最开始也是被WMTS参数折磨得够呛,后来把服务地址、投影关系、图层显隐分组捋顺之后,接入天地图就变成半小时的事。切换功能的思路也完全可以平移到Overlay层、业务图层的管理上:先建图层、再管显隐、最后补交互反馈,这套三层逻辑能覆盖大多数地图项目的图层控制需求。
最后分享一个小技巧:如果你后续要把这套底图方案嵌到微信小程序或者别的移动端容器里,记得优先用_w系列瓦片,同时把View的最大缩放级别限制在18级以内。移动端屏幕小,盲目放开到更高缩放级别只会让瓦片请求爆炸,体验反而更差。天地图本身在合理缩放范围内效果足够稳定,保持克制,地图用起来才顺手。