前言
上一篇文章讲了如何把一张图片处理成自定义复杂 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点截图运行效率

单点逐个渲染为什么会慢
性能差通常不是一个点导致的,而是多个环节叠加。
1. 数据量大
如果后端一次返回 3000 个点,前端就要处理 3000 条数据。
每个点都要做:
字段校验
经纬度转换
图标生成
实体创建
地图渲染
单个操作看起来很小,乘以几千之后就不小了。
2. 复杂图标生成成本高
如果每个点都需要自定义 UI 图标,那么每个点可能都要经历:
图片加载
图片解码
Canvas 绘制
toDataURL 编码
这些都不是免费的。
如果一次性并发生成几千张图标,主线程和内存都会有压力。
3. 地图实体太多
以 Cesium 为例,Entity 很好用,但它更偏易用封装。
如果实体数量特别大,逐个 viewer.entities.add 会带来额外管理成本。
少量点没问题。
大量点就要考虑:
是否需要全部用 Entity
是否需要 Primitive
是否需要聚合
是否需要分层
4. 视觉上也看不清
很多人只关注性能,但忽略了另一个问题:
点太多时,用户其实也看不清。
地图缩放很远时,如果把所有点都画出来,屏幕上可能是一团密密麻麻的图标。
所以大量点位不是简单地“全部画出来”。
更合理的思路是:
远距离看趋势
中距离看分布
近距离看明细
这就引出了分层渲染和聚合策略。
前端接收后端数据后的处理链路
大量点位数据不要一拿到就直接渲染。
更推荐把前端处理拆成几个阶段:
接口请求
↓
数据校验
↓
数据标准化
↓
按类型和状态分层
↓
根据地图层级决定渲染策略
↓
聚合或明细渲染
↓
地图图层更新
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以上,地图的事件也没有之前密集了,这就是做了聚合的结果。
一个更完整的前端处理流程
综合上面的内容,一个比较合理的流程是:
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
大量点用批量渲染
前端从后端接收到点位数据后,不要马上逐个打点,而是应该经历:
校验
标准化
分组
分层
聚合
渲染策略选择
最后一句话:
大量打点的优化,不只是渲染优化,而是从后端数据返回、前端数据处理、图层设计、聚合策略到最终渲染方式的一整条链路优化。