第18章 - 性能优化与最佳实践
18.1 先定位瓶颈,再谈优化
MapLibre 的性能问题几乎都落在四个环节,表现类似(掉帧、卡顿、白屏)但原因完全不同:
| 环节 | 症状 | 定位手段 |
|---|---|---|
| 网络/瓦片 | 首屏白,缩放时一阵一阵地加载 | DevTools Network 看瓦片请求数与大小 |
| worker 解析 | 地图能拖,但新瓦片出现慢,主线程空闲 | Performance 面板看 worker 任务(选择 Worker 线程) |
| 主线程 JS | 拖动不卡、缩放卡;自己的监听回调耗时长 | Performance 面板的 Main 轨道 |
| GPU 绘制 | 稳定低帧率、风扇转、移动端发热 | map._showOverdrawInspector、Chrome GPU 面板 |
先用内置调试开关确认是哪一环:
map.showTileBoundaries = true; // 画出瓦片边界,看是否在疯狂请求
map.showCollisionBoxes = true; // 画出符号碰撞框,看布局成本
map.showPadding = true; // 画出 viewport padding 区域
showTileBoundaries 打开后缩放地图,如果边界框频繁出现又消失,说明瓦片缓存太小或瓦片请求过多,问题在网络/缓存层;边界稳定但帧率低,往 GPU 方向查。
18.2 渲染相关的参数
Map 构造选项里与性能直接相关的,默认值都在 src/ui/map.ts 的 defaultOptions:
| 选项 | 默认值 | 影响 |
|---|---|---|
fadeDuration |
300(ms) |
瓦片淡入时长。设为 0 可显著减少切换时的重绘,但会有”跳变”感 |
maxTileCacheSize |
null(动态) |
每个源保留的瓦片上限 |
maxTileCacheZoomLevels |
5(config.MAX_TILE_CACHE_ZOOM_LEVELS) |
动态缓存按”视口瓦片数 × 该值”计算 |
cancelPendingTileRequestsWhileZooming |
true |
缩放中取消排队请求,避免加载无用的中间级别 |
refreshExpiredTiles |
true |
到期瓦片是否重新请求 |
crossSourceCollisions |
true |
跨源碰撞检测。关掉可省符号布局开销,代价是不同源的标注会重叠 |
maxCanvasSize |
[4096, 4096] |
canvas 最大像素尺寸,按 GL MAX_TEXTURE_SIZE 量级保守取值 |
zoomLevelsToOverscale |
4 |
最多向上过采样几级 |
terrainSkirtLength |
'auto' |
地形裙边长度 |
maxTileCacheSize 的默认值不是固定数字,而是按视口算出来的(src/tile/tile_manager.ts):
updateCacheSize(transform) {
const widthInTiles = Math.ceil(transform.width / this._source.tileSize) + 1;
const heightInTiles = Math.ceil(transform.height / this._source.tileSize) + 1;
const approxTilesInView = widthInTiles * heightInTiles;
const commonZoomRange = this._maxTileCacheZoomLevels === null ?
config.MAX_TILE_CACHE_ZOOM_LEVELS : this._maxTileCacheZoomLevels;
const viewDependentMaxSize = Math.floor(approxTilesInView * commonZoomRange);
const maxSize = typeof this._maxTileCacheSize === 'number' ?
Math.min(this._maxTileCacheSize, viewDependentMaxSize) : viewDependentMaxSize;
this._outOfViewCache.setMaxSize(maxSize);
}
按 1920×1080、瓦片 512px 计算:ceil(1920/512)+1 = 5,ceil(1080/512)+1 = 4,视口约 20 块瓦片,乘以 5 个 zoom 级别 = 每源约 100 块离屏缓存。全屏地图 + 3 个矢量源就是 300 块瓦片常驻内存,每块瓦片解压后的几何数据通常在几百 KB 到几 MB,这是内存占用的主要来源。
要压内存,就把 maxTileCacheSize 设成一个绝对上限(比如 64);要提流畅度(来回缩放不重新下载),就调大 maxTileCacheZoomLevels。
18.3 主线程与 worker
v6.17.0 的 worker 数默认是 1(src/util/worker_pool.ts):
const availableLogicalProcessors = Math.floor(browser.hardwareConcurrency / 2);
WorkerPool.workerCount = isSafari(globalThis) ? Math.max(Math.min(availableLogicalProcessors, 3), 1) : 1;
Safari 上用 CPU 核心数的一半、上限 3;其他浏览器固定 1 个。这个默认值来自 A/B 测试(源码注释指向 PR #2354),不是”按核心数自动扩”。瓦片解析(PBF 反序列化、几何布局、索引构建)全在这一个 worker 里排队。
数据量大、或者同时开了多个地图实例时,可以调:
import { getWorkerCount, setWorkerCount } from 'maplibre-gl';
console.log(getWorkerCount()); // 1(Chrome)
setWorkerCount(4); // 必须在创建任何 Map 之前调用
const map = new maplibregl.Map({ /* ... */ });
调大不是免费的:每个 worker 有自己的 WASM/JS 堆,多个 worker 之间不能共享解析结果,小心内存翻倍。桌面端 2–4 个通常合适,移动端保持 1。
图片(栅格瓦片、sprite、图标)的并发上限是全局的:
import { getMaxParallelImageRequests, setMaxParallelImageRequests } from 'maplibre-gl';
console.log(getMaxParallelImageRequests()); // 16
setMaxParallelImageRequests(24);
默认 16,同时有 MAX_PARALLEL_IMAGE_REQUESTS_PER_FRAME: 8 限制每帧发起的数量,避免一帧内爆出几十个请求。栅格底图密集时可提到 24,但超过浏览器同域连接上限(HTTP/1.1 是 6,HTTP/2 多路复用后宽松)意味着请求排队,收益递减。
18.3.1 建立可比较的基线
优化没有基线就是猜。三个数字足够做决策,且都能在浏览器里直接量:
// 1) 首帧:从 new Map 到 'load' 的时间
const t0 = performance.now();
const map = new maplibregl.Map({ /* ... */ });
map.once('load', () => console.log('首帧', (performance.now() - t0).toFixed(0), 'ms'));
// 2) 缩放流畅度:统计两次 idle 之间的渲染帧间隔
let last = performance.now();
const gaps = [];
function onRender() {
const now = performance.now();
if (now - last < 200) gaps.push(now - last); // 过滤掉非连续帧
last = now;
}
map.on('render', onRender);
map.on('idle', () => {
if (gaps.length) {
const avg = gaps.reduce((a, b) => a + b, 0) / gaps.length;
console.log('平均帧间隔', avg.toFixed(1), 'ms →', (1000 / avg).toFixed(0), 'fps');
gaps.length = 0;
}
});
// 3) 常驻内存(仅 Chromium):usedJSHeapSize 单位是字节
console.log('JS 堆', (performance.memory.usedJSHeapSize / 1048576).toFixed(1), 'MB');
在同一台设备、同一份数据上,改一个参数前后各测三次取中位数。map.on('render') 只在真正重绘时触发,正好可以用来判断”地图是不是在空转”——静止状态下这个回调一次都不该有,有就说明某处一直在 triggerRepaint()。
18.4 数据层优化:换表示比换参数有效
同样的数据,表示方式不同,性能差一个数量级:
GeoJSON 内联在 JS 里 → 数百 MB 字符串,解析即卡死
GeoJSON 从 URL 加载 → 几十 MB,worker 端切瓦片,首屏 1–3 s
简化 + 降精度的 GeoJSON → 体积降 60%–80%
矢量瓦片(服务端/PMTiles) → 首屏几百 ms,按需加载
具体手段按收益排序:
降精度。 GeoJSON 坐标常有 15–17 位小数,实际 6 位(约 1 cm)足够。用 mapshaper 或 Turf 的 coordEach 批量取整。
去属性。 只保留 paint/filter 用到的字段。属性是 PBF 里的大头,删掉不用的能省 30%–50%。
简化几何。 用 @turf/simplify 或 PostGIS ST_Simplify,容差按目标缩放级别的地面分辨率取(z12 约 38 m/像素 → 容差 20–40 m)。
换矢量瓦片。 超过约 5 万要素的静态数据,一律转瓦片。maxzoom 设到真实精度,靠过采样显示更高层级。
maxzoom 与 minzoom 收紧。 GeoJSON 源的 maxzoom 默认 18,把点源设到 12 能大幅减少高缩放层的瓦片数;图层加 minzoom: 8 避免低缩放的无效布局。
18.5 图层层面:少一遍就是一遍
// 1) 关掉跨源碰撞:不同源的标注不再互相避让
new maplibregl.Map({ crossSourceCollisions: false, /* ... */ });
// 2) 关闭不需要的淡入:静态底图设 0 可减少重绘
new maplibregl.Map({ fadeDuration: 0, /* ... */ });
// 3) 只在该级别可见的图层加 minzoom / maxzoom
map.addLayer({ id: 'buildings', type: 'fill-extrusion', source: 'vec', 'source-layer': 'building',
minzoom: 14, /* ... */ });
// 4) 用 filter 提前排除要素,比画出来再隐藏便宜
map.setFilter('parcels', ['>', ['get', 'area'], 200]);
优先级从高到低:减少图层数量 > 收紧 zoom 范围 > 提高过滤选择性 > 关碰撞检测 > 调缓存参数。多数”卡”的项目,问题在同时挂了十几个半透明图层,而不是某个参数没调。
18.5.1 一次典型的优化过程
以”15 万建筑面 + 3 个矢量底图源,缩放掉到 20 fps”为例,按上面的顺序走一遍:
showTileBoundaries = true观察:缩放时瓦片反复出现—消失,说明缓存命中率低。把maxTileCacheZoomLevels从 5 提到 7,帧率无明显变化 → 瓶颈不在缓存。- Performance 面板看 Main 轨道:
move事件上挂了一个自定义回调,每次遍历全部 15 万queryRenderedFeatures结果 → 改成只在moveend触发,掉帧消失。 - 剩余掉帧集中在 GPU:
fill图层用了fill-antialias: true(默认),大面数据叠加时边缘采样成本高。该层数据只在 z14+ 显示,加minzoom: 14后低缩放完全跳过。 - 最后压体积:
maxzoom从 18 调到 15,瓦片请求数下降约 60%。
四步里没有一步是”调大缓存”,三条结论都来自观察。反过来,先改 maxTileCacheSize 再看效果,通常只是让内存翻倍。
18.6 启动与销毁
// 预创建 worker 与 GL 上下文,减少第二个地图实例的首帧延迟
import { prewarm } from 'maplibre-gl';
prewarm();
// 不再需要时释放
import { clearPrewarmedResources } from 'maplibre-gl';
clearPrewarmedResources();
单页应用里”进详情页再返回列表”,如果不销毁地图,canvas 与 GL 上下文会一直占显存;正确做法是 map.remove(),它内部会终止瓦片请求、释放纹理、解绑事件。注意 remove() 之后不能再调用任何方法,重新显示要重建 Map。
18.7 排错
| 现象 | 原因 | 修法 |
|---|---|---|
| 首屏慢但拖动流畅 | 瓦片请求过多或首屏数据过大 | 收紧 maxzoom、改用瓦片源、加 bounds |
| 拖动时频繁掉帧 | 主线程被事件回调占满 | 用 Performance 面板定位回调;节流 move 监听 |
| 缩放时白块闪 | cancelPendingTileRequestsWhileZooming 取消过激 |
保留默认;或提高缓存 |
| 内存持续增长 | 瓦片缓存过大 + 未 remove() 的地图实例 |
设 maxTileCacheSize;路由切换销毁地图 |
| 移动端 GPU 报上下文丢失 | 纹理太多(地形 RTT 尤甚) | 关闭地形 / 减少源 / 降 maxCanvasSize |
| worker 解析排队 | workerCount: 1,同时多个瓦片源 |
setWorkerCount(2..4),在创建地图前调用 |
18.8 生产建议
首屏只加载看得见的数据。 用 fitBounds 圈定范围、给源加 bounds、按需加载矢量源,比全局加载后过滤快得多。
fadeDuration 视底图类型定。 矢量底图保留 300ms 让颜色过渡自然;栅格照片类底图设 0,避免切换时的半透明重绘。
移动端把 worker 保持 1。 手机核心多但功耗墙明显,多个 worker 同时解析反而更慢更热。
监控三项指标就够。 首帧时间、缩放时的掉帧率、常驻内存(performance.memory 仅 Chrome 可用)。有这三条曲线,绝大多数优化决策不需要再猜。
升级主版本前回归性能。 瓦片缓存公式、worker 数、默认 fadeDuration 这些都在版本间变过,v5→v6 的迁移指南里列了行为变化,升级后要重测指标。
18.9 本章小结
| 要点 | 事实 |
|---|---|
| worker 数 | 默认 1;Safari 为 min(核心数/2, 3);setWorkerCount 须在建图前调用 |
| 图片并发 | MAX_PARALLEL_IMAGE_REQUESTS: 16,每帧上限 8 |
| 瓦片缓存 | 默认动态:视口瓦片数 × 5 个缩放级别;maxTileCacheSize 设绝对值可压内存 |
| 淡入 | fadeDuration 默认 300ms,设为 0 减少重绘 |
| 碰撞 | crossSourceCollisions 默认 true,关掉省布局开销 |
| 数据层 | 降精度、去属性、简化、换矢量瓦片,收益远大于调参数 |