腾讯地图瓦片接入方案:一个 Bun 代理,让 Mapbox / Leaflet / OpenLayers 通用
@toc

一句话方案 :用一个 Bun 写的瓦片代理,把腾讯瓦片的两条私有规则收在服务端,对外只暴露标准 {z}/{x}/{y} 模板。前端三个引擎都用同一行 URL 接入,不用改引擎代码。
源码在这里(完整可跑,无第三方依赖):github.com/giszhc/tenc...
一、先看效果
OpenLayers 加载腾讯地形图,全国视角:

地形图的价值在山区才看得出来。这是川西,晕渲、等高线、沟谷走向都很清楚,右边是成都平原:

同一个页面切影像底图,路网和地名注记叠在影像上:

矢量底图:

Leaflet 和 Mapbox GL JS 加载的完全是同一批瓦片:


三个引擎的可视范围、瓦片层级、底图内容是同一套,切换引擎不用重新调底图。
二、问题出在哪
腾讯的瓦片服务没有公开文档,规则只能实测。用 node 的原生 fetch 逐级打,看状态码、字节数和文件头:
js
const res = await fetch(url, { headers: { Referer: 'https://map.qq.com/', 'User-Agent': 'Mozilla/5.0' } })
const buf = Buffer.from(await res.arrayBuffer())
console.log(res.status, buf.length, buf.subarray(0, 4).toString('hex'))
// ffd8ffe0 → JPEG 89504e47 → PNG 696e7661 → "inva"(无数据)
打出来两条硬规则:
规则一:行号是 TMS 方向,y=0 在最南端,标准 XYZ 的 y 必须翻过来:
js
const ry = 2 ** z - 1 - y
不翻的话,demTiles 直接返回 0 字节,sateTiles 返回一张空白占位图。两种都不报错,很容易往别的方向查半天。
规则二:路径前两段不是 z 和 x ,而是 x>>4 和 ry>>4,每 16×16 张瓦片归一个子目录:
bash
https://p{s}.map.gtimg.com/demTiles/{z}/{x>>4}/{ry>>4}/{x}_{ry}.jpg
层级上限同样是实测出来的:
| 图层 | 有效层级 | 超限时的表现 |
|---|---|---|
terrain 地形 |
1--15 | z≥16 返回 9 字节纯文本 inva |
img 影像 |
3--18 | z≥19 返回空白占位图 |
vec 矢量 |
1--18 | --- |
那为什么不能在前端直接处理?
OpenLayers 有 tileUrlFunction、腾讯自家的 GL JS 有 getTileUrl,它们确实能在前端消化掉这两条规则。但 Mapbox GL JS 的 raster source 只接受 {z}/{x}/{y} 这种占位符模板,没有"用代码算 URL"的入口 。我一开始试了 addProtocol,绕一圈才发现那是 MapLibre 的能力,Mapbox GL JS 根本没这个 API;社区有个 mapbox-gl-custom-protocol 做 polyfill,只适配 v1.9.1,太旧。Leaflet 的 tileLayer 同理,只能填模板。
所以只能把规则挪到服务端。
三、解决思路

