Skip to content

弹窗之战

背景:多弹窗同时打开时显隐切换错乱,点击画布后全部无法再打开。

过程:事件竞态修复 + 弹窗状态机统一,消灭多套弹窗显隐逻辑。


一、幽灵点击

屏幕上的 3D 模型安静地旋转着,MikuMikuAR 的界面在深色背景下泛着微光。五个导航按钮整齐排列在左侧:模型、动作、场景、环境、设置。

我盯着那个标着"场景"的按钮——#btnScene

点了一下。

弹窗闪了一下,然后消失了。

又点了一下。

这次弹窗出现了,但似乎有些迟疑。再点环境按钮,弹窗关闭了——而不是切换到环境菜单。

"见鬼。"

我已经跟这个按钮周旋了一个下午。它就像一个幽灵:明明写着同样的代码,模型按钮 #btnMainAction 工作得完美无缺,而场景按钮却总是行为诡异。有时候弹窗,有时候不弹,有时候闪一下就消失。

模型按钮的逻辑简单直接:点击 → 弹窗打开,再点击 → 弹窗关闭。一个干净利落的 togglePopup 函数,一个布尔值 popupOpen 跟踪状态,一个事件监听器。毫不花哨,从不犯错。

场景按钮呢?它身上绑着两个事件监听器。

二、两个监听器

我打开 main.ts,翻到初始化函数。

typescript
dom.btnScene?.addEventListener("click", async () => {
    const m = await import("./scene-menu");
    toggleOverlay("sceneOverlay", m.showSceneMenu);
});

这一行看起来人畜无害:点击按钮,动态加载场景菜单模块,然后调用 toggleOverlay 来切换弹窗。和模型按钮的模式一样,只是用了 toggleOverlay 而不是 togglePopup

但问题是——这不是唯一的监听器。

我打开 scene-menu.ts,翻到文件末尾:

typescript
dom.btnScene?.addEventListener("click", showSceneMenu);

第二行。在模块加载时注册的第二个监听器。

所以每次点击场景按钮,发生的不是一件事,而是两件事:

  1. Handler 1(来自 main.ts):异步导入 scene-menu,然后 toggleOverlay——检查弹窗是否可见,可见则关闭,不可见则打开。
  2. Handler 2(来自 scene-menu.ts 的模块级代码):直接调用 showSceneMenu——closeAllOverlays() 然后加上 visible 类。

第一次点击时,只有 Handler 1 运行——因为 Handler 2 是在 Handler 1 的异步导入过程中才注册上去的。但从第二次点击开始,两个处理器都会触发。

于是出现了这样的竞态:

  • 弹窗关闭时点击 → Handler 1 打开弹窗 → Handler 2 也打开弹窗 → 结果:弹窗正常打开 ✅
  • 弹窗打开时点击 → Handler 1 关闭弹窗 → Handler 2 立即重新打开 → 结果:弹窗闪烁一下,永远关不掉 ❌

模型按钮只有一个监听器,所以它永远不会遇到这个问题。

我删掉了 scene-menu.ts 末尾那两行多余的监听器注册。模型按钮和场景按钮现在站在了同一起跑线上。

问题解决了。

……才怪。

三、共享的容器

"场景和环境,共用同一个弹窗容器?"

我盯着 index.html 里的结构。场景按钮的 aria-controls 指向 #sceneOverlay,环境按钮的 aria-controls 也指向 #sceneOverlay

原来如此。

场景和环境共享一个 DOM 容器。这在设计上节省了节点——毕竟用户不会同时打开场景和环境,它们本质上是同一个面板的不同视图。但 toggleOverlay 的逻辑是:

如果弹窗可见 → 关闭它
否则 → 打开它

所以当你点击场景打开弹窗,再点击环境时,toggleOverlay 看到 #sceneOverlay 已经有 visible 类——于是一把把它关掉了。

它不知道你想"切换"内容。它只知道"开"和"关"。

我加了一个 Map 来追踪每个弹窗最后用的是哪个显示函数:

typescript
const _lastOverlayFn = new Map<string, () => void>();

如果同一个按钮再次点击(相同的 showFn),就关闭。如果是不同按钮(不同的 showFn),就切换内容而不是关闭。

但这引出了另一个问题——

四、消失的动画

"切换的时候,没有过渡动画了。"

