三维 GIS 离线缓存实战:逆向 CesiumOfflineCache,并亲手为它补上"自动更新"能力

三维 GIS 离线缓存实战:逆向 CesiumOfflineCache,并亲手为它补上"自动更新"能力

场景:某政务大屏项目,基于 LSGlobe(Cesium 深度封装的国产三维 GIS SDK)做全域三维可视化。客户要求:大屏断网后仍能打开,且二次打开要"秒开"。方案是引入配套的 CesiumOfflineCache.min.js 离线缓存插件------但接入后我们发现,这个插件比想象中"简陋",于是有了这篇从逆向到改造的完整记录。

一、背景:三维场景为什么需要专门的离线缓存

三维 GIS 场景的资源加载和普通 Web 应用完全不是一个量级:

  • 一个区县级场景,影像瓦片 + 地形瓦片 + 白模/精模,首次加载轻松几百 MB;
  • 瓦片是海量小文件(单张 256×256 的 JPEG,几十 KB),HTTP 请求数动辄上千;
  • 模型 glb 单文件可能几十 MB,且加载后还要解析。

浏览器默认的 HTTP 磁盘缓存对三维场景基本无效:瓦片 URL 常带随机参数、缓存策略由服务端 Cache-Control 决定、大文件(glb)经常被直接排除。所以三维场景"每次打开都重新下载"是常态。

CesiumOfflineCache 的思路很直接:在 Cesium 资源加载管线里加一层 IndexedDB 旁路,所有资源请求先查本地库,命中直接返回,未命中走网络并顺手落库。IndexedDB 的三个特性正好匹配需求:

  1. 容量大(配额通常为磁盘的百分比,GB 级);
  2. 原生支持结构化克隆,可直接存 Blob / ArrayBuffer;
  3. 异步 API,不阻塞渲染主线程。

二、逆向:33KB 混淆包里到底藏了什么

插件是一个 webpack UMD 单文件包,33KB,压缩成一行。直接读是不可能的,但混淆只做了压缩(mangle + 压平),没有做字符串加密,所以标准 API 名都是明文------这就是突破口。

2.1 摸清骨架

先全局搜关键 API 名,立刻能画出依赖图:

text 复制代码
UMD 入口
├── 模块 483: browserify 运行时 + localforage(IndexedDB 封装库)
│   └── 内嵌依赖: promise-polyfill(异步调度 + Promise 实现)
└── 主模块: CesiumOfflineCache 插件本体
    └── 加载即自执行: initLicence() → initCacheProxy()

两个有意思的细节:

  • 它内嵌了一整个 promise-polyfill (MutationObserver / MessageChannel / setImmediate 三级回退的异步调度器),说明打包时目标是兼容老浏览器;
  • 加载后向 window 挂了两个全局:CesiumOfflineCache(插件 API)和 localforage(它的内部依赖,全局泄漏,这是个隐患)。

2.2 核心机制:重写 loadWithXhr

initCacheProxy 的关键一行(还原后):

js 复制代码
LSGlobe.Resource._Implementations.loadWithXhr = function(url, responseType, method, data, headers, deferred, overrideMimeType) { ... }

Resource._Implementations.loadWithXhr 是 Cesium 体系所有资源请求的底层 XHR 通道 ------影像瓦片、地形、glb 模型、geojson,最终都汇到这里。重写它,等于在管线的最底部装了一个旁路,上层所有 Provider(ImageryProvider、TerrainProvider、Model)完全无感知。这是整个插件最聪明的设计:零业务侵入。

还原后的核心逻辑:

js 复制代码
loadWithXhr(url, responseType, method, data, headers, deferred, overrideMimeType) {
  // 缓存 key = 完整请求描述的 JSON 序列化
  const key = JSON.stringify({ url, responseType, method, data, headers, deferred, overrideMimeType });

  // data URI 直接解析返回,不缓存(复刻原生逻辑)
  if (dataUriRE.test(url)) return deferred.resolve(parseDataUri(url, responseType));

  const xhr = new XMLHttpRequest();

  // ★ 缓存读路径:规则命中 → 查 IndexedDB → 命中直接 resolve,连接都不建
  if (judgeUrl(url)) {
    getItem(key).then(cached => cached ? deferred.resolve(cached) : xhr.send(data));
  }

  // 以下全部复刻 Cesium 原生行为
  TrustedServers.contains(url) && (xhr.withCredentials = true);  // 跨域凭证
  xhr.open(method, url, true);
  /* headers / responseType / overrideMimeType / file:// 特判 */

  xhr.onload = function() {
    /* 非 2xx → reject;HEAD/OPTIONS → 返回响应头;204 → resolve() */
    // 成功分支(按 responseType 分 json/text/document/arraybuffer/blob):
    //   ★ 每个分支在 resolve 前插入 setItem(key, response) ------ 写缓存
    setItem(key, response).then(() => deferred.resolve(response));
  };

  // ★ 规则未命中 → 直接发请求,不缓存
  judgeUrl(url) || xhr.send(data);
  return xhr;
}

