第06章 - 瓦片图层与 OGC 地图服务
栅格瓦片是 Leaflet 里唯一”能撑满全屏且不卡”的底图形态。瓦片图层的类层次只有三层:
GridLayer 管"格子":算可见范围、创建/回收 DOM 元素、控制透明度与层级
└── TileLayer 管"URL":把 {z}/{x}/{y} 填进模板,加载 <img>
└── WMSTileLayer 管"参数":把 WMS 的 bbox、layers、srs/crs 拼进 URL
这个分层决定了扩展方式:想换瓦片来源,继承 TileLayer 重写 getTileUrl;想换瓦片内容形态(canvas、div、video),继承 GridLayer 实现 createTile。搞混这两个扩展点,会写出”拿不到 URL”或”瓦片不显示”的代码。
6.1 GridLayer:格子管理器
6.1.1 它做什么
GridLayer 不关心瓦片是什么。它的职责是:根据当前视图算出需要哪些 (x, y, z),为每个格子调用 createTile 拿到一个 HTMLElement,把它绝对定位到正确位置,并在移出视野后回收。
关键选项:
| 选项 | 默认值 | 说明 |
|---|---|---|
tileSize |
256 |
瓦片边长(像素);可传 Point(w, h) 用非方形瓦片 |
opacity |
1 |
整层透明度,可运行时 setOpacity() |
updateWhenIdle |
false |
true 时只在平移结束后加载新瓦片(省请求,拖动时留白) |
updateWhenZooming |
true |
缩放动画过程中是否逐级更新 |
updateInterval |
200 |
平移时最多每 200 ms 更新一次 |
keepBuffer |
2 |
视野外保留几圈瓦片再回收 |
minZoom / maxZoom |
0 / undefined |
本层可见的层级范围 |
minNativeZoom / maxNativeZoom |
undefined |
瓦片服务实际拥有的层级;超出时用边界层级缩放显示 |
noWrap |
false |
true 时不在水平方向重复世界副本 |
pane |
'tilePane' |
默认在瓦片 pane |
zIndex |
1 |
图层叠放顺序 |
bounds |
null |
只在这些经纬度范围内加载 |
minNativeZoom / maxNativeZoom 是最常被忽略的一对。它们解决的是”地图能缩放到 19,但服务商只提供到 17”的问题:
new TileLayer('https://tiles.example.com/{z}/{x}/{y}.png', {
minZoom: 0,
maxZoom: 19,
maxNativeZoom: 17 // 18、19 级用 17 级的瓦片放大显示
}).addTo(map);
源码里的实现是一个简单的夹取:
_clampZoom(zoom) {
const options = this.options;
if (undefined !== options.minNativeZoom && zoom < options.minNativeZoom) {
return options.minNativeZoom;
}
if (undefined !== options.maxNativeZoom && options.maxNativeZoom < zoom) {
return options.maxNativeZoom;
}
return zoom;
}
所以 18 级时实际请求的是 /{z}/ 填 17 的瓦片,再由浏览器放大。代价是模糊,收益是不会出现”放大后一片空白”。
6.1.2 继承 GridLayer 画自定义瓦片
GridLayer 唯一的必须实现是 createTile(coords, done?),返回一个 HTMLElement。下面的例子在瓦片上画坐标文字,做瓦片网格调试:
import {LeafletMap, GridLayer, DomUtil} from 'leaflet';
class CoordTileLayer extends GridLayer {
createTile(coords) {
const tile = DomUtil.create('div', 'leaflet-tile');
const size = this.getTileSize();
tile.width = size.x;
tile.height = size.y;
tile.style.cssText = 'font:12px monospace;color:#333;background:#fff;border:1px solid #ccc;';
tile.textContent = coords.x + ',' + coords.y + ',' + coords.z;
return tile;
}
}
const map = new LeafletMap('map').setView([30.27, 120.15], 4);
new CoordTileLayer().addTo(map);
异步场景(例如用第三方库渲染到 canvas)用第二个参数 done:
createTile(coords, done) {
const canvas = document.createElement('canvas');
const size = this.getTileSize();
canvas.width = size.x;
canvas.height = size.y;
renderAsync(coords, canvas)
.then(() => done(null, canvas))
.catch(err => done(err, canvas));
return canvas; // 必须同步返回元素,done 只负责通知"画完了"
}
done(err) 会触发 tileerror 事件。注意 createTile 必须同步返回一个元素,done 只是告诉 GridLayer 这个瓦片可以显示了。
6.2 TileLayer:URL 模板
6.2.1 模板占位符
TileLayer 的构造函数第一个参数是 URL 模板,支持这些占位符:
| 占位符 | 含义 |
|---|---|
{z} |
层级 |
{x} / {y} |
列号 / 行号(TMS 方案里 y 从下往上) |
{-y} |
反序行号,等价于 (2^z - 1 - y) |
{s} |
子域名,从 subdomains 里按顺序取 |
{r} |
设备像素比大于 1 时为 '@2x',否则空字符串 |
其他键 |
从图层的 options 里取同名属性 |
例如要带 token:
new TileLayer('https://tiles.example.com/{z}/{x}/{y}.png?key={tk}', {
tk: 'YOUR_TOKEN',
subdomains: 'abc',
maxZoom: 18
});
源码里的替换逻辑是 Util.template(this._url, data),其中 data 由 options 加 r/s/x/y/z 组成。有一个细节:找不到对应值时 Util.template 会抛 No value provided for variable {xxx},所以 subdomains 错写成不存在的键会直接报错。
6.2.2 TMS 与 y 轴方向
大多数 XYZ 服务(Google、OSM、高德)的 y 轴从上往下,TMS 规范从下往上。用 tms: true 切换:
// TMS 服务:y 从下往上
new TileLayer('https://server/tms/{z}/{x}/{y}.png', {tms: true});
// 等价写法:用 {-y}
new TileLayer('https://server/tiles/{z}/{x}/{-y}.png');
两个都写反而会更正两次,症状是”地图上下颠倒且拼接错乱”。判断方法:如果瓦片边界对得上但整体上下翻转,就是 y 轴方向问题。
6.2.3 subdomains 与并发
浏览器对同一域名的并发连接数有限制(HTTP/1.1 约 6 个),瓦片服务常用多子域名绕开:
new TileLayer('https://{s}.tile.example.com/{z}/{x}/{y}.png', {
subdomains: ['a', 'b', 'c', 'd'],
maxZoom: 19
});
subdomains 可以是字符串 'abcd'(每个字符一个)或数组。HTTP/2 下多域名收益很小,反而增加 DNS 与连接开销——如果服务端已经上 HTTP/2,用单域名。
6.2.4 detectRetina 的实际行为
detectRetina: true 时,若 devicePixelRatio >= 1.5,TileLayer 会做四件联动的事(构造函数里直接改 options):
tileSize → 减半(256 → 128)
zoomOffset → +1
maxZoom → -1
maxNativeZoom / minNativeZoom → 各 -1(若设置了)
本质是”用高一层的瓦片、按半尺寸显示”,等效于请求 2 倍图。副作用是请求量翻四倍、且 URL 里的 {z} 与地图层级不再一一对应。如果服务商支持 {r} 占位符,用 {r} 更简单,不需要开 detectRetina。
6.3 WMSTileLayer:把 OGC 服务当瓦片
6.3.1 默认参数
WMSTileLayer 继承 TileLayer,把 WMS 的 GetMap 请求参数拼进 URL。默认的 WMS 参数是:
| 参数 | 默认值 | 说明 |
|---|---|---|
service |
'WMS' |
固定 |
request |
'GetMap' |
固定 |
layers |
'' |
必填,逗号分隔的图层名 |
styles |
'' |
逗号分隔的样式名 |
format |
'image/jpeg' |
有透明度的图层要改 'image/png' |
transparent |
false |
透明背景开关 |
version |
'1.1.1' |
WMS 版本 |
图层自身的选项只有两个:crs(默认 null,即跟随地图的 CRS)与 uppercase(默认 false,为 true 时参数名转大写)。
6.3.2 一个可用的 GeoServer 示例
import {LeafletMap, WMSTileLayer} from 'leaflet';
const map = new LeafletMap('map').setView([30.27, 120.15], 9);
new WMSTileLayer('http://localhost:8080/geoserver/wms', {
layers: 'workspace:roads', // GeoServer 里要求 "工作区:图层名"
format: 'image/png',
transparent: true,
version: '1.3.0',
attribution: '© 自建 GeoServer'
}).addTo(map);
6.3.3 轴序陷阱:WMS 1.3.0 + EPSG:4326
这是 WMS 最著名的坑。源码 getTileUrl 里有一段专门的判断:
const bbox = (this._wmsVersion >= 1.3 && this._crs === EPSG4326 ?
[min.y, min.x, max.y, max.x] : // 纬度在前
[min.x, min.y, max.x, max.y]) // 经度在前
.join(',');
WMS 1.3.0 在 EPSG:4326 下要求 bbox 按 miny,minx,maxy,maxx 排列(纬度在前),1.1.1 则是 minx,miny,maxx,maxy。Leaflet 会按 version 与 crs 自动处理,但有两个前提:
version必须真的传对。声明 1.3.0 但服务端只认 1.1.1,就会拿到”图片整体转了 90 度”或空白;- 自定义
crs时,比较的是this._crs === EPSG4326(对象恒等),传一个自制对象不会走纬度优先分支。
6.3.4 参数与版本切换
setParams 可以运行时改参数并重绘:
const wms = new WMSTileLayer('http://localhost:8080/geoserver/wms', {
layers: 'workspace:roads',
format: 'image/png',
transparent: true
}).addTo(map);
// 换图层,不重建对象
wms.setParams({layers: 'workspace:buildings'});
// 第二个参数 noRedraw=true 只改参数不立即重绘
wms.setParams({styles: 'highlight'}, true);
参数名大小写由 uppercase 控制。有些老服务端(尤其 .NET 系)要求全大写,设 uppercase: true 即可。
自定义参数会被原样拼进 URL。源码注释说明:If any custom options not documented here are used, they will be sent to the WMS server as extra parameters。这给了传 vendor 参数(如 GeoServer 的 CQL_FILTER)的能力,但要注意值里不能有未编码的 &。
6.4 事件与错误处理
GridLayer 事件:
| 事件 | 触发时机 | 事件对象关键字段 |
|---|---|---|
loading |
开始加载瓦片 | — |
tileloadstart |
单个瓦片开始请求 | tile、coords |
tileload |
单个瓦片加载完成 | 同上 |
tileerror |
单个瓦片出错 | 同上 |
tileunload |
瓦片被移除出视野 | 同上 |
load |
所有可见瓦片加载完毕 | — |
用 tileerror 做兜底:
const layer = new TileLayer('https://tiles.example.com/{z}/{x}/{y}.png', {
maxZoom: 18,
errorTileUrl: 'https://tiles.example.com/blank.png'
}).addTo(map);
let errors = 0;
layer.on('tileerror', e => {
errors++;
if (errors === 1) {
console.warn('首个瓦片失败:', e.coords, e.tile.src);
}
});
errorTileUrl 是专门为”某些层级没有数据”设计的:把失败瓦片替换成一张占位图,避免出现破图图标。
6.5 排错清单
| 现象 | 原因 | 修法 |
|---|---|---|
整层空白,tileerror 大量触发 |
URL 模板错、跨域被拒、token 失效 | 用 DevTools Network 看实际请求 URL;核对 {z}/{x}/{y} 是否被替换 |
| 瓦片上下颠倒 | y 轴方向与 TMS 不匹配 | 加 tms: true,或改用 {-y}(二者不要同时) |
| 瓦片整体旋转 90° | WMS 1.3.0 与 1.1.1 的轴序差异 | 显式指定与服务端一致的 version |
| 放大到某一级后空白 | 服务端没有该层级瓦片 | 设 maxNativeZoom(以及 minNativeZoom) |
| 缩小时地图出现多份 | 世界副本重复 | noWrap: true,或配合 bounds 限制 |
| 底图叠了两层、请求量翻倍 | 多个底图同时 addTo |
切换时先 removeLayer(见第 5 章) |
| 高分屏上瓦片模糊 | 没开 detectRetina 或服务端无 2 倍图 |
用 {r} 占位符,或开 detectRetina |
| 拖动时地图频繁闪烁 | updateWhenIdle: false + 网络慢 |
试 updateWhenIdle: true 并配合 keepBuffer: 3 |
| 瓦片请求被浏览器缓存穿透 | 服务端没给 Cache-Control |
服务端加缓存头;客户端无解 |
6.6 生产建议
瓦片服务的坐标系必须是 Web Mercator(EPSG:3857)。 Leaflet 默认 CRS 就是 EPSG3857,瓦片网格按它的投影切分。服务端若用 EPSG:4326 切片,要么换 crs: EPSG4326(此时瓦片方案与常见 XYZ 不兼容),要么在服务端重切。
maxNativeZoom 一定要设。 不设的后果不是报错,而是”用户放大到 19 级看到一片灰”。设了之后至少是”放大后模糊但可见”。
updateWhenIdle 与 keepBuffer 是一对调优参数。 移动端弱网优先”少请求”:updateWhenIdle: true + keepBuffer: 3;桌面端体验优先保留默认值。
注意 CORS 与 referrerPolicy。 2.0 把 TileLayer 的 referrerPolicy 默认值改成了 'strict-origin-when-cross-origin',官方文档特别提醒:使用 tile.openstreetmap.org 时不要改这个值,OSM 的瓦片使用政策依赖它。需要读取瓦片像素数据(canvas 导出)时必须设 crossOrigin: true,且服务端要返回允许跨域的响应头。
自建瓦片服务用 leaflet.zip + 内网 CDN。 涉密或内网项目把瓦片与 Leaflet 都放内网,注意 2.0 的 zip 里没有 images 目录(默认图标已改为内联 SVG),不需要额外拷图。
6.7 本章小结
| 要点 | 结论 |
|---|---|
| 类层次 | GridLayer(格子)→ TileLayer(URL)→ WMSTileLayer(参数) |
| 两个扩展点 | 换内容形态重写 createTile;换 URL 规则重写 getTileUrl |
| 常用默认值 | tileSize: 256、maxZoom: 18(TileLayer)、subdomains: 'abc'、updateInterval: 200、keepBuffer: 2 |
| 层级夹取 | maxNativeZoom 让超出层级的瓦片放大显示,避免空白 |
| TMS | tms: true 或 {-y} 二选一,不要同时用 |
| WMS 轴序 | 1.3.0 + EPSG:4326 是纬度在前,Leaflet 自动处理但依赖 version 与 crs 传对 |
| 调试手段 | tileerror + Network 面板看真实请求 URL |