接上篇offlineCache —— 三维资源弱网/离线缓存

/**
 * offlineCache —— 三维资源弱网/离线缓存
 * ---------------------------------------------------------------------------
 * 替代 CesiumOfflineCache.min.js,针对其逆向分析发现的问题逐一改进:
 *
 * | 原插件问题                        | 本实现对策 |
 * | --------------------------------- | ---------- |
 * | 无失效机制(同 URL 永远旧数据)     | fresh/stale/expired 三态 + SWR 条件请求校验(If-None-Match/If-Modified-Since),无校验头时退化为 HEAD 头比对 |
 * | 无淘汰策略(只增不减)              | LRU + 条数/字节双上限,超限淘汰至 80% 水位 |
 * | deleteDatabase 被打开连接阻塞       | clear() 走自有连接内清空,立即生效,不依赖 deleteDatabase |
 * | 缓存 key 序列化 deferred(脆弱)    | key = method|responseType|dataHash|url,稳定可读,不含 deferred/headers |
 * | window.localforage 全局泄漏         | 手写 IndexedDB 薄封装,零依赖、零全局泄漏(仅 debug 模式挂调试句柄) |
 * | getUseSize 报整个 origin 用量       | 内存索引精确统计本库字节 |
 * | 同 URL 并发重复请求                 | inflight 去重,并发等待者共享同一次网络结果 |
 * | 无超时(弱网请求挂死)              | 请求超时 abort 并手动结算,失败/超时回退旧缓存(含过期条目)——宁可旧数据不要白屏 |
 *
 * 拦截点与原插件相同:重写 LSGlobe.Resource._Implementations.loadWithXhr。
 * 关键差异:不复刻原生响应解析逻辑,而是代理 deferred(鸭子类型 resolve/reject/promise),
 * 网络路径完全复用 Cesium 原生实现——写入值即原生产出值,天然无类型对齐问题。
 *
 * 缓存条目双 store 存储:body(响应本体,可能为大对象)+ meta(元数据),
 * 启动时仅游标扫描 meta 构建 LRU 索引,不触碰大对象。
 */

const DEFAULTS = {
  rules: [],                       // URL 子串规则数组,'*' 表示全部(同原插件 ruleList 语义)
  maxEntries: 3000,                // LRU 条数上限
  maxBytes: 500 * 1024 * 1024,     // 字节总量上限
  freshTtl: 10 * 60 * 1000,        // 新鲜期:期内直接用缓存,不回源
  staleTtl: 7 * 24 * 3600 * 1000,  // 陈旧上限:期内 SWR 后台校验;超期网络优先
  swrMinInterval: 10 * 60 * 1000,  // 后台校验节流:每 key 最小间隔
  requestTimeout: 15 * 1000,       // 网络超时:超时 abort,回退旧缓存或失败
  debug: false
};

const DB_NAME = 'GisOfflineCache';
const DB_VERSION = 1;
const STORE_BODY = 'body';
const STORE_META = 'meta';

let cfg = null;
let db = null;
let rawLoadWithXhr = null;
let installed = false;
const lruIndex = new Map();      // key -> { size, lastAccess, hits }
let totalBytes = 0;
const inflight = new Map();      // key -> Promise(网络去重)
const revalidating = new Set();  // 后台校验去重
const stats = { hits: 0, staleHits: 0, misses: 0, network: 0, timeouts: 0, fallbacks: 0, evicted: 0 };

function log(...args) {
  if (cfg && cfg.debug) console.log('[offlineCache]', ...args);
}

// ---------------------------------------------------------------------------
// IndexedDB 薄封装(零依赖)
// ---------------------------------------------------------------------------