代理对外只有一个接口:
ini
GET /tencent/map/{layer}/{z}/{x}/{y}
layer = terrain | img | vec
核心就是把两条私有规则消化掉,二十来行:
ts
function upstreamUrl(layer, z, x, y, styleid) {
const ry = 2 ** z - 1 - y // XYZ → TMS
const s = ['0', '1', '2', '3'][(x + ry) % 4] // 子域
const dir = LAYERS[layer].dir
if (!dir) { // 矢量
return `https://rt${s}.map.gtimg.com/tile?z=${z}&x=${x}&y=${ry}&type=vector&styleid=${styleid}`
}
return `https://p${s}.map.gtimg.com/${dir}/${z}/${x >> 4}/${ry >> 4}/${x}_${ry}.jpg`
}
三件事值得单独说。
① 参数守卫要当成 SSRF 防线来写。 layer 走枚举白名单,z 卡在实测区间,x/y 卡在 0 ~ 2^z-1。这样拼出来的 URL 只可能落到腾讯那几个固定域名上,用户传什么值都跑不出去。顺带解决另一个问题:前端如果没配好 maxzoom,请求会直接被 404 挡住,不会打到上游。
② 图片类型必须看魔数,不能信 content-type。 这是我在这个项目上踩得最久的一个坑:
| 服务 | 上游返回的 content-type | 实际字节 |
|---|---|---|
demTiles 地形 |
image/png |
JPEG |
sateTiles 影像 |
image/jpeg |
JPEG |
tile?type=vector 矢量 |
application/octet-stream |
PNG |
我第一版是按 content-type.startsWith('image/') 判断有效性的,结果 vec 整层 404。查了半天才发现矢量瓦片的 content-type 是 application/octet-stream,被我的判断逻辑当成"上游无此瓦片"给拦了。改成看文件头就正常了:
js
function sniffImageType(buf) {
const b = new Uint8Array(buf, 0, 4)
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'
if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'image/png'
if (b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46) return 'image/webp'
return null // 顺带挡掉无数据时返回的纯文本 "inva"
}
③ 两级缓存。 响应带 Cache-Control: public, max-age=604800, immutable,瓦片内容基本不变,长缓存能挡掉绝大部分回源;服务端再挂一层内存 LRU,按插入顺序淘汰。实测回源 100ms 级别,缓存命中降到 1--3ms。
四、三端接入代码
代理跑起来之后,三个引擎都是一行 URL 的事。
bash
bun run tencent-tile-proxy.ts
Mapbox GL JS
js
map.addSource('terrain', {
type: 'raster',
tiles: ['http://localhost:8787/tencent/map/terrain/{z}/{x}/{y}'],
tileSize: 256,
minzoom: 1,
maxzoom: 15, // 数据上限;地图 maxZoom 可设更大,靠 overzoom 兜住
attribution: '© 腾讯地图'
})
map.addLayer({ id: 'terrain', type: 'raster', source: 'terrain' })
Leaflet
js
L.tileLayer('http://localhost:8787/tencent/map/terrain/{z}/{x}/{y}', {
minNativeZoom: 1,
maxNativeZoom: 15,
maxZoom: 18,
attribution: '© 腾讯地图'
}).addTo(map)
OpenLayers
js
new ol.source.XYZ({
url: 'http://localhost:8787/tencent/map/terrain/{z}/{x}/{y}',
tileGrid: ol.tilegrid.createXYZ({ minZoom: 1, maxZoom: 15, tileSize: 256 }),
attributions: '© 腾讯地图'
})
五、踩坑记录
这一节是这篇文章里最值钱的部分,都是实测撞出来的。
坑 1:Mapbox 的 zoom 和另外两家不是一个基准
三个页面我一开始都写的 zoom: 4,看着没毛病,直到把可视范围量出来对比:
| 引擎 | 配置 zoom | 可视经度跨度 | 请求的瓦片层级 |
|---|---|---|---|
| OpenLayers | 4 | 103.71° | z4 |
| Leaflet | 4 | 103.71° | z4 |
| Mapbox | 4 | 51.86° | z5 |
Mapbox 的 zoom 定义在 512px 瓦片上,Leaflet 和 OpenLayers 是 256px 基准。同样写 zoom: 4,Mapbox 的视野只有别家的一半------它的 4 相当于别家的 5。想让三个页面视野一致,Mapbox 得写 zoom: 3。
顺带弄明白一件容易搞混的事:raster source 的 tileSize 只决定"去请求哪一级瓦片",不影响屏幕上的地理范围。设成 256 时,实际请求层级是 min(mapZoom + 1, source.maxzoom)。也就是说 tileSize 换不了 zoom 基准,只能靠改 zoom 值对齐。
坑 2:Mapbox 没有有效 token 一定白屏
这个坑的迷惑性在于它的表现:
瓦片请求全部 200,
load事件正常触发,areTilesLoaded()返回true,但画面就是白的。

