3. 前端如何承接大量地图打点数据:从单点渲染到分层渲染和聚合策略

158 阅读11分钟

前言

上一篇文章讲了如何把一张图片处理成自定义复杂 UI 图标。

那篇主要解决的是:

单个点位图标怎么做得好看

但真实业务里,问题很快会升级。

后端一次返回的点位可能不是 10 个、20 个,而是:

几百个
几千个
甚至更多

如果每个点都直接:

viewer.entities.add(...)

或者每个点都生成一张复杂 Canvas 图标,再一个个加到地图上,页面很容易出现:

  • 首屏慢。
  • 地图卡顿。
  • 缩放拖动不流畅。
  • 图标重叠严重。
  • 浏览器内存升高。
  • 弱设备直接掉帧。

所以这一篇文章聊的是另一个问题:

前端如何承接后端返回的大量地图点位数据,并通过分层渲染和聚合策略优化性能?

本文会从数据接收开始,一路讲到:

  • 为什么一开始的单个方法渲染会慢。
  • 前端拿到后端点位数据后应该怎么处理。
  • 什么是分层渲染。
  • 什么是聚合策略。
  • Canvas 和 WebGL 在大量点位渲染中的区别。
  • 如何设计一条更稳的前端渲染链路。

业务背景

假设我们做的是一个无人机调度或事件管理地图。

后端返回一批点位数据:

[
  {
    "id": "1001",
    "name": "事件 A",
    "type": "event",
    "status": "warning",
    "longitude": 120.2263869,
    "latitude": 30.2291879,
    "image": "https://example.com/event-a.png",
    "createTime": "2026-08-14 10:00:00"
  },
  {
    "id": "1002",
    "name": "无人机 B",
    "type": "uav",
    "status": "online",
    "longitude": 120.2363869,
    "latitude": 30.2391879,
    "image": "https://example.com/uav-b.png",
    "createTime": "2026-08-14 10:01:00"
  }
]

这些数据需要展示到地图上。

最开始我们可能会写一个非常直接的方法:

async function renderMarkers(points) {
  for (const point of points) {
    const icon = await createMarkerIcon(point.image)

    viewer.entities.add({
      position: Cesium.Cartesian3.fromDegrees(
        point.longitude,
        point.latitude
      ),
      billboard: {
        image: icon.image,
        width: icon.width,
        height: icon.height,
        verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
      },
    })
  }
}

数据少的时候,这样没有问题。

但当点位数量上来之后,这个方法会暴露出很多性能问题。

在实际情况测算中,单个点位渲染可能只需要几十毫秒,但如果一次性渲染 2000 个点位,整个过程可能需要几秒钟甚至更久。而且1000多个点已经降到30fps,在2000个点位时,帧率可能会降到20以下fps,地图操作明显卡顿。 附图2000点截图运行效率

alt text转存失败,建议直接上传图片文件

单点逐个渲染为什么会慢

性能差通常不是一个点导致的,而是多个环节叠加。

1. 数据量大

如果后端一次返回 3000 个点,前端就要处理 3000 条数据。

每个点都要做:

字段校验
经纬度转换
图标生成
实体创建
地图渲染

单个操作看起来很小,乘以几千之后就不小了。

2. 复杂图标生成成本高

如果每个点都需要自定义 UI 图标,那么每个点可能都要经历:

图片加载
图片解码
Canvas 绘制
toDataURL 编码

这些都不是免费的。

如果一次性并发生成几千张图标,主线程和内存都会有压力。

3. 地图实体太多

以 Cesium 为例,Entity 很好用,但它更偏易用封装。

如果实体数量特别大,逐个 viewer.entities.add 会带来额外管理成本。

少量点没问题。

大量点就要考虑:

是否需要全部用 Entity
是否需要 Primitive
是否需要聚合
是否需要分层

4. 视觉上也看不清

很多人只关注性能,但忽略了另一个问题:

点太多时,用户其实也看不清。

地图缩放很远时,如果把所有点都画出来,屏幕上可能是一团密密麻麻的图标。

image.png 这时就算性能能扛住,用户体验也不好。

所以大量点位不是简单地“全部画出来”。

更合理的思路是:

远距离看趋势
中距离看分布
近距离看明细

这就引出了分层渲染和聚合策略。

前端接收后端数据后的处理链路

大量点位数据不要一拿到就直接渲染。

更推荐把前端处理拆成几个阶段:

接口请求
  ↓
数据校验
  ↓
数据标准化
  ↓
按类型和状态分层
  ↓
根据地图层级决定渲染策略
  ↓
聚合或明细渲染
  ↓
地图图层更新

1. 接口请求

普通写法:

async function fetchMapPoints(params) {
  const res = await request({
    url: "/api/map/points",
    method: "get",
    params,
  })

  return res.data || []
}

如果数据量很大,接口层面也要考虑:

  • 是否分页。
  • 是否按区域查询。
  • 是否按视窗范围查询。
  • 是否按缩放级别返回不同精度数据。
  • 是否只返回当前业务需要的字段。