function openDb() {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, DB_VERSION);
    req.onupgradeneeded = () => {
      const d = req.result;
      if (!d.objectStoreNames.contains(STORE_BODY)) d.createObjectStore(STORE_BODY);
      if (!d.objectStoreNames.contains(STORE_META)) d.createObjectStore(STORE_META);
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

function idbGet(store, key) {
  return new Promise((resolve, reject) => {
    const r = db.transaction(store).objectStore(store).get(key);
    r.onsuccess = () => resolve(r.result);
    r.onerror = () => reject(r.error);
  });
}

function idbPutEntry(key, body, meta) {
  return new Promise((resolve, reject) => {
    const t = db.transaction([STORE_BODY, STORE_META], 'readwrite');
    t.objectStore(STORE_BODY).put(body, key);
    t.objectStore(STORE_META).put(meta, key);
    t.oncomplete = () => resolve();
    t.onerror = () => reject(t.error);
    t.onabort = () => reject(t.error);
  });
}

function idbPutMetaOnly(key, meta) {
  return new Promise((resolve, reject) => {
    const t = db.transaction(STORE_META, 'readwrite');
    t.objectStore(STORE_META).put(meta, key);
    t.oncomplete = () => resolve();
    t.onerror = () => reject(t.error);
    t.onabort = () => reject(t.error);
  });
}

function idbDeleteEntry(key) {
  return new Promise((resolve, reject) => {
    const t = db.transaction([STORE_BODY, STORE_META], 'readwrite');
    t.objectStore(STORE_BODY).delete(key);
    t.objectStore(STORE_META).delete(key);
    t.oncomplete = () => resolve();
    t.onerror = () => reject(t.error);
    t.onabort = () => reject(t.error);
  });
}

function idbClearAll() {
  return new Promise((resolve, reject) => {
    const t = db.transaction([STORE_BODY, STORE_META], 'readwrite');
    t.objectStore(STORE_BODY).clear();
    t.objectStore(STORE_META).clear();
    t.oncomplete = () => resolve();
    t.onerror = () => reject(t.error);
    t.onabort = () => reject(t.error);
  });
}

function idbEachMeta(cb) {
  return new Promise((resolve, reject) => {
    const req = db.transaction(STORE_META).objectStore(STORE_META).openCursor();
    req.onsuccess = () => {
      const c = req.result;
      if (!c) return resolve();
      cb(c.key, c.value);
      c.continue();
    };
    req.onerror = () => reject(req.error);
  });
}

// ---------------------------------------------------------------------------
// 工具函数
// ---------------------------------------------------------------------------

function hash(s) {
  let h = 0;
  for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
  return (h >>> 0).toString(36);
}

// 稳定 key:不含 deferred(原插件的脆弱设计)、不含 headers(瓦片请求无敏感头且头变化不应使缓存漂移)
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;
}

function matchRule(url) {
  for (const r of cfg.rules) {
    if (r === '*' || url.indexOf(r) > -1) return true;
  }
  return false;
}

function estimateSize(v) {
  if (v == null) return 0;
  if (v instanceof ArrayBuffer) return v.byteLength;
  if (ArrayBuffer.isView(v)) return v.byteLength;
  if (typeof Blob !== 'undefined' && v instanceof Blob) return v.size;
  if (typeof v === 'string') return v.length * 2;
  if (typeof Document !== 'undefined' && v instanceof Document) return 4096; // XML 粗估
  try {
    return JSON.stringify(v).length * 2;
  } catch (e) {
    return 0;
  }
}

// navigator.onLine 只作"跳过网络"的优化信号(不可靠),弱网兜底靠超时
function isOffline() {
  return typeof navigator !== 'undefined' && navigator.onLine === false;
}

function withCredentialsFor(url) {
  const LSG = window.LSGlobe;
  return !!(LSG && LSG.TrustedServers && LSG.TrustedServers.contains(url));
}

function copyHeaders(xhr, headers) {
  if (!headers) return;
  for (const h in headers) {
    if (Object.prototype.hasOwnProperty.call(headers, h)) xhr.setRequestHeader(h, headers[h]);
  }
}

// ---------------------------------------------------------------------------
// 缓存写路径与 LRU 淘汰
// ---------------------------------------------------------------------------

async function cachePut(key, url, value, extra) {
  if (value == null) return; // 204 等空响应不缓存
  const size = estimateSize(value);
  const now = Date.now();
  const old = lruIndex.get(key);
  const meta = {
    url,
    size,
    cachedAt: now,
    lastAccess: now,
    checkedAt: now,
    hits: old ? old.hits : 0,
    etag: (extra && extra.etag) || null,
    lastModified: (extra && extra.lastModified) || null,
    contentLength: (extra && extra.contentLength) || null
  };
  try {
    await idbPutEntry(key, value, meta);
  } catch (e) {
    log('put failed:', e);
    return;
  }
  if (old) totalBytes -= old.size;
  lruIndex.set(key, { size, lastAccess: now, hits: meta.hits });
  totalBytes += size;
  evictIfNeeded();
}

