弱网不白屏:三维资源离线缓存的设计与实现(拦截 loadWithXhr、SWR 校验与 LRU 淘汰)

弱网不白屏:三维资源离线缓存的设计与实现(拦截 loadWithXhr、SWR 校验与 LRU 淘汰)

系列第三篇。第一篇《三维大屏踩坑记》里提到的 DataCloneError 和跨域 ETag 告警,都出自同一个模块——三维资源离线缓存。这篇把这个模块的设计完整讲一遍:它要解决什么问题、为什么这样设计、以及每个降级路径背后的取舍。


一、问题:弱网环境下的三维白屏

项目是部署在矿区/工地的监测预警大屏,三维场景要加载瓦片、模型、地形——单次浏览动辄几百个请求、几十上百 MB。网络一旦不稳,表现就是三维场景加载到一半卡死或白屏,而监控业务恰恰在最需要它的时候(事故现场回看)最依赖它。

项目原本有一个闭源的 CesiumOfflineCache.min.js 做这件事,但行为诡异:缓存永不失效、存储只增不减、弱网下请求挂死。替换它之前,先逆向梳理了它的问题清单——这份清单反过来就成了新设计的验收标准:

原插件问题 新实现对策
无失效机制(同 URL 永远旧数据) fresh/stale/expired 三态 + SWR 条件请求校验
无淘汰策略(只增不减) LRU + 条数/字节双上限
deleteDatabase 被打开连接阻塞,清缓存"不生效" 连接内 clear(),立即生效
缓存 key 序列化了 deferred 对象(脆弱) 稳定可读的 method|responseType|dataHash|url
localforage 泄漏到 window 手写 IndexedDB 薄封装,零依赖零全局泄漏
用量统计报的是整个 origin 内存索引精确统计本库字节
同 URL 并发重复请求 inflight 去重
无超时(弱网请求挂死) 超时 abort + 旧缓存兜底

二、拦截点:站在"必经之路"上,但别复刻路本身

三维引擎所有资源请求最终都走 Resource._Implementations.loadWithXhr——这就是拦截点,与原插件相同。真正的设计差异在怎么拦。

原插件的做法是复刻一份"收到响应后如何解析"的逻辑(根据 responseType 解析 JSON/文本/二进制)。这条路很脆弱:引擎升级改了解析行为,缓存层就悄悄产出不一致的类型。

新实现的做法:不复刻解析逻辑,代理 deferred。三维引擎内部用 when.js 风格的 deferred 传递结果,而调用方实际只用到了它的三个成员:resolve、reject、promise。那就造一个鸭子类型的代理对象传给原生实现,在出口插桩:

// 代理 deferred:网络与响应解析完全复用原生实现,仅在出口插桩
const proxyDeferred = {
  resolve: onResolve,          // 原生解析完调用 → 这里落缓存
  reject: onReject,            // 失败 → 这里决定是否回退旧缓存
  promise: deferred.promise    // 透传原 promise,调用方 await 的是它
};
xhr = rawLoadWithXhr.call(this, url, responseType, method, data, headers, proxyDeferred, overrideMimeType);

这样带来一个重要性质:写入缓存的值就是原生产出的值,类型天然对齐。唯一的例外是 XMLDocument——DOM 节点进不了 IndexedDB(第一篇的 DataCloneError),落库前序列化为字符串、meta 打标、读取时还原,上层无感。

启发:拦截/包装第三方库时,代理出口优于复刻内部。你复刻的每一行内部逻辑,都是对方升级时可能背刺你的地方。


三、缓存 key:稳定、可读、不含"环境噪音"

key = method | responseType | hash(data) | url

三个刻意排除项:不含 deferred(原插件把 deferred 序列化进 key,每次调用对象都不同,等于缓存永不命中)、不含 headers(瓦片请求无敏感头,头变化不应使缓存漂移)、data 做哈希而非原文(POST body 可能很长,32 位滚动哈希转 base36 足够区分且 key 可读)。

function makeKey(url, responseType, method, data) {
  let dataSig = '';
  if (data != null) {
    try {
      dataSig = typeof data === 'string' ? data : JSON.stringify(data);
    } catch (e) { dataSig = String(data); }
  }
  return method + '|' + (responseType || '') + '|' + hash(dataSig) + '|' + url;
}

四、核心:三态生命周期 + SWR 后台校验

简单 TTL(过期就重新下载)对三维资源太浪费——瓦片基本不变,全量重下是几十 MB 的代价。这里借用了 HTTP 缓存的 stale-while-revalidate 思想,按条目年龄分三态:

请求进来
 ├─ 无缓存 ──────────────→ 走网络(inflight 去重),成功落库
 ├─ age < freshTtl(10min) ─→ 直接用缓存(零网络)
 ├─ age < staleTtl(7天)  ─→ 先返回旧数据,后台 SWR 校验
 └─ 超过 staleTtl ────────→ 网络优先(新数据直出),失败回退旧缓存

SWR 校验(后台进行,不阻塞渲染)分两条路径:

路径 1:有校验头 → 条件 GET。 带 If-None-Match(ETag)/If-Modified-Since 发条件请求:304 零 body 开销最小,只需续期(把 cachedAt 重置,freshTtl 从头算);200 则更新条目。

