Appearance
悬空之影
背景:弹窗关闭后再也打不开,aria-expanded 和 visible 类都正常,但弹窗不出现。
过程:四轮追查——根因是 innerHTML = "" 销毁了 DOM 节点,但 modelStack 仍持有旧引用。每次打开重建实例修复。
序
Jieling 坐在屏幕前,表情像在辨认一道没有正确答案的谜题。
「很奇怪,」她说,手指悬在 Model 按钮上方,「弹窗打开,正常。点一下画布,弹窗关闭。再点 Model 按钮——什么都没发生。」
Riku 凑过来看。按钮上的 aria-expanded 属性在切换,CSS 的 visible 类也在加加减减。一切信号都表明弹窗「应该」打开了。
但屏幕是空的。
「像管家出了问题。」Riku 说。
「管家?」
「你让管家去布置房间,他点头哈腰说『好的好的』,但房间里空空如也——因为他手里的采购单和实际家具对不上号。」
Jieling 挑眉。「你的意思是……他不知道自己该布置什么?」
「或者他知道,但房间已经不是他认识的那个房间了。」
这个比喻在当时还很模糊。Riku 自己也不确定它通向哪里。但接下来的四轮追查,会让这个比喻逐渐显影,最终露出它真正的形状。
第一轮:toggle 的嫌疑
发现异常
「我怀疑是 toggleOverlay 的时序问题,」Riku 说,盯着 menu.ts 里的这段逻辑,「closeAllOverlays() 先跑,把 visible 类清掉。然后 showFn() 渲染内容。再然后才加 visible。」
他画出时间线:
closeAllOverlays() → showFn() → el.classList.add("visible")
↑________________|
时序倒转?「如果这三步里有一步跳过了,或者某个地方又单独调了 closeAllOverlays——visible 就加不上去。」
假设
假设:时序竞争。closeAllOverlays() 在某个未知角落被二次触发,覆盖了刚加上的 visible 类。
追查
Riku 把 toggleOverlay 的完整代码读了三遍:
typescript
} else {
closeAllOverlays();
showFn();
el.classList.remove("overlay-fade-out");
el.classList.add("visible");
}逻辑清晰,顺序正确。没有发现二次触发 closeAllOverlays 的路径。
他又检查了 showModelPopup() 内部——这个函数是 showFn 的实际指向。
typescript
export function showModelPopup(): void {
dom.sceneOverlay.innerHTML = "";
dom.sceneOverlay.classList.add("overlay-model");
dom.sceneOverlay.dataset.popupType = "model";
if (!stackRegistry.modelStack) {
stackRegistry.modelStack = makeModelStack();
}
stackRegistry.modelStack.reset(buildModelRoot());
}也是干净的。没有偷偷调用 closeAllOverlays。
反转
「不是这里的问题。」
Riku 把椅子往后一推,揉了揉眉心。toggle 的逻辑没有问题,showModelPopup 也没有问题。但弹窗就是打不开。
「像管家没有问题,」他自言自语,「但房间是空的。」
Jieling 在旁边插了一句:「那会不会是房间的问题,不是管家的问题?」
Riku 一愣。
第二轮:aria 的幽灵
发现异常
Riku 重新审视 aria-expanded 的流向。
aria-expanded 是按钮的状态,它说「弹窗已展开」。这个状态和弹窗内容是否实际显示,应该是一致的。但如果按钮说「展开了」,弹窗却是空的——
两者之间有断层。
假设
假设:syncNavAriaExpanded() 在错误的时间被调用,导致按钮状态和弹窗实际状态不同步。
追查
Riku 找到了这段代码:
typescript
if (last === showFn) {
el.classList.remove("visible");
setPopupOpen(false);
syncNavAriaExpanded(); // ← 关闭时同步 aria
_lastOverlayFn.delete(id);
}关闭分支里,syncNavAriaExpanded() 在移除 visible 类之后才调用。这意味着按钮的 aria-expanded 会在弹窗实际消失之后才变成 false。
他觉得找到了问题:关闭流程里 aria 同步太晚了,导致关闭动画期间按钮状态和弹窗状态不一致,可能触发某种异常路径。
他把这个调用移到了移除 visible 类之前:
typescript
if (last === showFn) {
syncNavAriaExpanded(); // ← 提前同步
el.classList.remove("visible");
setPopupOpen(false);
_lastOverlayFn.delete(id);
}验证
aria-expanded 现在在弹窗消失之前就变回 false 了。Riku 让 Jieling 再测一遍。
Jieling 点击 Model 按钮。弹窗打开。她点画布,弹窗关闭。她再点 Model 按钮。
什么都没发生。
反转
「aria 修好了,」Riku 盯着屏幕说,「但弹窗还是打不开。」
他把笔往桌上一扔。「所以问题不在 aria,不在 toggle 时序,也不在 syncNavAriaExpanded 的位置。」
房间里安静了几秒。
「那问题在 showFn 内部?」Jieling 试探着说。
「或者——」Riku 顿了顿,「在 showFn 之前。」
第三轮:fade-out 的残留
发现异常
Riku 注意到一个细节:overlay-fade-out。
这个类是弹窗 cross-fade 动画的一部分。当一个弹窗被另一个替代时,旧弹窗会获得 overlay-fade-out 类,慢慢淡出。
但如果用户在动画进行中按了 ESC,或者直接点画布呢?
假设
假设:overlay-fade-out 类残留在 sceneOverlay 上,下次打开时遮住了内容——因为这个类的 CSS 是 opacity: 0。
追查
Riku 找到了 closeAllOverlays():
typescript
export function closeAllOverlays(): void {
document.querySelectorAll<HTMLElement>("[data-overlay].visible").forEach(el => {
el.classList.remove("visible");
});
// ...
}这里只移除了 visible,没有移除 overlay-fade-out。
他又看了 cross-fade 分支:
typescript
} else {
closeAllOverlays();
showFn();
el.classList.remove("overlay-fade-out");
el.classList.add("visible");
}打开时会清理一次 overlay-fade-out,但只有当执行到 else 分支时才会。如果关闭是通过 ESC 或画布点击走的其他路径,这个清理就被跳过了。
修复
他在 closeAllOverlays() 里加了一行:
typescript
export function closeAllOverlays(): void {
document.querySelectorAll<HTMLElement>("[data-overlay].visible").forEach(el => {
el.classList.remove("visible", "overlay-fade-out"); // ← 新增
});
// ...
}验证
「好了吗?」Jieling 问。
她点击 Model 按钮。弹窗打开。点画布。弹窗关闭。再点 Model 按钮。
什么都没发生。
反转
Riku 沉默了很久。
「……还是不对。」
他盯着天花板,脑子里在转:toggle 没问题,aria 没问题,fade-out 清理了也没用。问题一定在更底层——在 showModelPopup 的内部,在 innerHTML = "" 之后的某个地方。
「Jieling,」他突然说,「管家手里有一叠发票。你知道发票是什么吗?」
「不知道。」
「是 DOM 节点的引用。」
第四轮:发票
发现异常
Riku 重新看 showModelPopup:
typescript
export function showModelPopup(): void {
dom.sceneOverlay.innerHTML = ""; // ← 这里
dom.sceneOverlay.classList.add("overlay-model");
dom.sceneOverlay.dataset.popupType = "model";
if (!stackRegistry.modelStack) {
stackRegistry.modelStack = makeModelStack();
}
stackRegistry.modelStack.reset(buildModelRoot());
}innerHTML = "" — 这行代码把 sceneOverlay 里的所有 DOM 节点都销毁了。
但 stackRegistry.modelStack 还活着。
这个 SlideMenu 实例是第一次打开弹窗时创建的,之后一直存在。它内部有一个 _root 属性,指向 buildModelRoot() 返回的 DOM 树。
而 buildModelRoot() 生成的节点,是挂载在 sceneOverlay 下的。
当 innerHTML = "" 执行时,这些节点被销毁了。但 modelStack._root 还持有它们的引用。
假设
假设:当 reset() 被调用时,SlideMenu 操作的是 modelStack._root 里保存的旧引用——但这些引用指向的 DOM 节点已经不存在了。操作一个「幽灵节点」,结果是什么都不发生。
追查
Riku 写了一个最小化测试:
typescript
const container = document.createElement("div");
const span = document.createElement("span");
container.appendChild(span);
document.body.appendChild(container);
console.log(span.parentNode); // <div>
container.innerHTML = ""; // 销毁 span
console.log(span.parentNode); // null —— span 变成了孤儿
span.textContent = "ghost"; // 操作无效,span 不在 DOM 树里这就是问题所在:持有旧引用不等于拥有活节点。
他用「管家和发票」向 Jieling 解释:
你第一次让管家布置房间,他记下了每一件家具的发票——「沙发在角落,茶几在中央」。这些发票就是
modelStack._root持有的 DOM 引用。后来你喊了「清空房间」(
innerHTML = "")。家具被拉走了,但发票还在管家手里。下次你说「重新布置」,管家拿出发票开始念:「沙发搬到这里,茶几挪到那里……」但房间是空的——家具已经不存在了。管家的操作全部打在虚空里。
你看见的是:命令发出去了,但房间空空如也。
「所以不是命令的问题,」Jieling 说,「是家具不在了,命令还在对着空气喊。」
「对。」
「那怎么办?」
「把旧管家换掉,」Riku 说,「每次打开弹窗,都建一个新的 SlideMenu 实例。让它从没见过那些已经不存在的家具。」
修复
typescript
// 修复前
if (!stackRegistry.modelStack) {
stackRegistry.modelStack = makeModelStack();
}
stackRegistry.modelStack.reset(buildModelRoot());
// 修复后
stackRegistry.modelStack = makeModelStack();
stackRegistry.modelStack.reset(buildModelRoot());五个弹窗函数全部做了相同修改:
| 文件 | 函数 | 修改 |
|---|---|---|
library-core.ts | showModelPopup() | ✅ 每次 new SlideMenu() |
motion-popup.ts | showMotionPopup() | ✅ 每次 new SlideMenu() |
settings.ts | showSettings() | ✅ 每次 new SlideMenu() |
scene-menu.ts | showSceneMenu() | ✅ 每次 new SlideMenu() |
env-menu.ts | showEnvMenu() | ✅ 每次 new SlideMenu() |
验证
Riku 启动应用。
Jieling 点击 Model 按钮。弹窗打开。她点画布,弹窗关闭。她再点 Model 按钮——
弹窗出现了。
她又试了 Motion、Settings、Scene、Env。全部正常。
「过了。」她说。
Riku 点头。他靠在椅背上,看着窗外泛白的天色。
「四轮,」他说,「四轮才找到那张该死的发票。」
尾声
后来 Riku 在 .workbuddy/memory/ 里记了一笔,标题是「innerHTML 与管家」:
innerHTML = ""是毁灭性操作。它不通知任何人,直接把容器里的所有 DOM 节点拉走烧掉。但任何持有这些节点引用的对象——比如管家手里的发票——并不知道这件事。它们还以为家具在原地,操作全打在虚空里。症状:按钮状态正常,aria 正常,
visible类正常添加。但弹窗不出现。根因:内容从未被挂载。
教训:每次打开弹窗时重建 SlideMenu 实例,成本忽略不计,但彻底消除了幽灵引用的问题。对于低频 UI,「重建」比「复用但小心维护」要安全得多。
他在最后加了一句:
管家需要一张新发票。每一次。
窗外,晨光已经漫过了屋顶的轮廓线。
教训:innerHTML = "" 是原子弹。任何持有旧 DOM 引用的对象,在它之后都变成了幽灵。不要复用已销毁 DOM 的引用——要么在清空前主动销毁引用,要么每次重建引用。