第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 的两个行为变化必须知道:

  1. 2.0 的 <canvas> 设置了 pointer-events: none,命中测试由地图自己做(从最上层的 canvas 逐个调 _layerAt)。所以给 canvas 元素加原生监听器不再触发,必须监听图层或地图。
  2. 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 数量直接压垮布局。

三条硬规则:

  1. 只把视口内的 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));
  1. 批量添加用 addLayers 而不是循环 addLayer。MarkerClusterGroup.addLayers() 内部做一次批量处理,官方 README 给出的量级是 20 万 marker 约 0.4 秒;循环单个添加会触发 N 次内部重算。LayerGroup 没有 addLayers,只能用 addLayer 并接受 layeradd 事件的开销。

  2. 能聚合就聚合。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 万以内聚合或矢量瓦片,更大交给服务端