Blog
May 9, 2026 - 6 MIN READ
Cesium 资源请求的 IndexedDB 离线缓存实现

Cesium 资源请求的 IndexedDB 离线缓存实现

解析如何拦截 Cesium.Resource 请求,把影像瓦片、地形瓦片和 3D Tiles 写入 IndexedDB,并控制缓存范围与二次加载性能。

Kevin

Kevin

这篇文章解决什么问题

Cesium 场景里的影像、地形和 3D Tiles 都会拆成大量资源请求。第一次进入场景时,这些请求必须经过网络;第二次进入同一区域时,如果仍然重复下载同一批瓦片,用户会明显感到等待。

这个 Demo 想解决的是浏览器侧的资源复用:在 Cesium 发起请求前先查询 IndexedDB,命中就直接返回本地数据,未命中再走网络,并把响应写入本地缓存。这样固定场景、固定区域或常用视角的二次加载,可以减少对远程服务的依赖。

这里的缓存不是服务端 CDN,也不是完整离线包。它缓存的是 Cesium 请求回来的源数据,例如影像瓦片 Blob、地形瓦片 ArrayBuffer、tileset.json.b3dm/.pnts 等 3D Tiles 内容。Cesium 后续解析、图片解码和 GPU 上传仍然会发生。

核心难点

第一个难点是拦截层级。Cesium 内部有不同资源类型,但大量请求最终会经过 Cesium.Resource。如果在 Viewer 创建之后才安装拦截器,早期的图层、地形或 tileset 请求就可能已经发出,所以缓存工具必须在资源加载前启用。

第二个难点是响应类型还原。影像适合缓存为 Blob,地形和 3D Tiles 更适合保留 ArrayBuffer 或 JSON 原始结构。命中缓存后返回的数据必须和 Cesium 原始请求期望一致,否则内部解析链会被破坏。

第三个难点是缓存范围。IndexedDB 命中不等于一定更快。小图片、SkyBox、Cesium 内置静态 JSON 等资源如果全部进库,反而会增加事务开销。因此这个 Demo 默认只缓存影像瓦片、地形瓦片和 3D Tiles。

实现思路

整体链路可以分成四步。

第一步,包装 Cesium.Resource 的 GET 请求。工具覆盖 fetchArrayBufferfetchBlobfetchJsonfetchImage 等常见入口,把请求统一交给缓存管线处理。

第二步,为每个请求生成稳定 key。key 使用规范化 URL、响应类型、请求方法和 headers 组成的可读签名,保留 query 参数,因为瓦片行列号、层级、token 都是资源身份的一部分。

第三步,按响应类型读写 IndexedDB。命中时从本地恢复 Blob、ArrayBuffer 或 JSON;未命中时调用 Cesium 原始请求,成功后再写入 IndexedDB,并记录资源类型、体积、访问时间和命中次数。

第四步,控制缓存边界。写入后按 lastAccessedAt 做 LRU 淘汰,默认容量限制为 128MB;同时用资源类型过滤减少无关资源进库,让缓存收益集中在真正昂贵的瓦片和模型资源上。

技术流程图

正在渲染流程图...

关键代码解析

缓存 key 不是简单 hash,而是可读请求签名。这样在 DevTools 的 IndexedDB 面板里可以直接看到资源 URL、响应类型和方法,排查瓦片是否命中会方便很多。

function createCacheKey(method, responseType, normalizedUrl, headers = {}) {
  const signature = {
    url: normalizedUrl,
    responseType,
    method,
    headers
  }
  const signatureText = JSON.stringify(signature)

  if (signatureText.length <= READABLE_KEY_MAX_LENGTH) {
    return signatureText
  }

  return JSON.stringify({
    url: `${normalizedUrl.slice(0, READABLE_KEY_URL_PREVIEW_LENGTH)}...`,
    responseType,
    method,
    headers,
    signatureHash: hashString(signatureText)
  })
}

资源类型判断决定了哪些请求能进缓存。这里把地形和 3D Tiles 放在图片判断之前,是为了避免 .pnts.b3dm 或局部 JSON 被误归类成普通资源。

function isDefaultCacheTarget(info) {
  if (info.resourceType === 'terrain' || info.resourceType === '3d-tiles') {
    return true
  }

  if (info.resourceType === 'imagery') {
    return isImageryTileUrl(info.normalizedUrl)
  }

  return false
}

请求管线采用 cache-first。命中后更新访问时间,未命中才走网络;网络结果可序列化时写入 IndexedDB。这里的重点是返回值必须保持 Cesium 期望的类型,而不是统一返回一种自定义结构。

async requestWithCache(resource, responseType, networkLoader, options = {}) {
  const info = this.createRequestInfo(resource, responseType, options.requestOptions)
  const cacheState = this.shouldUseCache(resource, info)

  if (!cacheState.cacheable) {
    return networkLoader()
  }

  const cachedRecord = await this.store.get(info.key)

  if (cachedRecord) {
    const touchedRecord = await this.store.touch(cachedRecord)
    return this.deserializeRecord(touchedRecord, responseType, options.imageOptions)
  }

  const result = await networkLoader()
  await this.writeRecord(info, result, options.contentType)
  return result
}

地形开关的性能问题也来自同一个原则:不要让无关异步工作参与用户交互。关闭地形时只切到椭球地形,已经加载过的 World Terrain provider 保留下来;再次打开时复用 provider,而不是重建 Viewer 或重新走复杂初始化。

效果与边界

这个方案在网络较慢、资源较大、用户反复进入同一场景时收益最明显。比如远程地形瓦片、大体积 3D Tiles、常用影像区域,二次加载可以减少大量网络等待。

但 IndexedDB 不能跳过 Cesium 的后续工作。.b3dm.pnts 命中后仍然要解析,图片 Blob 命中后仍然要解码,纹理和 buffer 仍然要上传 GPU。如果资源本来就是本地 /public 文件,或者浏览器 HTTP cache 已经很快,IndexedDB 反而可能因为事务和数据还原多出一点成本。

因此缓存范围比“全量缓存”更重要。这个 Demo 默认只缓存影像瓦片、地形瓦片和 3D Tiles,避免 SkyBox、内置资源和普通页面图片挤占容量,也避免它们在频繁请求时制造额外事务。

如果后续要做生产级离线能力,可以考虑 Service Worker + Cache API 承接请求级响应缓存,再用 IndexedDB 保存元数据、容量估算和 LRU 索引。前者更接近浏览器原生 HTTP 缓存,后者更适合做可控管理。

小结

Cesium 资源缓存的核心不是“把所有请求都塞进 IndexedDB”,而是找到真正值得缓存的资源,并保证命中后返回的数据类型不破坏 Cesium 原有解析链。

这条链路可以概括为:提前安装 Cesium.Resource 拦截器,生成稳定请求签名,按响应类型读写 IndexedDB,再用资源类型和 LRU 控制缓存边界。

性能优化也遵循同一个思路。缓存命中只省掉网络,不省掉解析;交互开关只应该切换必要状态,不应该重建整个 Viewer。把缓存范围和场景生命周期都收窄到真正需要的部分,二次加载体验才会稳定。

凯文的技术博客 • © 2026