翻了下 v3 的压缩源码,里面有个 _revokeAuth(),函数体里有一句无条件的清屏:
js
_revokeAuth() {
const gl = this.painter.context.gl
// ...
gl.clear(gl.DEPTH_BUFFER_BIT | gl.COLOR_BUFFER_BIT | gl.STENCIL_BUFFER_BIT)
// ...
}
Mapbox 在地图 load 之后会调 _authenticate(),拿 token 去服务端校验一次,失败就走 _revokeAuth(),把 WebGL 画布擦干净。
我试过几种绕法,都没用:
| 配置 | 结果 |
|---|---|
testMode: true |
白屏 |
config.REQUIRE_ACCESS_TOKEN = false |
白屏 |
| 换成 v2.15.0 | 白屏 |
REQUIRE_ACCESS_TOKEN = false 只跳过构造函数里那次"有没有传 token"的检查,跳不过 load 之后的服务端校验。别在这上面花时间,填自己的 token 是唯一解。
坑 3:token 别写进要推 GitHub 的文件
我用自己账号的 pk token,第一版直接写在 HTML 里,push 的时候被 GitHub 拦了:
yaml
GH013: Repository rule violations found for refs/heads/main
------ Mapbox Secret Access Token ------
path: tencent-terrain-mapbox.html:119
GitHub 的 secret scanning 不区分你这是公共示例 token 还是私钥,只要格式对就拦。改成从查询参数读就行:
js
const MAPBOX_TOKEN = new URLSearchParams(location.search).get('token') || ''
用法 /mapbox?token=pk.xxxxx,token 不进代码库。这个习惯对任何 pk. / sk. 开头的第三方 token 都适用。
坑 4:让注记层压在底图上,三个引擎是三套写法
地形图本身没有地名,要叠一层矢量注记就得保证注记永远在最上面。这件事三个引擎实现完全不同,一套代码抄不了三遍。
Leaflet 得自己建 pane,不能靠添加顺序------切底图的时候,后 addTo 的底图会盖住先加进去的注记:
js
map.createPane('labels')
map.getPane('labels').style.zIndex = 300 // tilePane 是 200
L.tileLayer(url, { pane: 'labels' })
OpenLayers 靠图层数组顺序,数组后面的画在上面。有个坑:vec 既可能当底图、也可能当注记叠加,这两种状态必须用两个不同的 layer 实例,共用一个实例可见性会互相打架。
Mapbox 是 style 里 layers 数组的顺序,注记放最后。
坑 5:怎么判断 WebGL 地图到底渲染出来没有
白屏这件事,光断言"瓦片请求 200"是判断不出来的,得看画面。我的做法是 headless 截图之后数像素,这里我自己还踩了一次:
一开始按"非白像素占比"判,阈值取 240,结果白屏的页面也测出了 94% ------因为地图容器的底色 #eef1f5,R 值是 238,低于 240,整片被算成了非白。
换成在地图区域取一块样、数不同颜色数才靠谱:
js
const d = ctx.getImageData(420, 200, 500, 400).data // 只取中间一块,避开面板和控件
const colors = new Set()
for (let i = 0; i < d.length; i += 4) {
colors.add(d[i] + ',' + d[i+1] + ',' + d[i+2])
}
// 有瓦片:颜色数 2 万以上;白屏:颜色数 = 1(纯底色)
实测:带 token 颜色数 24797,无 token 颜色数是 1。
六、两个还没解决的问题
坐标系是 GCJ-02。 腾讯全系底图都是火星坐标,直接往上叠 GPS 或 shp 数据会整体偏移三四百米------我在北京天安门那边量出来是 597 米。业务数据得先做 WGS-84 到 GCJ-02 的转换。
地形底图没有地名路网。 demTiles 只有晕渲、等高线和高程注记,找地方得叠一层矢量注记。叠的时候把地形透明度压到 60% 左右比较好看。
代理自身还有个限制:内存缓存不落盘,进程重启就没了。要对外提供服务记得加鉴权,这版没内置,只在源码里留了 ALLOWED_ORIGINS 来源白名单。
源码与运行
css
tencent-tile-proxy.ts 代理服务(内含 Leaflet 演示页)
tencent-terrain-ol.html OpenLayers 演示页
tencent-terrain-mapbox.html Mapbox GL JS 演示页
bash
git clone git@github.com:giszhc/tencent-service-proxy.git
cd tencent-service-proxy
bun run tencent-tile-proxy.ts
然后打开 http://localhost:8787/。三个演示页同一套版式,底图可在地形 / 影像 / 矢量之间切,能叠矢量注记、调不透明度。数据和代码都来自腾讯地图的公开瓦片服务,领土表示由腾讯负责。