路径 2:无校验头 → HEAD 补头。 首次写入时可能没拿到 ETag(跨域未暴露,见第一篇),先发 HEAD 请求补齐头信息,再比对 ETag / Last-Modified / Content-Length 判断是否变化;有变化才发全量 GET。如果三个头一个都拿不到,直接放弃校验——退化为纯 TTL,而不是对每个瓦片发全量 GET 轰炸服务端。

// revalidate 节流:每 key 最小校验间隔,避免同一资源被高频重复校验
if (isOffline() || revalidating.has(key)) return;
if (Date.now() - (meta.checkedAt || 0) < cfg.swrMinInterval) return;

两个细节:后台校验用裸 XHR 而不是走 loadWithXhr——否则会递归进自己的拦截;校验失败(onerror/ontimeout)静默结束,缓存照用——校验是优化,不是正确性前提。


五、弱网兜底:宁可旧数据,不要白屏

这是整个模块的第一性原理。具体落在三处:

1. 超时必须手动结算。 给原生 XHR 设超时后 abort(),有个隐蔽的规范细节:abort 只派发 abort 事件,不触发 onerror——原生的 deferred 永远不会 settle,调用方就挂死了。所以超时后要自己调 onReject:

// 弱网超时:abort 不触发原生 onerror(XHR 规范只派发 abort 事件),必须手动结算
timer = setTimeout(() => {
  try { xhr.abort(); } catch (e) { /* 忽略 */ }
  onReject(new Error('[offlineCache] timeout: ' + url));
}, cfg.requestTimeout);

2. 失败/超时回退旧缓存。 网络路径带着 staleBody(过期条目的旧值)出发,一旦失败就回退——含过期条目。旧数据也许过期一周,但一张旧瓦片远好于一块白屏。

3. navigator.onLine 只当优化信号。 它只能告诉你"网卡说没网",不能告诉你"弱网"。所以离线时用它快速失败(不发注定失败的请求),弱网兜底完全靠超时,不赌 onLine 的准确性。

另外,网络路径自带 inflight 去重:同一 key 的并发请求共享同一次网络结果。三维场景初始化时几十个组件同时要同一份配置,这个去重把请求数直接除以并发度。


六、存储设计:双 store 分离 + 内存索引

body 与 meta 分开两个 objectStore。启动时只游标扫描 meta 构建 LRU 索引——几百 MB 的瓦片数据一个字节都不碰,索引构建瞬间完成:

async function buildIndex() {
  await idbEachMeta((key, meta) => {
    lruIndex.set(key, { size: meta.size || 0, lastAccess: meta.lastAccess || 0, hits: meta.hits || 0 });
    totalBytes += meta.size || 0;
  });
}

LRU 双上限:条数 3000 + 字节 500MB,超限淘汰至 80% 水位(而不是刚好压线,避免每次写入都触发淘汰)。按 lastAccess 排序逐个删除。

命中不写库。touch() 只更新内存索引的 lastAccess,持久化延迟到下次 put/revalidate 顺带完成——大屏一帧可能命中几十个瓦片,每次命中都写库的话 IndexedDB 就是瓶颈。

clear() 走连接内清空。原插件用 deleteDatabase 清缓存,但只要有连接没关就会被阻塞,表现为"清了没效果"。新实现直接在自有连接里对两个 store clear(),立即生效。


七、可观测性:缓存层必须能回答"它干了什么"

缓存是最容易"静默失效"的组件(第一篇的 DataCloneError 就是被 catch 吞了半个月),所以计数器从第一天就要有:

const stats = { hits: 0, staleHits: 0, misses: 0, network: 0, timeouts: 0, fallbacks: 0, evicted: 0 };

debug 模式下挂 window.__offlineCache 调试句柄:getStats() 看命中率与回退次数,getEntryInfo(url) 按地址查条目元数据。上线后排查"离线为什么没生效",靠的就是这些计数——fallbacks 高说明弱网兜底在干活,timeouts 高说明该调超时或该查网络了。


八、设计原则小结

  1. 拦截点选"必经之路",但代理出口、不复刻内部——被拦截者的内部逻辑不是你的契约;
  2. 缓存语义要三态,不要二态——"有缓存/没缓存"之间还有一整个 stale 区间,SWR 让你用零感知的代价保持新鲜;
  3. 弱网的第一性原理:宁可旧数据,不要失败——超时、回退、离线快速失败,都是这句话的实现;
  4. 大对象与元数据分离——启动索引、命中路径、淘汰决策都不该触碰大对象;
  5. 每个降级路径都要有最终兜底——IndexedDB 异常就直通原生不缓存,校验头拿不到就退化为纯 TTL,任何一层坏掉都不该影响渲染;
  6. 缓存层必须有计数器——不可观测的缓存等于没有缓存。

系列目录:《三维大屏踩坑记》(第一篇)· 《老项目性能优化实录》(第二篇)· 本篇(第三篇)

posted on 2026-10-25 08:10  中文还在写码  阅读(5)  评论(0)    收藏  举报