// revalidate 200 更新时:responseType 为空时按旧值类型对齐(原生可能产出 parse 后 JSON/XMLDocument)
async function cachePutAligned(key, url, response, responseType, extra) {
  let value = response;
  if (!responseType && typeof response === 'string') {
    try {
      const old = await idbGet(STORE_BODY, key);
      const oldIsPlain =
        old != null && typeof old === 'object' &&
        !(old instanceof Blob) && !(old instanceof ArrayBuffer) && !ArrayBuffer.isView(old) &&
        !(typeof Document !== 'undefined' && old instanceof Document);
      if (oldIsPlain) value = JSON.parse(response);
    } catch (e) {
      /* 解析失败保持字符串 */
    }
  }
  await cachePut(key, url, value, extra);
}

async function evictIfNeeded() {
  if (lruIndex.size <= cfg.maxEntries && totalBytes <= cfg.maxBytes) return;
  const targetEntries = Math.floor(cfg.maxEntries * 0.8);
  const targetBytes = Math.floor(cfg.maxBytes * 0.8);
  const ordered = [...lruIndex.entries()].sort((a, b) => a[1].lastAccess - b[1].lastAccess);
  let bytes = totalBytes;
  const victims = [];
  for (const [k, v] of ordered) {
    if (lruIndex.size - victims.length <= targetEntries && bytes <= targetBytes) break;
    victims.push(k);
    bytes -= v.size;
  }
  for (const k of victims) {
    lruIndex.delete(k);
    try {
      await idbDeleteEntry(k);
    } catch (e) {
      /* 单条删除失败忽略 */
    }
  }
  totalBytes = bytes;
  stats.evicted += victims.length;
  log('evicted', victims.length, 'entries');
}

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;
  });
  log('index built:', lruIndex.size, 'entries,', (totalBytes / 1048576).toFixed(1), 'MB');
}

function touch(key, meta) {
  meta.hits = (meta.hits || 0) + 1;
  const e = lruIndex.get(key);
  if (e) {
    e.lastAccess = Date.now();
    e.hits = meta.hits;
  }
  // lastAccess 持久化延迟到下次 put/revalidate,避免每次命中写库
}

// ---------------------------------------------------------------------------
// 后台校验(SWR revalidate)
// ---------------------------------------------------------------------------

function revalidate(key, url, meta, responseType, headers) {
  if (isOffline() || revalidating.has(key)) return;
  if (Date.now() - (meta.checkedAt || 0) < cfg.swrMinInterval) return;
  revalidating.add(key);
  const finish = () => revalidating.delete(key);
  const withCred = withCredentialsFor(url);

  // 路径 1:已有校验头 → 条件 GET(304 续期 / 200 更新),304 零 body 开销最小
  if (meta.etag || meta.lastModified) {
    const xhr = new XMLHttpRequest(); // 裸 XHR:不经过 loadWithXhr,避免递归
    xhr.open('GET', url, true);
    xhr.responseType = responseType || 'text';
    if (withCred) xhr.withCredentials = true;
    if (meta.etag) xhr.setRequestHeader('If-None-Match', meta.etag);
    if (meta.lastModified) xhr.setRequestHeader('If-Modified-Since', meta.lastModified);
    copyHeaders(xhr, headers);
    xhr.timeout = cfg.requestTimeout;
    xhr.onload = () => {
      if (xhr.status === 304) {
        meta.checkedAt = Date.now();
        meta.cachedAt = Date.now(); // 续期:freshTtl 从头计算
        idbPutMetaOnly(key, meta).then(finish, finish);
      } else if (xhr.status >= 200 && xhr.status < 300) {
        cachePutAligned(key, url, xhr.response, responseType, {
          etag: xhr.getResponseHeader('ETag'),
          lastModified: xhr.getResponseHeader('Last-Modified'),
          contentLength: parseInt(xhr.getResponseHeader('Content-Length'), 10)
        }).then(finish, finish);
      } else {
        finish();
      }
    };
    xhr.onerror = finish; // 弱网/断网失败静默,缓存照用
    xhr.ontimeout = finish;
    xhr.send();
    return;
  }

  // 路径 2:无校验头(首次写入或 CORS 未暴露 ETag)→ HEAD 补头 + Content-Length 比对
  const xhr = new XMLHttpRequest();
  xhr.open('HEAD', url, true);
  if (withCred) xhr.withCredentials = true;
  xhr.timeout = cfg.requestTimeout;
  xhr.onload = () => {
    const etag = xhr.getResponseHeader('ETag');
    const lm = xhr.getResponseHeader('Last-Modified');
    const cl = parseInt(xhr.getResponseHeader('Content-Length'), 10);
    if (!etag && !lm && (isNaN(cl) || cl <= 0)) {
      finish(); // 头全部拿不到:跳过(行为退化为纯 TTL,不做全量 GET 轰炸)
      return;
    }
    const changed =
      (etag && etag !== meta.etag) ||
      (lm && lm !== meta.lastModified) ||
      (!isNaN(cl) && meta.contentLength && cl !== meta.contentLength);
    if (changed) {
      fetchFull(key, url, responseType, headers, withCred, finish);
    } else {
      meta.etag = etag || meta.etag;
      meta.lastModified = lm || meta.lastModified;
      meta.contentLength = cl || meta.contentLength;
      meta.checkedAt = Date.now();
      idbPutMetaOnly(key, meta).then(finish, finish);
    }
  };
  xhr.onerror = finish;
  xhr.ontimeout = finish;
  xhr.send();
}