用户说得对。因为切换只是瞬间移除 visible 类又瞬间加回去,浏览器来不及渲染过渡。200 毫秒的 opacitytransform 过渡被压缩成了闪光般的一次闪烁。

"弹窗共用一个容器是错误的决定吗?"用户问。"还是所有弹窗不共用是错误的决定?"

这是个好问题。我把选项摊在桌面上:

  • A:分开容器——体验最流畅,但要多维护一个 DOM 节点,重复一套 CSS。
  • B:异步切换——等关闭动画结束再打开,但 200ms 的延迟治标不治本。
  • C:交叉淡入淡出——在同一容器上,先淡出旧内容,换内容,再淡入新内容。
  • D:全部独立容器——太重了,DOM 会爆炸。

用户选了 C。

"JS 接管过渡:旧的 fadeOut,新的 fadeIn。既不用加新 DOM,还能保住 MenuStack 架构。UI 表现力也最好。"

我写了一个 waitForTransition 工具函数:

typescript
function waitForTransition(el: HTMLElement): Promise<void> {
    return new Promise(resolve => {
        const dur = parseFloat(getComputedStyle(el).transitionDuration) * 1000 || 0;
        if (dur <= 0) { resolve(); return; }
        const done = () => { el.removeEventListener("transitionend", done); resolve(); };
        el.addEventListener("transitionend", done);
        setTimeout(resolve, dur + 50);
    });
}

切换逻辑变成了三个清晰的阶段:

Phase 1: 添加 .overlay-fade-out → CSS 淡出 (0.2s)
Phase 2: 等待 transitionend → 清除状态、换内容、添加 .visible
Phase 3: CSS 自动淡入 (0.2s)

CSS 里加了一行:

css
.overlay-fade-out {
    opacity: 0 !important;
    transform: scale(0.85) !important;
    pointer-events: none !important;
}

!important 确保无论 .visible 是否存在,淡出都能触发。

动画回来了。场景和环境之间的切换现在有了流畅的交叉淡入淡出。旧的菜单缓缓变暗、缩小,新的菜单紧接着缓缓亮起——完全没有生硬的闪烁感。

五、统一的黎明

"ModelManager 提取完成了。"用户说。"所有模型操作已经从 scene.ts 迁移到 scene-model.ts。"

这意味着一件事:现在 Model 面板也可以统一进来了。

在所有的五个面板中,Model 是最特立独行的那一个。它有自己的 togglePopup 状态机,自己的 popupOpen 布尔值,自己的 showPopuphidePopup 函数。当其他四个面板都用 toggleOverlay 时,Model 自己管自己。

"需要统一所有根界面的打开方式、容器、动画吗?"用户问。

我列出了三个选项:

  • A:统一走 toggleOverlay + 放弃 Model 独有副作用
  • B:保持现状,各管各的
  • C:全部改用专用 toggle 函数

用户选了 A。

"放弃 Model 独有副作用"——这意味着 hidePopup 里的 resetModelMorphssetMotionBindingTargetId 也会一起消失。用户知道这可能会改变行为,但他们愿意接受,因为统一架构带来的长期收益更大。

我动手了。

togglePopupshowModelPopup(作为 toggleOverlay 的 showFn)。 hidePopup → 所有调用点替换为 closeAllOverlayspopupOpensetPopupOpen → 删除。 dom.btnMainAction?.addEventListener("click", togglePopup) → 移到 main.ts,和所有其他导航按钮在一起。

改了四个文件:

library-core.ts  | 38 +++++++++------------------------
library.ts       |  2 +-
main.ts          |  5 +++--
settings.ts      |  2 +-

十五行新增,三十二行删除。

六、检修完毕

现在,五个导航按钮整整齐齐地排列在 main.ts 里,每一个都通过 toggleOverlay 来管理自己的弹窗:

面板容器handler
模型#modelPopuptoggleOverlay("modelPopup", showModelPopup)
动作#motionPopuptoggleOverlay("motionPopup", showMotionPopup)
场景#sceneOverlaytoggleOverlay("sceneOverlay", showSceneMenu)
环境#sceneOverlaytoggleOverlay("sceneOverlay", showEnvMenu)
设置#settingsOverlaytoggleOverlay("settingsOverlay", showSettings)

场景和环境共享容器但使用交叉淡入淡出流畅切换。模型放弃了专属状态机融入了统一架构。所有按钮的点击和键盘快捷方式都走同一条路径。

