前言
上一篇文章聊了大量地图点位的分层渲染和聚合策略。
那篇主要解决的是:
text
同一时刻,地图上不要渲染太多点。
通过按业务分层、按相机高度切换展示策略、远距离聚合、近距离展示明细,可以让 2000 个点位的地图不再一打开就卡成 PPT。
但做到这里还不够。
真实业务里还有另一个更常见的问题:
text
后端不可能每次都把所有点位重新推给前端。
比如无人机和事件这类实时数据,可能每隔几秒就有变化。
变化通常只是:
text
某一架无人机的位置变了
某一个事件状态更新了
新增了几个任务点
删除了几个已经处理完成的点
如果后端每次只变了 10 个点,前端却把 2000 个点全部删除再全部创建,那么前面的渲染优化只能解决一部分问题。
这一篇就继续聊:
text
地图点位如何从全量更新优化成增量更新?
为什么不能每次全量更新
以前的写法大概是这样:
js
async function updateMarkers(markers) {
clearAllMarkers()
for (const marker of markers) {
const icon = await createMarkerIcon(marker)
addMarker(marker, icon)
}
}
这种写法简单,适合初版功能。
但在实时地图里,它的问题会越来越明显:
- 删除所有旧点,会造成地图闪烁。
- 没变化的点也会重新创建 Entity。
- 没变化的图标也会重新加载图片、重新画 Canvas。
- 后端只变了 10 个点,前端却处理了 2000 个点。
所以这里要把渲染思路从:
text
替换整个图层
改成:
text
维护图层状态,只更新变化的点
真正稳定的方案应该是:
text
后端推增量
前端合并增量
地图只更新变化的点
增量消息应该长什么样
后端可以不用每次返回完整数组,而是返回一批变化:
json
{
"upserts": [
{
"id": "1002",
"name": "无人机 B",
"type": "uav",
"status": "online",
"longitude": 120.2368869,
"latitude": 30.2396879,
"image": "https://example.com/uav-b.png"
}
],
"removeIds": ["1008", "1012"]
}
这里我比较推荐用两个字段:
text
upserts:新增或更新的点
removeIds:需要删除的点 id
为什么叫 upserts?
因为前端不需要关心它到底是新增还是更新。
逻辑可以统一成:
text
如果 id 不存在,就新增。
如果 id 已经存在,就更新。
这比后端拆成 adds、updates、deletes 三个数组更好用一些。
当然,如果业务已经有自己的协议,也不一定非要叫这个名字,关键是要有稳定的点位 id。
前端需要维护一份点位数据池
做增量更新时,前端不能只依赖这一次后端推来的数据。
因为这一批数据只是变化量,不是完整状态。
所以前端需要维护一份当前地图上的点位数据池:
js
const markerDataMap = new Map()
收到增量消息时,先合并数据:
js
function applyMarkerPatch(patch) {
const upserts = patch.upserts || []
const removeIds = patch.removeIds || []
removeIds.forEach(id => {
markerDataMap.delete(String(id))
})
upserts.forEach(marker => {
markerDataMap.set(String(marker.id), marker)
})
return Array.from(markerDataMap.values())
}
这样后端每次只推变化的数据,前端仍然可以得到当前地图应该展示的完整状态。
注意,这里的完整状态不是后端重新给的,而是前端自己合并出来的。
地图实体也要建立索引
只有数据池还不够。
如果每次合并完以后,还是把数组丢给地图重新渲染,那本质上还是全量更新。
所以地图层也要维护一份实体索引:
js
const markerEntityMap = new Map()
它的结构是:
text
id -> Cesium Entity
这样下一次某个点变化时,就可以直接找到对应的地图实体:
js
const entity = markerEntityMap.get(marker.id)
然后只更新它的位置、图标或属性。
判断一个点有没有变化
不是后端推过来的点都一定要重绘。
比如后端推了一批数据,但其中某些点的经纬度、状态、图片都没变,那就可以直接跳过。
前端可以给每个点生成一个快照签名:
js
function getMarkerSignature(marker) {
return JSON.stringify({
longitude: marker.longitude,
latitude: marker.latitude,
height: marker.height || 0,
image: marker.image || "",
imageWidth: marker.imageWidth || "",
type: marker.type || "",
status: marker.status || "",
})
}
再维护一份上一次的快照:
js
const markerSnapshotMap = new Map()
更新时判断:
js
const oldSignature = markerSnapshotMap.get(marker.id)
const nextSignature = getMarkerSignature(marker)
if (oldSignature === nextSignature) {
return
}
这样没变化的点就不会进入后面的图标生成和地图更新流程。
增量更新的核心流程
整个流程可以变成这样:
text
收到后端增量消息
↓
把 upserts 合并进 markerDataMap
↓
把 removeIds 从 markerDataMap 删除
↓
根据当前完整数据重新判断聚合/明细模式
↓
明细模式下,对 id 做 diff
↓
删除消失的 Entity
↓
只更新变化的 Entity
↓
保留没有变化的 Entity
这里有一个细节:
text
聚合模式可以重新计算聚合结果。
明细模式更适合做精细的增量更新。
因为聚合点本身不是后端真实点位,它是由当前视图、相机高度、屏幕网格临时算出来的结果。
用户拖动地图或缩放地图时,聚合结果本来就可能变化。
所以聚合层重算是可以接受的。
真正需要避免的是:
text
近距离明细点每次都全量删除再全量创建。
组件里的实现思路
在组件里,可以维护四份状态:
js
data() {
return {
markerDataMap: null,
markerEntityMap: null,
markerSnapshotMap: null,
markerIconCache: null,
}
}
分别负责:
text
markerDataMap:当前业务点位数据池
markerEntityMap:id 到 Cesium Entity 的索引
markerSnapshotMap:上一次渲染时的点位快照
markerIconCache:图标缓存,避免重复生成 Canvas 图片
增量入口可以这样设计:
js
async function applyMarkerPatch(patch = {}) {
const upserts = patch.upserts || []
const removeIds = patch.removeIds || []
removeIds.forEach(id => {
markerDataMap.delete(String(id))
})
upserts.forEach(marker => {
markerDataMap.set(String(marker.id), marker)
})
return updateMarkers(Array.from(markerDataMap.values()), {
fromPatch: true,
})
}
fromPatch 的作用是告诉组件:
text
这次数据不是外部传入的全量 markers,
而是内部数据池合并后的结果。
否则相机移动、聚合刷新时,很容易又回退到旧的 markers prop。
明细点的 diff 渲染
明细模式下,可以按 id 做三件事。
第一步,记录下一次应该存在的 key:
js
const nextKeys = new Set()
第二步,遍历当前点位,只处理新增或变化的点:
js
for (const marker of markers) {
const key = String(marker.id)
const signature = getMarkerSignature(marker)
const oldSignature = markerSnapshotMap.get(key)
const oldEntity = markerEntityMap.get(key)
nextKeys.add(key)
if (oldEntity && oldSignature === signature) {
continue
}
const icon = await getMarkerIcon(marker)
if (oldEntity) {
oldEntity.position = Cesium.Cartesian3.fromDegrees(
Number(marker.longitude),
Number(marker.latitude),
Number(marker.height || 0)
)
oldEntity.billboard.image = icon.icon
oldEntity.billboard.width = icon.width
oldEntity.billboard.height = icon.height
} else {
const entity = viewer.entities.add({
position: Cesium.Cartesian3.fromDegrees(
Number(marker.longitude),
Number(marker.latitude),
Number(marker.height || 0)
),
billboard: {
image: icon.icon,
width: icon.width,
height: icon.height,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
},
})
markerEntityMap.set(key, entity)
}
markerSnapshotMap.set(key, signature)
}
第三步,删除这次已经不存在的点:
js
Array.from(markerEntityMap.keys()).forEach(key => {
if (!nextKeys.has(key)) {
viewer.entities.remove(markerEntityMap.get(key))
markerEntityMap.delete(key)
markerSnapshotMap.delete(key)
}
})
这样一来,后端推 10 个变化点,前端就主要处理这 10 个变化点。
没有变化的 1990 个点会继续留在地图上。
for 循环是不是也很耗性能
这里有一个容易误解的点:
text
增量更新不等于完全不遍历。
在很多实现里,前端仍然会遍历当前点位列表,逐个判断这个点有没有发生变化。
但真正影响性能的通常不是这一次普通 JavaScript 循环,而是循环里面有没有做重操作。
比如:
- 重新创建 Cesium Entity。
- 删除旧 Entity。
- 重新加载图片。
- 重新绘制 Canvas。
- 重新生成 dataURL。
- 重新计算屏幕坐标。
- 重新计算聚合结果。
所以增量更新的关键不是"完全不写 for 循环",而是让大部分没有变化的点快速跳过。
例如这一句:
js
if (oldEntity && oldSignature === signature) {
continue
}
它的作用就是让没有变化的点直接复用旧 Entity。
对于这些点,前端只做了:
text
取 id
生成或读取签名
Map 查询
字符串比较
continue 跳过
它不会做这些更重的事情:
text
不会重新 add Entity
不会 remove Entity
不会重新加载图片
不会重新绘制 Canvas
不会重新创建 billboard
真正进入更新逻辑的,只有新增、删除或展示字段发生变化的点。
举个例子,如果地图上有 2000 个点,这次后端只更新了 10 个点。
全量更新会变成:
text
删除 2000 个 Entity
重新创建 2000 个 Entity
可能重新生成 2000 张图标
增量更新会变成:
text
遍历 2000 个点
1990 个点快速跳过
10 个点更新原有 Entity
这两者的性能差距很大。
普通遍历不是完全没有成本,但和大量 Entity 创建、Canvas 绘制、图片加载相比,它通常不是主要瓶颈。
所以这里更准确的说法是:
text
增量更新不是消灭遍历,而是消灭大部分无意义的重渲染操作。
当然,如果点位达到几万甚至十几万,并且后端高频推送,这时连全量遍历也可能变成压力。
这个阶段就需要继续做:
- 分层数据池。
- patch 级明细更新。
- 聚合计算节流。
- 当前视窗过滤。
- Web Worker 计算。
- 后端聚合。
但对于几千级点位,先通过 signature diff 避免重复创建 Entity,收益已经非常明显。
图标也要缓存
如果点位图标是 Canvas 动态生成的,增量更新还要加一层图标缓存。
缓存 key 可以由图片地址、宽度、边框配置组成:
js
function getMarkerIconCacheKey(marker) {
return [
marker.image || defaultMarkerImg,
marker.imageWidth || "",
marker.border ? "border" : "plain",
].join("|")
}
使用时:
js
async function getMarkerIcon(marker) {
const cacheKey = getMarkerIconCacheKey(marker)
if (markerIconCache.has(cacheKey)) {
return markerIconCache.get(cacheKey)
}
const icon = await createEnhancedMarker(marker)
markerIconCache.set(cacheKey, icon)
return icon
}
这样多个点使用同一张业务图标时,不会反复加载图片、绘制 Canvas、生成 dataURL。
增量更新不只是少操作几个 Entity,也要少做图标生成这种隐形开销。
这里一定要依赖稳定 id
增量更新有一个前提:
text
后端必须给每个点一个稳定 id。
如果没有稳定 id,前端就不知道:
text
这次来的点到底是新点,还是上一次那个点的位置变了。
很多人会用经纬度当 key,这在地图业务里并不稳。
因为实时目标本来就会移动。
经纬度一变,key 就变了,前端会误以为:
text
旧点删除
新点新增
这样就退回到了变相的全量重建。
所以后端最好保证:
json
{
"id": "uav-1002",
"longitude": 120.2368869,
"latitude": 30.2396879
}
id 表示同一个业务对象,坐标只是它当前的位置。
增量更新之后,前端链路变成什么样
上一篇里的链路是:
text
接口请求
↓
数据校验
↓
数据标准化
↓
按类型和状态分层
↓
根据地图层级决定渲染策略
↓
聚合或明细渲染
↓
地图图层更新
加入增量更新以后,可以变成:
text
全量初始化
↓
建立 markerDataMap
↓
建立 markerEntityMap
↓
后端持续推送增量 patch
↓
前端合并 patch
↓
重新判断聚合/明细模式
↓
聚合模式重算聚合点
↓
明细模式只更新变化点
这一步之后,前端承接大量地图点位就更完整了。
一开始加载时,可以按视窗请求一批数据。
后续实时变化时,只处理后端推来的增量。
用户缩放或拖动地图时,再结合聚合策略重新计算展示形态。
这才比较接近真实项目里的地图更新链路。
测试模拟
我做了一个可跑的 mock 对比:同一批模拟点位,分别走"优化前全量重建"和"优化后增量 diff",统计更新耗时、add/remove/update/skip。

总结
增量更新的核心不是写一个新的 update 方法,而是把地图渲染层从"无状态替换"改成"有状态维护"。
简单总结一下:
text
后端不要每次推全量
前端维护当前数据池
地图实体建立 id 索引
每个点记录渲染快照
没有变化的点直接复用
变化的点才更新 Entity
删除的点才 remove Entity
图标生成结果也要缓存
最后一句话:
大量点位的实时更新,不应该每次重建整张地图图层,而应该让后端推变化量,让前端合并状态,再让地图只处理真正变化的那一小部分。