经纬志
首页 / Leaflet / 教程

性能与大量点

先看结论

Leaflet 的默认矢量渲染器是 SVG,每个要素对应一个 DOM 节点。 超过几千个点,瓶颈就不再是网络,而是 DOM 节点数量与每次缩放时的重排。 解决方向只有三个:换渲染器、减少同时存在的要素、降低要素的复杂度。

最后验证 2026-11-03 · Leaflet 1.9.4 · 更新 2026-10-29 · 引用本文

先认清成本

方案可承受数量级交互能力代价
L.marker数百到三千每个都是 DOM,可放任意 HTML节点多、阴影多、事件多
L.circleMarker + SVG数千支持 hover、click节点数量同样随点数增长
L.circleMarker + Canvas数万支持点击命中检测,不支持 CSS hover整层重绘,单点更新成本高
聚合图层十万以上点被折叠,需展开才能点选精度损失,需要额外插件
矢量瓦片 / WebGL百万级样式受瓦片管线约束需要服务端出瓦片或引入 WebGL 图层

这张表是量级参考,不是硬边界。同一台设备上,点的样式复杂度、 是否有弹窗、是否绑定 hover,都会让临界值上下浮动一个数量级。

换成 Canvas 渲染器

两种开法:地图级 preferCanvas: true,让所有矢量图层默认走 canvas; 或者建一个渲染器实例,显式传给图层。后者适合「只有点层用 canvas、 面和线仍要 SVG 的交互」的混合场景。

var map = L.map('map', {
  center: [39.907359, 116.391263],
  zoom: 10,
  preferCanvas: true,
  renderer: L.canvas({ padding: 0.5 })
});

var canvasLayer = L.canvas({ padding: 0.5 });

var points = [];
for (var i = 0; i < 50000; i++) {
  points.push([
    39.5 + Math.random() * 0.9,
    116.0 + Math.random() * 0.8
  ]);
}

var dots = L.layerGroup();
points.forEach(function (ll) {
  dots.addLayer(L.circleMarker(ll, {
    radius: 3,
    stroke: false,
    fillColor: '#17694a',
    fillOpacity: 0.6,
    renderer: canvasLayer      // 统一走同一个画布
  }));
});
dots.addTo(map);

padding 是画布的预留边距,单位是容器尺寸的倍数。 0.5 表示在可视区外多画半屏,拖动时不会露出空白; 设得太大会增加每帧的绘制量,0.1 到 0.5 之间通常够用。

一个渲染器实例只应被一层共用。每层各建一个 L.canvas(), 页面上就会出现多张叠在一起的画布,内存与重绘次数一起翻倍。

减少同时存在的要素

数据有十万条,不等于要画十万个点。最有效的一步永远是在数据侧裁剪: 只取当前视野内的记录,或者按缩放级别取不同精度的数据集。 客户端也可以先做一次包围盒粗筛,再交给 Leaflet。

function inView(ll, bounds, pad) {
  return ll[0] > bounds.getSouth() - pad &&
         ll[0] < bounds.getNorth() + pad &&
         ll[1] > bounds.getWest()  - pad &&
         ll[1] < bounds.getEast()  + pad;
}

function renderVisible(all, layer) {
  var b = map.getBounds();
  var pad = (b.getNorth() - b.getSouth()) * 0.2;
  layer.clearLayers();

  var batch = [];
  for (var i = 0; i < all.length; i++) {
    if (inView(all[i], b, pad)) batch.push(all[i]);
  }

  // 分批加入,避免一次阻塞主线程
  var step = 800;
  (function addChunk(start) {
    if (start >= batch.length) return;
    for (var j = start; j < Math.min(start + step, batch.length); j++) {
      layer.addLayer(L.circleMarker(batch[j], {
        radius: 3, stroke: false, fillOpacity: 0.6
      }));
    }
    requestAnimationFrame(function () { addChunk(start + step); });
  })(0);
}

map.on('moveend', function () {
  renderVisible(allPoints, dots);
});

每次 moveend 全量重建并不可取。实际项目里配合防抖、 按瓦片编号缓存已渲染的点集,才能把拖动时的开销压到可接受范围。

逐帧操作的三个开关

参数 / 做法作用建议
smoothFactor折线简化强度,值越大顶点越少轨迹类数据设 1.5 到 2
updateWhenZooming缩放过程中是否持续重绘矢量层数据多时设 false,缩放结束再画
updateWhenIdle拖动过程中是否加载新瓦片移动端设 true,减少抖动

注意事项

Canvas 层没有 CSS hover

canvas 里画的是像素,不是节点,:hover、CSS 动画都用不上。 悬停高亮要在 mouseover 里改该图层的 setStyle, 并接受整层重绘的开销。

大量 Marker 时先怀疑阴影

每个 L.marker 默认带一张阴影图,等于节点数翻倍、 请求数翻倍。视觉上不需要阴影时,shadowUrl: null 是零成本的优化。

数据更新时反复重建图层

每次刷新都 removeLayer 再全量添加,会让地图闪烁并丢掉已打开的状态。 能增量更新的场景,用 layer.setLatLng()、 layer.setStyle() 改已有对象,比销毁重建便宜得多。

引用本文

经纬志(2026)。《性能与大量点》。https://www.jingweizhi.com/leaflet/tutorial/performance-points.html(访问日期 2026-11-03)。