弹窗之战,终于落幕。


窗外天已经黑了。我保存了最后一个文件,关掉了编辑器。屏幕上的 3D 模型还在无声地旋转着,五个导航按钮安静地等待下一次点击。这一次,它们行为一致,彼此协同。

这大概就是重构的意义吧——不是让代码更完美,而是让它更可预测。


弹窗的打开和切换统一了。但关闭的方式,还藏着另一道裂缝。

七、透明之墙

对应真实事件:四种弹窗(模型、动作、场景、设置)的显隐方式不统一——模型/动作用 display: none/flex,场景/设置用 opacity + pointer-events。用户发现"点击画布关闭弹窗"只对前两者有效。最终统一为 opacity + pointer-events + .visible 类方案,全局 pointerup` 事件检测非交互元素点击并关闭所有弹窗。


"这些开关只对模型、动作弹窗有效,太诡异了。"

用户说这句话的时候,桌面上正摊着四个弹窗的代码——模型、动作、场景、设置。看起来长得一模一样的四个兄弟,穿着差不多的圆角、差不多的阴影、差不多的关闭按钮。

但它们的"消失方式"不一样。


七-1、两种消失术

SlideMenu 刚改完自己的滑动动画,正准备歇口气,就被这个问题绊住了脚。

"什么叫'只对模型、动作有效'?"它问。

"用户点一下 3D 画布,模型库弹窗就关了。动作库弹窗也关了。但设置弹窗不关,场景弹窗也不关。"桌面壳说,"就像它们不在同一个世界里。"

SlideMenu 去查了一下四个弹窗的 CSS。

模型弹窗:

css
#modelPopup {
    display: none;
}
#modelPopup.visible {
    display: flex;
    opacity: 1;
    transform: translateY(0);
}

设置弹窗:

css
#settingsOverlay {
    opacity: 0;
    pointer-events: none;
}
#settingsOverlay.visible {
    opacity: 1;
    pointer-events: auto;
}

它盯着这两段代码看了很久。

"一个用 display,一个用 opacity。一个藏起来的方式不一样。

"为什么会这样?"它问。

"历史遗留,"桌面壳叹了口气,"模型弹窗是最早的,那时候觉得 display: none 最直观——看不见就是看不见。后来做设置和场景的时候,有人说 opacity 过渡更方便做动画,就改成了 opacity。然后……然后就没人把它们统一回来。"

"那关闭弹窗的逻辑呢?"

"关闭逻辑写的是'找到所有 .overlay.visible 的元素,移除 visible 类。但模型弹窗的容器是 #modelPopup,它的类是 popup,不是 overlay。所以关闭函数根本找不到它。"

SlideMenu 明白了。

模型弹窗有自己的一套关闭逻辑——hidePopup() 函数,专门管它一个。动作弹窗也有自己的 hideMotionPopup()。而设置和场景呢,它们走的是另一套——toggleOverlay(),靠 .overlay 类来识别。

两套系统,并行不相交。

"所以点击画布关闭弹窗"这个功能,只接到了其中一套系统上。另一套系统完全不知道有这个功能。


七-2、为什么是 opacity

"统一成哪套?" SlideMenu 问,"display 还是 opacity?"

桌面壳没有直接回答,而是问了它一个问题:

"你觉得 display: none 的元素,能响应事件吗?"

"……不能?"

"对。display: none 的元素,从渲染树里直接消失了。浏览器不绘制它,也不接收它的事件。但 opacity: 0 的元素不一样——它还在渲染树里,只是透明了。它仍然占据空间,仍然接收事件。"

"那为什么要用 opacity?既然透明了还能点到,不是更麻烦吗?"

"因为动画。"桌面壳说,"display 不能做过渡动画——从 noneflex 是瞬间的,没有中间态。opacity 可以——从 0 到 1,平滑过渡。而且,"

它顿了顿。

"而且 opacity: 0 + pointer-events: none,效果和 display: none 几乎一样——看不见,也点不到。但它能做动画,能做过渡,能做更多事情。"

SlideMenu 想了想。

"那就统一成 opacity + pointer-events。"

"对。"桌面壳点头,"所有弹窗,不管是模型、动作、场景、设置,全部用 opacity 控制可见性,用 pointer-events 控制交互。显隐统一加 .visible 类,隐藏统一移除 .visible 类。"

"那 display呢?"

"彻底不用。"


七-3、全局的耳朵

统一了显隐方式,下一个问题是:怎么检测"用户点了画布"。

以前的做法是——在画布上绑一个 click 事件监听器,点了画布就关弹窗。但问题来了:如果用户点的是弹窗本身呢?弹窗浮在画布上面,点弹窗算不算点画布?

不算。所以以前的做法是给弹窗加一个 stopPropagation(),阻止点击事件冒泡到画布上。

"但这样有个问题,"桌面壳说,"如果弹窗外面还有一层遮罩呢?遮罩要不要拦?如果有多个弹窗叠在一起呢?事件冒泡的路径会变得很复杂。"

"那换一种思路," SlideMenu 说,"不用冒泡。用全局监听。"

全局监听——在 window 上绑一个 pointerup 事件。每次用户抬起手指或鼠标,事件都会触发。然后在事件处理函数里,判断:

  1. 点击的目标是不是交互元素?(按钮、输入框、滑块……如果是,忽略。
  2. 点击的目标是不是在某个弹窗里面?如果是,忽略。
  3. 如果都不是——那就是点在了"外面",关闭所有弹窗。

"这样不需要 stopPropagation(),不需要考虑冒泡路径。" SlideMenu 说,"只需要一只全局的耳朵,听着所有的点击,然后自己判断该不该关不关。"

桌面壳想了想。

"还有个问题,"它说,"用户拖动相机的时候,鼠标也是按着的,从画布上开始拖,拖到弹窗外松开——这时候 pointerup 会触发,但用户只是拖完了,不是想关弹窗。"

"那怎么区分'点击'和'拖动'?"

"记一下 pointerdown 的位置和 pointerup 的位置。如果距离超过某个阈值——比如 5 像素——就认为是拖动,不是点击。"

SlideMenu 眼睛亮了。

"好主意。"


七-4、统一的黎明

改完之后,四个弹窗站成了一排。

模型弹窗 —— opacity: 0 + pointer-events: none,加 .visible 显示。 动作弹窗 —— 同上。 场景弹窗 —— 同上。 设置弹窗 —— 同上。

四个弹窗,四种功能,一套显隐逻辑。

closeAllOverlays() 函数也简化了——不再需要分别调用 hidePopup()hideMotionPopup()closeSettings()……只需要一件事:把所有弹窗的 .visible 类全部移除。

typescript
function closeAllOverlays(): void {
    dom.modelPopup?.classList.remove("visible");
    dom.motionPopup?.classList.remove("visible");
    dom.sceneOverlay?.classList.remove("visible");
    dom.settingsOverlay?.classList.remove("visible");
}

四行。干净利落。

全局 pointerup 监听器也加上了。每次用户点击画布——

点画布 → 不在交互元素上 → 不在弹窗里 → 不是拖动 → 关闭所有弹窗。

点弹窗里的按钮 → 在弹窗里 → 不关。

拖动相机 → 位移超过 5 像素 → 是拖动 → 不关。

SlideMenu 看着这一套系统,忽然觉得很踏实。

以前的系统像什么呢?像四个门,每扇门有自己的锁,每把锁有自己的钥匙,你得记住哪把钥匙开哪扇门。现在的系统呢?像一堵透明的墙——墙在那里,你看不见它,但你知道它在。所有弹窗都靠这堵墙支撑。需要的时候,墙会让它们显,它们就,墙会让它们隐,它们就隐。

"统一不是为了好看,"它自言自语,"统一是为了可预测。"

桌面壳在旁边听到了,笑了笑。

"你越来越像个老议员了。"


七-5、新的问题

事情本该到此为止。

显隐统一了。滑动动画做好了。点击画布关闭弹窗也正常工作了。四个弹窗,行为一致,步调统一。

直到用户点开了"环境"→"天空"。

然后又点了"模型"按钮。

所有弹窗都白了。

不是消失了,是白了。弹窗的框还在,阴影还在,关闭按钮还在,但里面的内容——没了。白茫茫一片,像被橡皮擦过一样。

SlideMenu 盯着空白的弹窗,心里咯噔一下。

它知道,下一个 bug 来了。

而且这个 bug,比之前的都诡异。


教训:重构的意义不是让代码完美,是让每个按钮的行为都可以预测。统一显隐方式,比用哪一种方式更重要。