注意它复刻了原生 loadWithXhr 的全部 响应分支(HEAD/OPTIONS 返回头解析、204 空响应、file:// 协议下 status 0 视为成功、json 手动 parse 回退),只在成功分支前插了一句 setItem。这种"复刻 + 插桩"的写法保证了与 Cesium 行为完全兼容,但也埋了个雷------后面会讲。

2.3 ruleList:默认关死的总开关

js 复制代码
ruleList: new Set,   // 默认空集合!

judgeUrl(url) {
  let hit = false;
  [...this.ruleList].forEach(rule => hit = hit || url.indexOf(rule) > -1 || rule === '*');
  return hit;   // 子串匹配,'*' 全通配
}

ruleList 默认是空 Set ,judgeUrl 恒返回 false------也就是说,插件加载后什么都不缓存,静默地什么都不做。必须手动:

js 复制代码
CesiumOfflineCache.ruleList.add('*');   // 或 add('http://your-tile-server') 只缓存特定服务

这是个非常坑的设计:不报错、不警告、控制台干干净净,你以为接入了,实际上它一行缓存都没写。我们最初接入时就栽在这里------刷新页面、断网测试,发现"离线"完全无效,回头逆向源码才看到这个空 Set。

2.4 授权机制:硬编码的 2099 年

js 复制代码
let o = []; o[0] = 2099;               // 年
async initLicence() {
  o[1] = 4; o[2] = 1;                  // 月、日 → 到期日 2099-04-01
  await localforage.setDriver(localforage.INDEXEDDB);  // 强制 IndexedDB,禁用降级
}
async initCacheProxy() {
  const days = (new Date(o.join('-')) - new Date()) / 864e5;
  if (days <= 0) {
    console.warn('CesiumOfflineCache 数据高速缓存插件,使用期已结束,请获取授权。');
    return this.clear();               // 过期 → 清空缓存库,且不安装代理
  }
  // 安装代理...
}

所谓"授权"就是硬编码日期 2099-04-01,约等于永久。但注意过期后的行为是先清库再罢工------如果哪天你拿到一个试用版插件,过期瞬间你的离线缓存会被无声无息地清掉。

2.5 缓存 key 的脆弱设计

再看一眼 key 的生成:

js 复制代码
const key = JSON.stringify({ url, responseType, method, data, headers, deferred, overrideMimeType });

deferred(when.js 的 Deferred 对象)被序列化进了 key 。函数会被 JSON.stringify 忽略,promise 对象恰好没有可枚举属性、序列化成 {},所以 key 目前是稳定的------但这完全靠 when.js 的内部实现"配合"。一旦 deferred 上出现任何可枚举的可变属性,key 就会漂移,缓存永远无法命中。这是一个能工作、但踩在钢丝上的设计,也提醒我们:给第三方库的内部结构做序列化,本身就是脆弱耦合。

三、接入:三行代码与一个隐藏前提

html 复制代码
<!-- 顺序是硬约束:插件读 window.LSGlobe,必须在 SDK 之后 -->
<script src="<%= BASE_URL %>SDK/LSGlobe/LSGlobe.js"></script>
<script src="<%= BASE_URL %>js/CesiumOfflineCache.min.js"></script>  <!-- 自初始化 -->
<script>
  window.CesiumOfflineCache && window.CesiumOfflineCache.ruleList.add('*');  <!-- 启用开关 -->
</script>

ruleList.add 与异步的 initCacheProxy 没有顺序依赖(judgeUrl 在每次请求时实时读 Set),同步内联脚本远早于 Vue 挂载后的第一个瓦片请求,时序天然安全。

影响面也干净:loadWithXhr 只服务 LSGlobe.Resource 加载的三维资源,业务 axios 请求不经过这条通道,'*' 不会误伤接口。

四、踩坑:两个 IndexedDB 的深坑

4.1 "清理模型"按钮清不掉缓存

项目原有的清理逻辑:

js 复制代码
handleClearIndexDb() {
  window.indexedDB.deleteDatabase('GlbOfflineCache');
  window.indexedDB.deleteDatabase('CesiumOfflineCache');
}

看起来没毛病,实际清不掉 。IndexedDB 规范:deleteDatabase 遇到仍打开的连接 时不会报错、不会失败,而是触发 blocked 事件,挂起等待所有连接关闭------而插件启动后一直持有打开的连接,页面不刷新,连接永不关闭。结果就是:用户点了"清理成功",库原封不动。

修复:走插件自己的 clear()(localforage 在连接内清空,立即生效),deleteDatabase 只作为插件未加载时的回退:

js 复制代码
if (window.CesiumOfflineCache && window.CesiumOfflineCache.clear) {
  window.CesiumOfflineCache.clear();          // 连接内清空,立即生效
} else {
  window.indexedDB.deleteDatabase('CesiumOfflineCache');  // 回退
}

这是 IndexedDB 最经典的坑之一:删除操作是"协商"而非"强制"。

4.2 永远读旧数据:缺失的失效机制

逆向完源码,最失望的发现是:这个插件没有任何新老数据对比机制。

常见失效手段 插件实现
HTTP 协商缓存(存 ETag,回源 If-None-Match) ✗ 不存校验头,不回源
TTL 过期 ✗ 无时间戳
版本号 / 内容 hash ✗ 无

缓存 key 只含请求描述(URL/method/headers),URL 不变 → key 不变 → 永远命中同一条旧数据。命中后连 HTTP 连接都不建立,服务端有没有更新根本无从得知。

于是"数据更新"的实际流程变成了:

text 复制代码
服务端更新模型 → 客户端无感知(读旧缓存)
→ 用户肉眼发现数据不对 → 手动点"清理模型" → 全量重新下载

对比环节由人完成。对"模型偶尔更新"的场景勉强够用,但大屏是 7×24 挂着的,指望值班员发现"这块底图好像旧了"再跑来点按钮,不现实。

五、增强:给插件补上 stale-while-revalidate

目标:命中缓存先返回旧数据(保住秒开和离线) ,同时后台发条件请求校验,变了就更新缓存,下次请求生效。这正是 HTTP 语义里的 stale-while-revalidate 模式。

5.1 设计决策:不改 min.js,外挂补丁

直接改 33KB 的压缩文件?改得动,但插件一升级就全丢。选择外挂补丁:在 min.js 之后加载一个 swr.js,把插件安装好的 loadWithXhr 再包一层。

这里有个关键洞察:插件把 localforage 挂到了 window,而 localforage 的 createInstance({ name }) 是同名共享 的------用同样的参数再建一个实例,拿到的是同一个 IndexedDB store 的句柄,可以直接读写插件的所有缓存条目。不需要 hack 闭包,不需要改一行 min.js:

js 复制代码
const store = window.localforage.createInstance({ name: 'CesiumOfflineCache' });  // 与插件共享

5.2 补丁核心逻辑

js 复制代码
impls.loadWithXhr = function(url, responseType, method, data, headers, deferred, overrideMimeType) {
  // key 生成逻辑与 min.js 保持一致(已知耦合点)
  const key = JSON.stringify({ url, responseType, method, data, headers, deferred, overrideMimeType });

  // 规则外 / 非 GET:完全走原逻辑
  if (!C.judgeUrl(url) || method !== 'GET') {
    return rawLoadWithXhr.apply(this, arguments);
  }

  store.getItem(key).then(entry => {
    if (!entry) return rawLoadWithXhr.apply(this, arguments);  // 未命中:原逻辑
    deferred.resolve(isWrapped(entry) ? entry.body : entry);   // ① 立即返回旧数据
    revalidate(key, url, responseType, headers);               // ② 后台校验
  });
};

后台校验是标准的 HTTP 条件请求:

js 复制代码
function revalidate(key, url, responseType, headers) {
  const xhr = new XMLHttpRequest();   // 裸 XHR:不经过 loadWithXhr,避免递归!
  xhr.open('GET', url, true);
  xhr.responseType = responseType || 'text';
  if (meta.etag)         xhr.setRequestHeader('If-None-Match', meta.etag);
  if (meta.lastModified) xhr.setRequestHeader('If-Modified-Since', meta.lastModified);
  xhr.onload = () => {
    if (xhr.status === 304) {
      store.setItem(key, { body: oldBody, __cocSwrMeta: metaFrom(xhr) });  // 仅刷新校验时间
    } else if (xhr.status >= 200 && xhr.status < 300) {
      store.setItem(key, { body: alignBody(...), __cocSwrMeta: metaFrom(xhr) });  // 更新数据
    }
  };
  xhr.onerror = () => {};  // 离线/断网:静默,缓存照用------离线可用性的关键
  xhr.send();
}

四个必须处理的细节:

① 防递归 。校验请求如果走 LSGlobe.Resource,会再次进入被包装的 loadWithXhr------死循环。必须用裸 XMLHttpRequest。

② 格式兼容 。插件存量缓存存的是裸 body(ArrayBuffer/Blob/字符串/parse 后的对象),补丁的新格式是 { body, __cocSwrMeta }。读取时用 META in v 判断------注意 in 操作符对 Blob/ArrayBuffer 也安全(都是 object),字符串被 typeof 前置挡掉。存量缓存无需清库,平滑升级。

③ 类型对齐 。responseType 为空时,插件原函数可能存的是 JSON.parse 后的对象或 XMLDocument,而校验请求的 xhr.response 是字符串。直接存会导致下次命中时下游拿到类型不一致的数据。解法:按旧缓存值的类型 归一化新响应------旧值是普通对象就对新字符串尝试 JSON.parse,旧值是字符串就保持字符串。

④ 节流与去重 。每次命中都发校验请求等于每个瓦片每次都回源,请求数爆炸。两层防护:meta 里存 checkedAt,10 分钟内不重复校验;内存里维护 inflight Set,同一 key 的并发校验只发一个。304 响应体为空,流量开销可忽略。

5.3 一个绕不开的前提:CORS

JS 读取跨域响应头受 Access-Control-Expose-Headers 限制------服务端不显式暴露 ETag / Last-Modified,getResponseHeader 返回 null,条件请求就无从谈起。所以跨域部署时,瓦片/模型服务的 CORS 配置必须加:

text 复制代码
Access-Control-Expose-Headers: ETag, Last-Modified

拿不到校验头时补丁退化为定期无条件 GET(功能可用、流量增大),同源或代理部署则无此问题。

5.4 改造前后对比

场景 改造前 改造后
首次加载 网络 + 写缓存 不变
命中且未变 直接用旧缓存 先用旧缓存;超 10 分钟发条件请求,304 开销极小
命中且服务端已变 永远旧数据 当次显示旧数据,后台拉新,下次请求生效
离线命中 用缓存 相同(校验失败静默)
存量缓存 --- 自动兼容,无需清库

六、总结:缓存失效,终究要有人做"对比"

回顾整个链路,最有意思的其实是一个架构问题:缓存的新老数据对比,到底由谁做?

  • 浏览器 HTTP 缓存:HTTP 协商(ETag/304),协议层解决;
  • CDN:源站主动失效 + TTL;
  • 这个插件:谁都不做------key 是 URL,命中即用,把对比环节完全甩给了用户的眼睛;
  • 我们的补丁:把对比搬回 HTTP 协商层,同时用 SWR 保住"先展示、后校验"的体验。

三维 GIS 的离线缓存比普通 Web 缓存更棘手的地方在于:资源是海量小文件 + 大文件的混合体,且"数据更新"在政务场景里往往意味着"底图换了、模型改了"这种整批失效。SWR 解决的是"自动感知",而"清理模型"按钮作为整批失效的兜底手段依然值得保留------两者是互补而非替代。

最后留三个逆向第三方混淆包的实用心得:

  1. 标准 API 名不会被混淆 ------indexedDB、createObjectStore、getResponseHeader 这些明文就是最好的路标,全局搜一遍,架构立刻浮出水面;
  2. 先跑通数据流,再抠实现------用 DevTools 的 Application 面板看 IndexedDB 实际结构(库名、key 格式、存的值类型),比硬读压缩代码快得多;
  3. 警惕"能工作"背后的巧合------把 Deferred 对象序列化进缓存 key 还能命中,靠的是 when.js 恰好没有可枚举属性。能跑的代码不等于正确的代码,升级第三方依赖前,先想想哪些行为是"巧合"在撑着。
相关推荐
前进前进前进2 小时前
TRAE 全链路实战:做了一个警示牌一键生成器:纯 Canvas、单文件、零依赖
前端
小万7423 小时前
God‘s Eye View 深度解析:15 路公开信号 + CesiumJS 的浏览器级 OSINT 控制台,60 帧渲染的“真实间谍卫星模拟器”
前端
IT星宿3 小时前
浏览器标签页全是一个图标:我用名字哈希生成 favicon,让每个项目都有身份标识
前端
全栈项目管理程序猿3 小时前
ArcGIS JS 基础教程(32):体元系列 - VoxelSlice 体元切片
前端·javascript
拖孩5 小时前
AI 帮我写的小程序「厨菜记」上线满月了,挣了两百块!!!
前端·后端·微信小程序
用户360055579005 小时前
Kestrel_VLLM - 13 · 为什么排除 MoE FFN Tiling 与 Tensor Core
前端
Hilaku5 小时前
AI 流式输出都用 SSE,什么时候必须换 WebSocket?
前端·javascript·程序员
奇牙coding5 小时前
Codex CLI 升级后多文件编辑静默失效:config.yaml 枚举变更排查与修复指南
java·前端·javascript·ai
小羊没烦恼!6 小时前
Ajax与JSON的一些总结
android·java·前端·数据库·mysql·算法·okhttp