/**
* 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;