三维大屏踩坑记:Viewer 的"二次进入"之谜、IndexedDB 拒收 DOM、跨域 ETag 告警
项目背景:Vue 2 SPA + 基于 Cesium 二次开发的三维引擎(下文统称三维引擎)+ IndexedDB 离线缓存 + 数据大屏。最近一轮维护集中修了三个 bug,排查过程和修复方案都挺典型,记录下来。这三个问题看似不相关,但复盘时发现它们有一个共同点------都发生在"边界"上:组件生命周期与全局状态的边界、内存对象与持久化存储的边界、同源策略的边界。
坑一:二次进入页面,监测点标签全部消失
现象
三维页面第一次进入一切正常。退出再进,地图能加载、影像正常,但所有监测点标签一个都不渲染,控制台报错:
Cannot read properties of undefined (reading 'scene')
报错位置不在业务代码里,而在标签模块解绑旧监听的那一行。
根因:模块级单例撞上组件生命周期
为了性能,所有 DIV 标签共享一个渲染循环:模块级变量 sharedViewer 持有 viewer 引用,由 scene.postRender 每帧统一驱动所有标签做位置更新(带脏检查,相机静止时开销趋近于零):
js
// label.js(修复前)
let sharedBound = false;
let sharedViewer = null;
function ensureSharedPostRender(viewer) {
if (sharedBound && sharedViewer === viewer) return;
// viewer 变化(场景重建)时解绑旧监听
if (sharedBound && sharedViewer && sharedViewer.scene && sharedViewer.scene.postRender) {
sharedViewer.scene.postRender.removeEventListener(sharedPostRender);
}
sharedViewer = viewer;
sharedViewer.scene.postRender.addEventListener(sharedPostRender);
sharedBound = true;
}
问题出在 SPA 的页面销毁上。离开页面时 viewer 被 destroy() 了,但没有任何人去注销这个共享渲染循环 ------sharedViewer 依然指向那个已销毁的 viewer。而三维引擎的 Viewer 销毁后内部 _cesiumWidget 会被置空,再读 .scene 就直接抛 TypeError。
于是二次进入页面时,新标签注册走到"解绑旧监听"这一步就炸了。更要命的是,这个异常发生在注册路径上------第一个标签注册失败,后面的标签全部跟着失败,表现就是"全部消失"。
修复:销毁顺序 + 陈旧引用防御
两处修改,缺一不可。
第一处,理顺销毁顺序 。原则很简单:谁依赖 viewer,谁先销毁。标签的共享渲染循环依赖 viewer,所以要在 viewer.destroy() 之前先注销:
js
// index.vue beforeDestroy(修复后,节选)
beforeDestroy() {
// 1. 清理定时器
// 2. 先销毁全部 DivLabel(其共享渲染循环以模块级变量持有 viewer 引用,
// 须在 viewer 销毁前注销,防止 sharedViewer 残留已销毁 viewer)
destroyAllDivLabels();
// 3. 销毁鼠标事件处理器(同样依赖 viewer)
// 4. 最后销毁 Viewer 实例
}
第二处,对"陈旧引用"做防御。就算销毁顺序都对了,也保不住所有路径(比如异常分支跳过了清理)。解绑旧监听前先判断旧 viewer 是否已销毁,再兜一层异常:
js
// label.js(修复后)
if (sharedBound && sharedViewer) {
try {
const staleDestroyed =
typeof sharedViewer.isDestroyed === 'function' && sharedViewer.isDestroyed();
if (!staleDestroyed && sharedViewer.scene && sharedViewer.scene.postRender) {
sharedViewer.scene.postRender.removeEventListener(sharedPostRender);
}
} catch (err) {
// 旧 viewer 已失效,其渲染循环已随销毁终止,无需也无法解绑
}
}
这里有个容易纠结的点:try/catch 吞异常不是坏味道吗?在这个场景里,旧 viewer 已销毁意味着它的渲染循环已经随销毁自然终止,"解绑"这个动作本身已经没有意义------异常恰好说明无事可做。防御性代码要有明确的语义注释,否则后来者会以为它在掩盖别的 bug。
启发
- SPA 里凡是模块级变量持有组件实例引用的地方,都是生命周期错配的重灾区。写的时候问自己一句:组件销毁后,这个引用谁来清?
- 销毁顺序原则:依赖者先于被依赖者销毁。
- 注册路径上对"陈旧引用"做防御,别让一个历史遗留的坏引用,阻断整条初始化链路。
坑二:IndexedDB 拒收 DOM ------ DataCloneError
背景
为了支撑弱网/离线场景下的三维浏览,我们写了一个离线缓存层:重写三维引擎 Resource._Implementations.loadWithXhr 这个拦截点,把响应写入 IndexedDB(body + meta 双 store,启动时只游标扫描 meta 建 LRU 索引)。网络路径完全复用引擎原生实现,写入值即原生产出值,"天然无类型对齐问题"------注释里当初是这么写的。
现象
缓存全景场景的 XML 响应时,put 直接抛 DataCloneError,缓存写入静默失败(被 catch 吃掉了),离线模式打开就是白板。
根因:结构化克隆的边界
IndexedDB 存储对象用的是结构化克隆算法 (Structured Clone Algorithm)。它的支持范围比 JSON 宽(Blob、ArrayBuffer、Map、Set 都行),但有一条硬边界:DOM 节点不可克隆 。而三维引擎原生对 XML 响应的产出就是 XMLDocument------一个如假包换的 DOM 节点。
修复:序列化落库,打标还原
思路:Document 落库前用 XMLSerializer 序列化成字符串,在 meta 上打 xml: true 标记;读取时按标记用 DOMParser 解析回 Document------还原成与原生产出一致的类型,上层消费方完全无感。
js
// DOM Document(含 XMLDocument)无法结构化克隆,需字符串化存取
function isDocument(v) {
return typeof Document !== 'undefined' && v instanceof Document;
}
// 读取还原:meta.xml 标记的条目以字符串存储,取出时解析回 Document
function decodeBody(meta, body) {
if (meta && meta.xml && typeof body === 'string' && typeof DOMParser !== 'undefined') {
try {
return new DOMParser().parseFromString(body, 'text/xml');
} catch (e) { /* 解析失败返回原始字符串 */ }
}
return body;
}
async function cachePut(key, url, value, extra) {
if (value == null) return;
// ...构建 meta...
if (isDocument(value)) {
meta.xml = true;
value = new XMLSerializer().serializeToString(value);
}
await idbPutEntry(key, value, meta);
}
隐蔽的第二现场:更新路径的类型对齐
修完写入还没完。缓存有 revalidate 逻辑:带 ETag/Last-Modified 发条件请求,304 就续用旧值,200 就更新。更新时 responseType 是空的,需要按"旧值的类型"对齐新响应------旧值是普通对象就 JSON.parse,这个分支原本就有。但旧值是 XML 时,它落库已经是字符串了,从 body store 里读出来的 old 也是字符串,旧的类型探测逻辑根本识别不出来。
所以要把旧条目的 meta 传进来,靠 meta.xml 标记对齐:
js
async function cachePutAligned(key, url, response, responseType, extra, oldMeta) {
let value = response;
if (!responseType && typeof response === 'string') {
const oldIsPlain = /* ...旧值是普通对象... */;
if (oldIsPlain) {
value = JSON.parse(response);
} else if (oldMeta && oldMeta.xml) {
// 旧条目为 XML:对齐解析回 Document
value = new DOMParser().parseFromString(response, 'text/xml');
}
}
await cachePut(key, url, value, extra);
}
启发
- 往 IndexedDB 塞任何东西之前,先想一遍结构化克隆的边界。
DataCloneError往往被上层 catch 吞掉,表现为"缓存静默失效",很难第一时间定位。 - "存取类型对齐"必须在所有写入路径上成立------首次写入、revalidate 更新、对齐解析,漏一条路径,缓存里就会留下类型不一致的脏数据。
坑三:跨域下读 ETag ------ Refused to get unsafe header
现象
控制台持续刷告警:
Refused to get unsafe header "ETag"
根因:CORS 的默认暴露头白名单
跨域响应下,浏览器只允许 JS 读取简单响应头 :Cache-Control、Content-Language、Content-Type、Expires、Last-Modified。ETag 不在名单里------除非服务端显式配置 Access-Control-Expose-Headers: ETag。在跨域响应上调用 xhr.getResponseHeader('ETag'),浏览器直接拒绝并抛出上述告警。
我们的缓存用 ETag/Last-Modified/Content-Length 三件套做 revalidate 校验,而三维资源(瓦片、模型)恰恰都在跨域的 OSS/CDN 上,于是每次校验都告警一次。
修复:无告警的降级读取
xhr.getAllResponseHeaders() 有个特性:它只返回已被 CORS 暴露的头,且不触发告警。那就从它的原始文本里手动解析:
js
function getHeaderSafe(xhr, name) {
const lines = (xhr.getAllResponseHeaders() || '').split(/\r\n/);
const lower = name.toLowerCase();
for (let i = 0; i < lines.length; i++) {
const idx = lines[i].indexOf(':');
if (idx > -1 && lines[i].slice(0, idx).trim().toLowerCase() === lower) {
return lines[i].slice(idx + 1).trim();
}
}
return null;
}
拿不到 ETag 就返回 null,校验链自动退化为 Last-Modified / Content-Length 比对------这个兜底本来就在,所以功能无损,只是弱化了一致性协商的精度。根治方案是服务端加一行 Access-Control-Expose-Headers: ETag,但如果资源服务不在自己手里,客户端降级读取就是最务实的解法。
启发
- CORS 暴露头是服务端配置的事,客户端读不到不是 bug,是规范。
- 客户端能做的是"不触发告警的降级读取",前提是你的校验逻辑本来就有降级链。
附:同一次提交里顺手修的两个小问题
1. 无缝滚动克隆 DOM,Vue 事件失效。 vue-seamless-scroll 的实现是把列表 DOM 复制一份做无缝衔接,克隆出来的节点上没有 Vue 的事件绑定,点击下半区毫无反应。解法是事件委托:根节点统一 @click,用 e.composedPath() 找到目标卡片,再从 data-obj 取数据。顺带做了个优化------data-obj 原来每次渲染都 JSON.stringify 整个数据项,现在只存 { name, index },点击时回查列表:
js
getDataObj(name, index) {
return JSON.stringify(index === undefined ? { name } : { name, index });
}
2. v-for 的 key 别用 Math.random()。 原代码里无主键时兜底 Math.random()------每次重渲染 key 全变,等于强制 Vue 销毁重建全部节点,滚动列表直接闪瞎。无业务主键时回退 index 就够了。
写在最后
复盘这三个坑,共性很清晰:
| 坑 | 边界 |
|---|---|
| Viewer 二次进入全灭 | 组件生命周期 ↔ 模块级全局状态 |
| DataCloneError | 内存对象 ↔ IndexedDB 持久化 |
| unsafe header 告警 | 页面 JS ↔ 同源策略 |
边界处的问题有个共同特点:单测和常规联调很难覆盖------你得真的"退出再进"、真的"缓存一个 XML"、真的"跨一个域"。所以这类问题值得沉淀成检查清单:全局引用谁清理?持久化的类型对齐了吗?跨域头读得到吗?
希望这篇踩坑记能帮你少踩几个。