前端不要被动接收全量数据。

如果地图当前只展示杭州区域,就不应该一次拿全国点位。

更理想的接口参数应该包含地图视窗:

{
  west: 119.8,
  south: 30.0,
  east: 120.6,
  north: 30.5,
  zoom: 10,
  types: ["uav", "event"]
}

后端根据范围返回当前视图需要的数据。

这一步非常重要。

很多地图性能问题,不应该只靠前端硬扛。

2. 数据校验

后端数据不一定完全可靠。

前端渲染地图前,应该过滤掉无效经纬度:

function isValidLngLat(lng, lat) {
  return (
    Number.isFinite(lng) &&
    Number.isFinite(lat) &&
    lng >= -180 &&
    lng <= 180 &&
    lat >= -90 &&
    lat <= 90
  )
}

处理数据:

function filterValidPoints(points) {
  return points.filter(item => {
    const lng = Number(item.longitude)
    const lat = Number(item.latitude)
    return isValidLngLat(lng, lat)
  })
}

否则一个异常经纬度就可能导致地图飞到奇怪的位置,甚至渲染报错。

3. 数据标准化

后端不同接口返回的字段可能不一致。

前端最好统一转换成地图内部使用的数据结构:

function normalizePoint(item) {
  return {
    id: item.id,
    name: item.name || "",
    type: item.type || "unknown",
    status: item.status || "normal",
    longitude: Number(item.longitude),
    latitude: Number(item.latitude),
    height: Number(item.height || 0),
    image: item.image || "",
    raw: item,
  }
}

使用时:

const points = rawPoints
  .map(normalizePoint)
  .filter(item => isValidLngLat(item.longitude, item.latitude))

这样后面的分层、聚合、渲染都基于统一格式。

不要让地图渲染层直接依赖后端原始字段。

4. 按业务分层

点位多的时候,不同类型最好拆成不同图层。

比如:

无人机图层
机场图层
事件图层
任务图层
人员图层

前端可以先按类型分组:

function groupByType(points) {
  return points.reduce((map, point) => {
    if (!map[point.type]) {
      map[point.type] = []
    }

    map[point.type].push(point)
    return map
  }, {})
}

得到:

{
  uav: [...],
  event: [...],
  airport: [...],
  task: [...]
}

分层的好处是:

  • 不同类型可以独立控制显示隐藏。
  • 不同图层可以使用不同渲染方式。
  • 高优先级点位可以优先渲染。
  • 低优先级点位可以延迟渲染。
  • 图层更新时不一定影响所有点。

这就是分层渲染的第一步。

什么是分层渲染

分层渲染不是简单地把代码拆成几个数组。

它的核心是:

不同业务类型、不同重要程度,用不同方式渲染。

例如:

无人机点:数量中等,直接用图片
事件点:用自定义ui渲染

这样做的目的不是代码好看,而是性能和体验更合理。

分层示例

可以设计一个图层配置:

const layerConfig = {
  airport: {
    priority: 1,
    renderMode: "billboard",
    cluster: false,
  },
  uav: {
    priority: 2,
    renderMode: "billboard",
    cluster: false,
  },
  event: {
    priority: 3,
    renderMode: "cluster",
    cluster: true,
  },
  task: {
    priority: 4,
    renderMode: "canvas",
    cluster: true,
  },
}

然后根据配置选择渲染方式:

function renderLayers(groupedPoints) {
  Object.keys(groupedPoints)
    .sort((a, b) => layerConfig[a].priority - layerConfig[b].priority)
    .forEach(type => {
      const points = groupedPoints[type]
      const config = layerConfig[type]

      if (config.cluster) {
        renderClusterLayer(type, points, config)
      } else {
        renderMarkerLayer(type, points, config)
      }
    })
}

这样不同类型的数据就不再混在一起渲染。

什么是聚合策略

聚合策略解决的是:

点太多时,不要全部展示明细点,而是先展示一个聚合结果。

比如某个区域内有 100 个事件点。

远距离看时,只显示一个聚合点:

100

用户放大地图后,再逐渐展开成多个明细点。

这样有两个好处:

减少渲染数量
减少视觉重叠

这不是偷懒,而是地图产品里非常常见的交互方式。

聚合的基本思路

最常见的是网格聚合。

大致流程:

根据当前地图视图和缩放级别
把屏幕或经纬度区域划分成网格
落在同一个网格里的点合并成一个聚合点
聚合点显示数量和中心位置

简化代码:

function clusterByGrid(points, gridSize = 60) {
  const gridMap = new Map()

  points.forEach(point => {
    const screenPosition = lngLatToScreen(point.longitude, point.latitude)

    if (!screenPosition) {
      return
    }

    const gridX = Math.floor(screenPosition.x / gridSize)
    const gridY = Math.floor(screenPosition.y / gridSize)
    const key = `${gridX}_${gridY}`

    if (!gridMap.has(key)) {
      gridMap.set(key, [])
    }

    gridMap.get(key).push(point)
  })

  return [...gridMap.values()].map(list => {
    const center = getClusterCenter(list)

    return {
      count: list.length,
      longitude: center.longitude,
      latitude: center.latitude,
      children: list,
    }
  })
}

