第16章 - 性能优化与大数据渲染
16.1 先定位瓶颈在哪一层
地图卡的成因只有四类,优化手段完全不同:
| 现象 | 瓶颈 | 对应手段 |
|---|---|---|
| 平移缩放掉帧,静止时流畅 | 每帧的 DOM 变换与重绘 | 减少图层数量、关动画、查 CSS 滤镜 |
| 首次加载慢,之后正常 | 构造图层 + 绑定事件 | 批量添加、换 Canvas、事件合并 |
| 交互时卡,静止正常 | 事件回调里的同步计算或请求 | 节流、异步、Web Worker |
| 拖动后内存持续上涨 | 图层或监听未释放 | 视口裁剪、removeLayer、off |
先开一个只带瓦片的空地图量基线,再逐类加图层,才能判断是哪一类。本章按这个顺序给具体参数。
16.2 渲染器:SVG 与 Canvas 的分界
2.0 默认渲染器是 SVG。每个 Path(Polyline、Polygon、Circle、CircleMarker)对应一个 <path> 元素,浏览器要在样式、布局、绘制三个环节处理它。要素数上千后,DOM 数量本身成为瓶颈。
切 Canvas 的方式在 2.0 变了:preferCanvas 选项被移除,传入会打印错误:
Map: preferCanvas option has been removed. Use renderer: new Canvas() instead.
正确写法:
import {LeafletMap, Canvas, TileLayer, GeoJSON} from 'leaflet';
const map = new LeafletMap('map', {
center: [39.9042, 116.4074],
zoom: 10,
renderer: new Canvas({padding: 0.1, tolerance: 3})
});
new TileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {maxZoom: 19}).addTo(map);
new GeoJSON(bigFeatureCollection, {style: {weight: 1, fillOpacity: 0.3}}).addTo(map);
| 选项 | 默认值 | 说明 |
|---|---|---|
padding |
0.1 |
画布比视口大多少(按视口尺寸比例),越大越少重绘,但内存上升 |
tolerance |
0 |
Canvas 命中测试的额外容差(像素),点击细线时可以调大 |
pane |
'overlayPane' |
所在 pane |
padding: 0.1 意味着画布四周各多出 10% 视口尺寸。平移量在缓冲区内时只需整体平移画布,不用重绘全部要素。代价是画布面积变成 1.44 倍,retina 屏上再乘 devicePixelRatio 的平方——一台 2 倍屏、1600×900 的容器,canvas 实际像素约 830 万,每帧清空重绘的成本要算进去。
Canvas 的两个行为变化必须知道:
- 2.0 的
<canvas>设置了pointer-events: none,命中测试由地图自己做(从最上层的 canvas 逐个调_layerAt)。所以给 canvas 元素加原生监听器不再触发,必须监听图层或地图。 - 2.0 取消了 canvas 悬停事件的节流,悬停响应与 SVG 一致,但这也意味着
pointermove上做重计算的代码会更频繁地被执行。
Canvas 不总是更快。要素数量少(几百个)、需要逐要素 className 做 CSS 动画、需要无障碍逐个聚焦时,SVG 更合适。经验分界线在 1000~2000 个 Path:低于此保持 SVG,高于此换 Canvas,再去量。
16.3 几何简化与裁剪
Polyline 和 Polygon 在每次缩放都会重投影并简化:
const defaultOptions = {
smoothFactor: 0.5, // Douglas-Peucker 容差,单位像素
noClip: false // 是否禁用视口裁剪
};
smoothFactor 是 2.0 从 1 下调到 0.5 的默认值。含义是“丢掉偏离直线小于 0.5 像素的顶点”,数值越大简化越狠、渲染越快、形状越失真。源码 _simplifyPoints() 调用 LineUtil.simplify(parts[i], tolerance),两步走:先去重邻近点,再跑 Douglas-Peucker(阈值用平方距离避免开方)。
调整建议:
- 长轨迹(几万个点)先离线简化再入库,不要指望客户端逐级简化。用
simplify-js或 Turf 的simplify,容差按数据精度定。 - 需要保留细节时把
smoothFactor降到 0.1~0.2,代价是每级缩放的重算时间上升。 noClip: true只在极少数场景(例如需要完整几何做命中测试)有意义,默认裁剪能大幅减少绘制量。
16.4 瓦片层参数
GridLayer 与 TileLayer 的默认值直接决定请求量与内存:
| 选项 | 默认值 | 性能含义 |
|---|---|---|
tileSize |
256 |
改成 512 可把瓦片数降到四分之一,但单张解码变慢 |
updateWhenIdle |
false |
拖动过程中持续更新;移动端建议 true |
updateWhenZooming |
true |
平滑缩放时每级整数 zoom 都更新,设 false 只在动画结束更新 |
updateInterval |
200 |
平移时两次更新之间至少间隔的毫秒数 |
keepBuffer |
2 |
卸载前保留的行列数,越大越不容易出现白块,内存越高 |
maxNativeZoom |
undefined |
服务端最大级别,超过后放大复用,避免请求 404 |
minNativeZoom |
undefined |
同上,低级别复用 |
noWrap |
false |
关闭世界副本重复,能减少低缩放级别下的请求 |
detectRetina |
false |
DPR 不低于 1.5 时改为请求 4 张半尺寸高一级瓦片 |
detectRetina 的细节:源码判断 window.devicePixelRatio >= 1.5,然后 tileSize 减半、zoomOffset 加 1、maxZoom 减 1。这会让请求数变成 4 倍,只在底图是高清矢量渲染时值得开;普通栅格瓦片开了只是让标签变小、流量翻倍。
maxNativeZoom 是省请求最有效的一个参数。底图只有到 16 级的瓦片,地图允许放大到 19,不开这个参数时 17~19 级全部 404,每级都要重试;开了之后客户端用 16 级瓦片放大显示,零额外请求。
16.5 标记:DOM 成本与聚合
Marker 每个实例至少产生一个带图标的 DOM 元素(2.0 的默认图标是内联 SVG,比 1.x 的图片方案少一半节点,官方给出的渲染提速是 25% 到 45%)。几千个出地图的 marker,DOM 数量直接压垮布局。
三条硬规则:
- 只把视口内的 marker 加进地图。用
moveend或节流后的move取map.getBounds(),做差集更新:
import {Util} from 'leaflet';
const visible = new Set();
function syncMarkers() {
const bounds = map.getBounds().pad(0.2); // 预取 20% 边缘
for (const m of allMarkers) {
const inside = bounds.contains(m.getLatLng());
if (inside && !visible.has(m)) { map.addLayer(m); visible.add(m); }
else if (!inside && visible.has(m)) { map.removeLayer(m); visible.delete(m); }
}
}
map.on('moveend', Util.throttle(syncMarkers, 200));
-
批量添加用
addLayers而不是循环addLayer。MarkerClusterGroup.addLayers()内部做一次批量处理,官方 README 给出的量级是 20 万 marker 约 0.4 秒;循环单个添加会触发 N 次内部重算。LayerGroup没有addLayers,只能用addLayer并接受layeradd事件的开销。 -
能聚合就聚合。markercluster 的
removeOutsideVisibleBounds(默认true)会把视口外的簇移出地图,这是它能撑十万级的原因。数据超过 1 万点时不要自己写视口裁剪,直接用插件。
CircleMarker 在 Canvas 下只是绘制指令,没有 DOM,几千个点用它是合理选择;但它没有独立 DOM 节点,无法逐个用 CSS 做动画,也无法被键盘聚焦。
16.6 动画与交互的开关
Map 的动画默认值在低端设备上是主要卡顿源:
| 选项 | 默认值 | 建议 |
|---|---|---|
zoomAnimation |
true |
低端机或要素极多时设 false |
zoomAnimationThreshold |
4 |
一次跳超过 4 级不做动画 |
fadeAnimation |
true |
瓦片淡入;弱设备设 false |
markerZoomAnimation |
true |
marker 随缩放动画变换;marker 多时设 false |
inertia |
true |
惯性平移,可关 |
transform3DLimit |
8388608 |
32 位浮点精度上限,不要改 |
zoomAnimation: false 会让缩放变成瞬时跳变,但省掉整屏图层在动画期间的变换与重绘。数据密集型看板(几万个要素)关掉缩放的收益通常大于体验损失。
浏览器层面的 prefers-reduced-motion 会被 Leaflet 尊重:大幅缩放与平移直接跳变、flyTo 不再先拉远、惯性缩短。这是 2.0 内建行为,不需要额外处理。
16.7 事件与重绘的合并
高频事件里做 DOM 写入是最常见的隐形瓶颈。三条实现路径,优先级从高到低:
import {Util} from 'leaflet';
// 1. throttle:固定时间窗口执行一次
map.on('move', Util.throttle(updateHud, 100));
// 2. requestAnimationFrame:合并到下一帧,适合视觉更新
let rafId = 0;
map.on('move', () => {
if (rafId) { return; }
rafId = requestAnimationFrame(() => {
rafId = 0;
redrawOverlay();
});
});
// 3. 只监听结束事件
map.on('moveend', refreshData);
Canvas 渲染器内部已经做了合并:_requestRedraw() 用 requestAnimationFrame 把同一帧内的多次重绘请求压成一次。这是“要素多但静止时不卡”的关键,但它不保护你的业务回调。
16.8 大数据的三级方案
数据量上到十万级,单靠调参不够,要换架构:
| 量级 | 方案 | 关键工具 |
|---|---|---|
| 2000 以下 | SVG + 普通 GeoJSON | 默认配置即可 |
| 2000 到 50000 | Canvas 渲染器 + 视口裁剪 + 简化几何 | new Canvas()、map.getBounds() |
| 50000 到 500000 | 聚合(点)或矢量瓦片(面、线) | MarkerClusterGroup、protomaps-leaflet |
| 50 万以上 | 服务端渲染瓦片或矢量切片 | 后端切片、MapLibre GL 类方案 |
矢量瓦片是这条路径的终点:浏览器只解析当前视口的切片,几何由服务端预切割。Leaflet 本身不生产矢量瓦片,需要 protomaps-leaflet 这类插件读取 PMTiles,或直接把重数据交给 MapLibre GL。到了这个量级,Leaflet 的定位是“底图与交互”,不是“渲染引擎”。
一个容易被忽略的中间方案是服务端聚合:地图只请求当前 zoom 与 bbox 的聚类结果(每个簇一个点加计数),缩放时重新请求。客户端 marker 数恒定在几百个,数据量再大也不影响渲染,代价是每次缩放有一次网络往返。
16.9 排错
拖地图时 marker 位置漂移,松手后归位。 某个图层没有正确处理 zoomanim 或 viewreset。自定义图层只监听 moveend 会漏掉动画期间的变换。检查 getEvents() 是否包含 zoomanim,位置是否用 DomUtil.setTransform 更新。
切 Canvas 后点击事件全失效。 2.0 的 canvas 是 pointer-events: none,事件由地图命中测试后 fire 给图层。给 canvas 元素加 addEventListener 无效,改 layer.on('click')。另外 interactive: false 的图层永远不参与命中测试。
瓦片出现灰块或白块。 keepBuffer 太小(默认 2)或网络慢导致卸载早于加载完成。调大到 3 到 4,或把 updateWhenIdle 设为 true 减少拖动中的请求。
放大到高级别后满屏 404。 瓦片源没有该级别,设 maxNativeZoom 为服务的最大级别,让客户端复用低级别瓦片。
内存只增不减。 典型原因是每次 moveend 都新建一批 marker 却没 removeLayer 旧的,或者 on 了匿名函数无法 off。用 layeradd / layerremove 打点统计图层数,观察是否稳定。
Performance 面板里大量布局与样式重算。 地图容器内的 CSS 若含 filter、box-shadow、backdrop-filter,会在每次 pane 变换时强制重算。2.0 已移除瓦片的 filter: inherit,但页面级的 img { filter: ... } 仍会命中瓦片,改成用类选择器限定范围。
开启 zoomAnimation: false 后缩放有闪烁。 2.0 会保留旧瓦片避免闪灰,但如果自定义了 tilePane 的过渡或用了 fadeAnimation: false 叠加,仍可能出现。逐个关掉动画选项定位。
16.10 生产建议
- 建立数据量与方案的对应表,写进项目文档。超过阈值就换方案,而不是在参数里反复试。
- 首屏只加载视口加 20% 预取范围。
getBounds().pad(0.2)是简单可靠的做法。 - 瓦片服务必须配
maxNativeZoom,并对 404 做监控。放大到空白级别是最常见的线上投诉。 - 移动端默认关掉非必要的动画:
fadeAnimation: false、markerZoomAnimation: false,能明显降低低端机卡顿。 - 用
PerformanceObserver采集 long task,而不是靠肉眼。地图卡顿里超过 50ms 的任务才有优化价值。 - 大数据的最终方案是“不在客户端渲染”。聚合、服务端切片、矢量瓦片,本质都是把工作量前移。
16.11 本章小结
| 要点 | 结论 |
|---|---|
| 渲染器分界 | 1000 到 2000 个 Path 以下 SVG,以上 Canvas;preferCanvas 已移除 |
| Canvas 缓冲 | padding: 0.1,画布面积 1.44 倍,retina 再乘 DPR 平方 |
| 简化 | smoothFactor 默认 0.5(2.0 从 1 下调),RDP 算法,按像素容差 |
| 瓦片 | keepBuffer 2、updateInterval 200、updateWhenIdle false |
| 省请求 | maxNativeZoom / minNativeZoom 是最有效的一对参数 |
| marker | 视口裁剪 + addLayers 批量 + 聚合;2.0 默认图标渲染快 25% 到 45% |
| 动画 | 低端设备关 zoomAnimation / fadeAnimation / markerZoomAnimation |
| 事件 | throttle 或 requestAnimationFrame 合并,别在 move 里写 DOM |
| 量级方案 | 2000 以内 SVG,5 万以内 Canvas,50 万以内聚合或矢量瓦片,更大交给服务端 |