Skip to content

悬空之影

背景:弹窗关闭后再也打不开,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.tsshowModelPopup()✅ 每次 new SlideMenu()
motion-popup.tsshowMotionPopup()✅ 每次 new SlideMenu()
settings.tsshowSettings()✅ 每次 new SlideMenu()
scene-menu.tsshowSceneMenu()✅ 每次 new SlideMenu()
env-menu.tsshowEnvMenu()✅ 每次 new SlideMenu()

验证

Riku 启动应用。

Jieling 点击 Model 按钮。弹窗打开。她点画布,弹窗关闭。她再点 Model 按钮——

弹窗出现了。

她又试了 Motion、Settings、Scene、Env。全部正常。

「过了。」她说。

Riku 点头。他靠在椅背上,看着窗外泛白的天色。

「四轮,」他说,「四轮才找到那张该死的发票。」


尾声

后来 Riku 在 .workbuddy/memory/ 里记了一笔,标题是「innerHTML 与管家」:

innerHTML = "" 是毁灭性操作。它不通知任何人,直接把容器里的所有 DOM 节点拉走烧掉。但任何持有这些节点引用的对象——比如管家手里的发票——并不知道这件事。它们还以为家具在原地,操作全打在虚空里。

症状:按钮状态正常,aria 正常,visible 类正常添加。但弹窗不出现。

根因:内容从未被挂载。

教训:每次打开弹窗时重建 SlideMenu 实例,成本忽略不计,但彻底消除了幽灵引用的问题。对于低频 UI,「重建」比「复用但小心维护」要安全得多。

他在最后加了一句:

管家需要一张新发票。每一次。

窗外,晨光已经漫过了屋顶的轮廓线。


教训:innerHTML = "" 是原子弹。任何持有旧 DOM 引用的对象,在它之后都变成了幽灵。不要复用已销毁 DOM 的引用——要么在清空前主动销毁引用,要么每次重建引用。