这里的 lngLatToScreen 表示把经纬度转换成屏幕坐标。

在 Cesium 中,可以使用:

Cesium.SceneTransforms.wgs84ToWindowCoordinates(scene, position)

把三维坐标转成屏幕坐标。

聚合中心怎么取

简单做法是取平均经纬度:

function getClusterCenter(points) {
  const total = points.reduce(
    (sum, point) => {
      sum.longitude += point.longitude
      sum.latitude += point.latitude
      return sum
    },
    { longitude: 0, latitude: 0 }
  )

  return {
    longitude: total.longitude / points.length,
    latitude: total.latitude / points.length,
  }
}

也可以取第一个点的位置,或者取离中心最近的真实点位。

如果聚合点点击后要展开详情,建议保留:

children: list

这样可以知道这个聚合点包含哪些明细。

根据缩放级别决定展示方式

大量点位渲染时,最重要的是不要一个策略用到底。

可以根据相机高度或 zoom 级别切换:

远距离:展示聚合点
中距离:展示简化点
近距离:展示复杂 UI 明细点

Cesium 中可以用相机高度做判断:

function getRenderLevel(viewer) {
  const height = viewer.camera.positionCartographic.height

  if (height > 500000) {
    return "cluster"
  }

  if (height > 100000) {
    return "simple"
  }

  return "detail"
}

然后:

const level = getRenderLevel(viewer)

if (level === "cluster") {
  renderClusters(points)
} else if (level === "simple") {
  renderSimpleMarkers(points)
} else {
  renderDetailMarkers(points)
}

这样用户远距离看的是整体分布,近距离才看详细图标。

这比所有层级都强行展示复杂 UI 点位更合理。

结果

可以看到,最后的结果在2000个点位时,帧率仍然保持在30fps以上,地图的事件也没有之前密集了,这就是做了聚合的结果。

image.png

一个更完整的前端处理流程

综合上面的内容,一个比较合理的流程是:

1. 根据地图视窗请求后端数据
2. 前端过滤无效经纬度
3. 标准化数据结构
4. 按业务类型分层
5. 根据相机高度判断渲染级别
6. 远距离做聚合
7. 中距离展示简化点
8. 近距离展示复杂 UI 图标
9. 点很多时优先使用批量渲染能力(挖坑)

伪代码:

async function updateMapPoints(viewer) {
  const bounds = getCurrentMapBounds(viewer)
  const rawPoints = await fetchMapPoints(bounds)

  const points = rawPoints
    .map(normalizePoint)
    .filter(point => isValidLngLat(point.longitude, point.latitude))

  const grouped = groupByType(points)
  const level = getRenderLevel(viewer)

  Object.keys(grouped).forEach(type => {
    const layerPoints = grouped[type]

    if (level === "cluster") {
      renderClusterLayer(type, layerPoints)
    } else if (level === "simple") {
      renderSimpleLayer(type, layerPoints)
    } else {
      renderDetailLayer(type, layerPoints)
    }
  })
}

这里的关键不是某一行代码,而是思路:

不要把所有点都当成同一种东西渲染。
不要在所有缩放层级都展示完整复杂 UI。
不要让前端无脑承接后端全量数据。

渲染策略建议

可以按数据量大致分级。

1. 少量点位

数量:

几十个

策略:

直接 Entity + Billboard
可以使用复杂 UI 图标
不用过早优化

2. 中等点位

数量:

几百到几千

策略:

分层渲染
图标缓存
远距离聚合
近距离明细
必要时使用 BillboardCollection

3. 大量点位

数量:

上万甚至更多

策略:

后端按视窗返回数据
前端强制聚合
使用 WebGL 批量渲染
避免复杂 UI 全量展示
只在选中或近距离时展示复杂 UI

复杂 UI 图标应该放在哪一层

复杂 UI 图标很好看,但不适合所有层级都展示。

更合理的是:

远距离:聚合圆点 + 数字
中距离:简单状态图标
近距离:复杂 UI 图标
选中态:展示更详细 UI

也就是说,复杂 UI 图标应该出现在“用户真的需要看明细”的层级。

否则既浪费性能,也让地图变得拥挤。

总结

大量地图点位渲染,不应该从“怎么把所有点画出来”开始思考。

更合理的思路是:

后端少给一点
前端先洗数据
业务先分层
远距离先聚合
近距离再明细
少量点用复杂 UI
大量点用批量渲染

前端从后端接收到点位数据后,不要马上逐个打点,而是应该经历:

校验
标准化
分组
分层
聚合
渲染策略选择

最后一句话:

大量打点的优化,不只是渲染优化,而是从后端数据返回、前端数据处理、图层设计、聚合策略到最终渲染方式的一整条链路优化。