function fetchFull(key, url, responseType, headers, withCred, done) {
  const xhr = new XMLHttpRequest();
  xhr.open('GET', url, true);
  xhr.responseType = responseType || 'text';
  if (withCred) xhr.withCredentials = true;
  copyHeaders(xhr, headers);
  xhr.timeout = cfg.requestTimeout;
  xhr.onload = () => {
    if (xhr.status >= 200 && xhr.status < 300) {
      cachePutAligned(key, url, xhr.response, responseType, {
        etag: xhr.getResponseHeader('ETag'),
        lastModified: xhr.getResponseHeader('Last-Modified'),
        contentLength: parseInt(xhr.getResponseHeader('Content-Length'), 10)
      }).then(done, done);
    } else {
      done();
    }
  };
  xhr.onerror = done;
  xhr.ontimeout = done;
  xhr.send();
}

// ---------------------------------------------------------------------------
// 网络路径:inflight 去重 + 代理 deferred + 超时 + 旧缓存兜底
// ---------------------------------------------------------------------------

function fetchThrough(key, url, responseType, method, data, headers, deferred, overrideMimeType, staleBody) {
  // 离线:快速失败/兜底,不发请求
  if (isOffline()) {
    if (staleBody != null) {
      stats.fallbacks++;
      deferred.resolve(staleBody);
    } else {
      deferred.reject(new Error('[offlineCache] offline and no cache: ' + url));
    }
    return;
  }
  const pending = inflight.get(key);
  if (pending) {
    // 并发去重:等待者共享同一次网络结果
    pending.then(v => deferred.resolve(v), e => deferred.reject(e));
    return;
  }
  stats.network++;

  let settleShared;
  const shared = new Promise((resolve, reject) => {
    settleShared = { resolve, reject };
  });
  inflight.set(key, shared);

  let settled = false;
  let timer = null;
  const cleanup = () => {
    if (timer) clearTimeout(timer);
    inflight.delete(key);
  };
  const once = fn => arg => {
    if (settled) return;
    settled = true;
    cleanup();
    fn(arg);
  };

  const onResolve = once(v => {
    cachePut(key, url, v); // 异步写缓存,不阻塞渲染
    settleShared.resolve(v);
    deferred.resolve(v);
  });
  const onReject = once(e => {
    if (staleBody != null) {
      // 弱网/超时兜底:回退旧缓存(含过期条目)——宁可旧数据不要失败
      stats.fallbacks++;
      log('network failed, serve stale:', url);
      settleShared.resolve(staleBody);
      deferred.resolve(staleBody);
    } else {
      settleShared.reject(e);
      deferred.reject(e);
    }
  });

  // 代理 deferred:鸭子类型(when.js Deferred 只被原生用到 resolve/reject/promise),
  // 网络与响应解析完全复用原生实现,仅在出口插桩
  const proxyDeferred = { resolve: onResolve, reject: onReject, promise: deferred.promise };
  let xhr = null;
  try {
    xhr = rawLoadWithXhr.call(this, url, responseType, method, data, headers, proxyDeferred, overrideMimeType);
  } catch (e) {
    onReject(e);
    return;
  }
  // 弱网超时:abort 不触发原生 onerror(XHR 规范只派发 abort 事件),必须手动结算
  if (xhr && cfg.requestTimeout > 0) {
    timer = setTimeout(() => {
      stats.timeouts++;
      log('timeout, abort:', url);
      try {
        xhr.abort();
      } catch (e) {
        /* 忽略 */
      }
      onReject(new Error('[offlineCache] timeout: ' + url));
    }, cfg.requestTimeout);
  }
}

