4. 前端地图大量点位实时更新优化:后端推增量,前端做 diff

前言

上一篇文章聊了大量地图点位的分层渲染和聚合策略。

那篇主要解决的是:

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 已经存在,就更新。

这比后端拆成 addsupdatesdeletes 三个数组更好用一些。

当然,如果业务已经有自己的协议,也不一定非要叫这个名字,关键是要有稳定的点位 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
图标生成结果也要缓存

最后一句话:

大量点位的实时更新,不应该每次重建整张地图图层,而应该让后端推变化量,让前端合并状态,再让地图只处理真正变化的那一小部分。

相关推荐
SoaringHeart1 小时前
Flutter最佳实践:Web项目中文字体首次加载乱码问题解决
前端·flutter
西安小哥2 小时前
破局与重生:大厂前端如何借力 AI 转型“超级全栈“
前端·人工智能
sunly_2 小时前
TypeScript总结:16、面向对象速查
前端·javascript·typescript
MXN_小南学前端2 小时前
React超长文本域中实现“返回顶部”浮动按钮
前端·javascript·react.js
学习嵌入式的小周2 小时前
Notepad++8.8.7下载安装(附安装包)
前端·notepad++
gs801403 小时前
解构 Cordis:面向“时空可组合性”的 TypeScript 元框架深度剖析
前端·javascript·typescript
岁岁种桃花儿3 小时前
Vue核心语法第一篇:Vue是什么?
前端·javascript·vue.js
观无4 小时前
若依EasyExcel实现单元格合并
开发语言·前端·javascript
愚公搬代码4 小时前
【愚公系列】《Web应用安全》001-VMware的安装
前端·安全