三维 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 的三个特性正好匹配需求:
- 容量大(配额通常为磁盘的百分比,GB 级);
- 原生支持结构化克隆,可直接存
Blob/ArrayBuffer; - 异步 API,不阻塞渲染主线程。
二、逆向:33KB 混淆包里到底藏了什么
插件是一个 webpack UMD 单文件包,33KB,压缩成一行。直接读是不可能的,但混淆只做了压缩(mangle + 压平),没有做字符串加密,所以标准 API 名都是明文——这就是突破口。
2.1 摸清骨架
先全局搜关键 API 名,立刻能画出依赖图:
UMD 入口
├── 模块 483: browserify 运行时 + localforage(IndexedDB 封装库)
│ └── 内嵌依赖: promise-polyfill(异步调度 + Promise 实现)
└── 主模块: CesiumOfflineCache 插件本体
└── 加载即自执行: initLicence() → initCacheProxy()
两个有意思的细节:
- 它内嵌了一整个 promise-polyfill(
MutationObserver/MessageChannel/setImmediate三级回退的异步调度器),说明打包时目标是兼容老浏览器; - 加载后向
window挂了两个全局:CesiumOfflineCache(插件 API)和localforage(它的内部依赖,全局泄漏,这是个隐患)。
2.2 核心机制:重写 loadWithXhr
initCacheProxy 的关键一行(还原后):
LSGlobe.Resource._Implementations.loadWithXhr = function(url, responseType, method, data, headers, deferred, overrideMimeType) { ... }
Resource._Implementations.loadWithXhr 是 Cesium 体系所有资源请求的底层 XHR 通道——影像瓦片、地形、glb 模型、geojson,最终都汇到这里。重写它,等于在管线的最底部装了一个旁路,上层所有 Provider(ImageryProvider、TerrainProvider、Model)完全无感知。这是整个插件最聪明的设计:零业务侵入。
还原后的核心逻辑:
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:默认关死的总开关
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——也就是说,插件加载后什么都不缓存,静默地什么都不做。必须手动:
CesiumOfflineCache.ruleList.add('*'); // 或 add('http://your-tile-server') 只缓存特定服务
这是个非常坑的设计:不报错、不警告、控制台干干净净,你以为接入了,实际上它一行缓存都没写。我们最初接入时就栽在这里——刷新页面、断网测试,发现"离线"完全无效,回头逆向源码才看到这个空 Set。
2.4 授权机制:硬编码的 2099 年
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 的生成:
const key = JSON.stringify({ url, responseType, method, data, headers, deferred, overrideMimeType });
deferred(when.js 的 Deferred 对象)被序列化进了 key。函数会被 JSON.stringify 忽略,promise 对象恰好没有可枚举属性、序列化成 {},所以 key 目前是稳定的——但这完全靠 when.js 的内部实现"配合"。一旦 deferred 上出现任何可枚举的可变属性,key 就会漂移,缓存永远无法命中。这是一个能工作、但踩在钢丝上的设计,也提醒我们:给第三方库的内部结构做序列化,本身就是脆弱耦合。
三、接入:三行代码与一个隐藏前提
<!-- 顺序是硬约束:插件读 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 "清理模型"按钮清不掉缓存
项目原有的清理逻辑:
handleClearIndexDb() {
window.indexedDB.deleteDatabase('GlbOfflineCache');
window.indexedDB.deleteDatabase('CesiumOfflineCache');
}
看起来没毛病,实际清不掉。IndexedDB 规范:deleteDatabase 遇到仍打开的连接时不会报错、不会失败,而是触发 blocked 事件,挂起等待所有连接关闭——而插件启动后一直持有打开的连接,页面不刷新,连接永不关闭。结果就是:用户点了"清理成功",库原封不动。
修复:走插件自己的 clear()(localforage 在连接内清空,立即生效),deleteDatabase 只作为插件未加载时的回退:
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 连接都不建立,服务端有没有更新根本无从得知。
于是"数据更新"的实际流程变成了:
服务端更新模型 → 客户端无感知(读旧缓存)
→ 用户肉眼发现数据不对 → 手动点"清理模型" → 全量重新下载
对比环节由人完成。对"模型偶尔更新"的场景勉强够用,但大屏是 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:
const store = window.localforage.createInstance({ name: 'CesiumOfflineCache' }); // 与插件共享
5.2 补丁核心逻辑
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 条件请求:
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 配置必须加:
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 解决的是"自动感知",而"清理模型"按钮作为整批失效的兜底手段依然值得保留——两者是互补而非替代。
最后留三个逆向第三方混淆包的实用心得:
- 标准 API 名不会被混淆——
indexedDB、createObjectStore、getResponseHeader这些明文就是最好的路标,全局搜一遍,架构立刻浮出水面; - 先跑通数据流,再抠实现——用 DevTools 的 Application 面板看 IndexedDB 实际结构(库名、key 格式、存的值类型),比硬读压缩代码快得多;
- 警惕"能工作"背后的巧合——把 Deferred 对象序列化进缓存 key 还能命中,靠的是 when.js 恰好没有可枚举属性。能跑的代码不等于正确的代码,升级第三方依赖前,先想想哪些行为是"巧合"在撑着。
浙公网安备 33010602011771号