// ---------------------------------------------------------------------------
// 拦截入口
// ---------------------------------------------------------------------------

function wrappedLoadWithXhr(url, responseType, method, data, headers, deferred, overrideMimeType) {
  // 非 GET / data URI / 规则未命中 → 原生直通(缓存语义只针对 GET 静态资源)
  if (method !== 'GET' || /^data:/i.test(url) || !matchRule(url)) {
    return rawLoadWithXhr.apply(this, arguments);
  }
  const self = this;
  const args = arguments;
  const key = makeKey(url, responseType, method, data);

  idbGet(STORE_META, key)
    .then(meta => {
      if (!meta) {
        return fetchThrough.call(self, key, url, responseType, method, data, headers, deferred, overrideMimeType, null);
      }
      const age = Date.now() - meta.cachedAt;
      touch(key, meta);
      if (age < cfg.freshTtl || isOffline()) {
        // 新鲜期内或离线:直接用缓存
        stats.hits++;
        return idbGet(STORE_BODY, key).then(body => {
          if (body !== undefined) {
            deferred.resolve(body);
          } else {
            // meta/body 不一致(异常):走网络
            fetchThrough.call(self, key, url, responseType, method, data, headers, deferred, overrideMimeType, null);
          }
        });
      }
      if (age < cfg.staleTtl) {
        // 陈旧:SWR —— 先返回旧数据,后台条件请求校验
        stats.staleHits++;
        idbGet(STORE_BODY, key).then(body => {
          if (body !== undefined) deferred.resolve(body);
        });
        revalidate(key, url, meta, responseType, headers);
        return;
      }
      // 过期:网络优先(新数据直出),失败回退旧缓存
      return idbGet(STORE_BODY, key).then(body =>
        fetchThrough.call(self, key, url, responseType, method, data, headers, deferred, overrideMimeType, body)
      );
    })
    .catch(e => {
      // IndexedDB 异常:降级直通原生(不缓存),不影响渲染
      log('idb error, fallback to network:', e);
      rawLoadWithXhr.apply(self, args);
    });
  // 异步结算,调用方只依赖 deferred(与原插件行为一致),返回值无需透传
}

// ---------------------------------------------------------------------------
// 对外 API
// ---------------------------------------------------------------------------

async function install(options) {
  if (installed) return true;
  cfg = Object.assign({}, DEFAULTS, options || {});
  if (typeof window === 'undefined' || !window.indexedDB) return false;
  const impls = window.LSGlobe && window.LSGlobe.Resource && window.LSGlobe.Resource._Implementations;
  if (!impls) {
    console.warn('[offlineCache] window.LSGlobe 未就绪,请在 SDK 脚本之后调用 install()');
    return false;
  }
  try {
    db = await openDb();
    await buildIndex();
  } catch (e) {
    console.warn('[offlineCache] IndexedDB 不可用,缓存未启用:', e);
    return false;
  }
  rawLoadWithXhr = impls.loadWithXhr;
  impls.loadWithXhr = wrappedLoadWithXhr;
  installed = true;
  log('installed, rules =', cfg.rules);
  if (cfg.debug) window.__offlineCache = api; // 调试句柄(仅 debug 模式,避免全局泄漏)
  return true;
}

const api = {
  install,
  // 连接内清空,立即生效(规避原插件 deleteDatabase 被打开连接阻塞的问题)
  async clear() {
    if (!db) return;
    await idbClearAll();
    lruIndex.clear();
    totalBytes = 0;
    log('cache cleared');
  },
  // 精确统计本库字节(原插件 getUseSize 报的是整个 origin 的用量)
  getUseSize() {
    return totalBytes;
  },
  getStats() {
    return Object.assign({ entries: lruIndex.size, bytes: totalBytes }, stats);
  },
  judgeUrl(url) {
    return installed && matchRule(url);
  },
  // 调试:按 URL 查缓存条目元数据
  async getEntryInfo(url) {
    if (!db) return [];
    const out = [];
    await idbEachMeta((key, meta) => {
      if (meta.url === url) out.push(Object.assign({ key }, meta));
    });
    return out;
  }
};

export default api;

 

 

posted on 2026-10-19 16:20  中文还在写码  阅读(8)  评论(0)    收藏  举报