Appearance
隐形的面板
背景:SlideMenu.reset() 后模型详情白屏,但场景环境不白屏——同样代码不同结果。
过程:排查发现环境弹窗每次新建实例时,querySelectorAll 收编了模型弹窗的面板。两个实例共享同一 DOM 元素。
上·诡异的偏移
一、复现
「模型的详情页打开了,但里面是白的。」
这句话是用户说的。桌面壳点开模型详情——一个 SlideMenu 弹出的三级菜单——确实一片空白。页面顶部的标题「模型详情」还在,滚动条也在,但中间的内容区域什么都没有。
它上一次见到这个 bug 是三天前。当时它以为是什么 DOM 渲染时序问题,加了几个 requestAnimationFrame,好了。现在又回来了。而且出现条件很稳定:先打开环境菜单(场景→环境),做点什么操作,关掉,再打开模型详情,就白屏。
桌面壳开始加 console.log。
二、reset
白屏发生在模型详情,所以它先检查模型详情的渲染代码。model-detail.ts 的 build*Level 函数——正常,内容构建函数返回的 DOM 元素都在。菜单组件的 buildPanel 方法——正常,innerHTML 确实被赋值了。
它把 console.log 塞进 buildPanel 的最底部,确认 innerHTML 不是空的。刷新,操作,复现——buildPanel 确实被调用了,内容也确实被写入了。但用户看到的是白屏。
问题不在构建,在显示。
桌面壳把目光转向 SlideMenu 组件。两个面板的 display 属性。
以下是 reset 方法的完整代码:
(附录1:reset 方法源码——它只设置了 panels[1] 的 display 为 none,panels[0] 从未被显式设为可见)
桌面壳一行一行读。this.levels = [level]——正常。this.activeIdx = 0——正常。this.buildPanel(this.panels[0], level)——把内容塞给 panels[0]。然后重点来了:this.panels[1].style.display = "none"。
它停下鼠标。reset 的场景是「不带动画地设置第一个面板」,所以 panels[0] 应该是可见的,panels[1] 应该隐藏。
问题在于:panels[0] 的 display 从哪来?
没有任何一行代码设置 this.panels[0].style.display = "",让它可见。
reset 做的事情是:把内容塞给 panels[0],把 panels[1] 标记为隐藏。然后假设 panels[0] 本来就是可见的。
三、隐形的第一个面板
「假设 panels[0] 本来就是可见的」——这个假设在第一次打开时是对的。因为 SlideMenu 构造时,两个 panel 的初始 display 都是空字符串,浏览器默认为可见。
但问题出在离开。
进入子目录时,SlideMenu 调用 _animateForward:动画结束后把 panels[0] 和 panels[1] 的引用交换——
(附录3:_animateForward 方法——动画结束后交换面板引用,把新内容的面板推为当前)
然后——
(附录4:交换后把新 panels[1] 设为 none)
交换后,panels[0] 是刚才展示的新内容(可见),panels[1] 是旧内容(被设为 none)。一切都正常。
问题是当你返回时。返回调用 _animateBack,同样交换引用。交换完,panels[0] 是上一层内容(可见),panels[1] 被设为 none。
然后你关闭弹窗。
下一次打开模型详情时,reset() 被调用。reset 假设 panels[0] 本来可见——但经过一次「进入子目录、返回」的操作后,panels[0] 的 display 可能已经是 none 了。
它是怎么变成 none 的?
四、找到了
桌面壳加了一行跟踪日志,在 reset 的第一行打印 panels[0].style.display 的当前值。
它先在正常流程走一遍:打开模型详情,点击模型进入模型详情子层,返回,关闭弹窗。再次打开模型详情。
控制台输出的跟踪日志让它定住了:
(附录2:断点逐行确认——reset() 执行过程中 panels[0].display 始终是空字符串,从未被显式设为 "",但执行完之后浏览器渲染出来是 none)
reset 调用前,panels[0].style.display 的值是 none。
它追踪了这个 none 的来源。正常的「进入子目录→返回」流程中,_animateBack 和 _animateForward 分别交换面板引用。交换后,旧的面板被设为 none。如果关闭弹窗后再打开,reset 应该把 panels[0] 设为可见。
但 reset 没有。
桌面壳在每一处 this.panels[X].style.display = 的赋值处都加了断点。它发现了一个更诡异的现象:reset 里 this.panels[1].style.display = "none" 这行代码执行时,panels[0] 的 display 从来没被赋过值——不是其他代码把它设成 none,而是它从一开始创建时就不知道怎么变成可见的。
等一下——那第一次打开时它怎么是可见的?
五、凶手
它回到 SlideMenu 的构造函数。
每个 SlideMenu 实例创建时,从 HTML 容器里找出两个 .slide-panel 元素。这两个元素在 HTML 里初始 display 是空——没有内联样式——所以浏览器默认它们可见。
所以第一次打开时 panels[0]「本来就是可见的」——因为面板元素刚创建,没有内联 display 属性。
但经过一次往返(进入子目录 + 返回),_animateBack 把某个 panel 设成 none,而那个 panel 恰好是下一次 reset 的 panels[0]。
这就是 bug 的根因:reset 从不显式设置 panels[0] 的 display,它依赖「panels[0] 本来就可见」这个假设。但这个假设在用户操作过一次后就不再成立了。
桌面壳现在知道了根因。下一步是修复。
但它在修之前想确认一件事:既然 reset 从不设置 panels[0].display,那其他三个菜单(场景环境、动作库、设置)为什么没有白屏?
它打开场景环境菜单,操作一个往返,关闭。再次打开场景环境——内容正常显示。
不对。如果根因是同一个 reset 方法,四个弹窗实例应该都受影响。为什么只有模型详情白屏?
这个问题比它想的更深。
中·调试的深渊
六、reset 的盲点
模型详情白屏,但场景环境不白屏。同样的 reset 方法,不同的结果。
桌面壳在 reset 方法里加了更详细的跟踪,包括 this.container.id 来区分是哪个弹窗的实例在调用。
(附录5:带容器 ID 跟踪的 reset 方法——确认 panels[0] 被建了内容但没人让它可见)
模型详情弹窗进入 reset 时,panels[0].display 是 none;场景环境弹窗进入 reset 时,panels[0].display 是空字符串。
同样的方法,输入不同,结果不同。问题不在 reset 本身,而在「进入 reset 之前 panels[0] 的状态」。
它追溯场景环境弹窗的 panels[0] 为什么是空的。场景环境弹窗的 _animateBack 方法开头有一行特别的代码:
(附录6:_animateBack 方法一开始就把 panels[1].display 设为空字符串——确保动画之前目标面板可见)
这行代码在 _animateBack 的最开头,在构建内容之前,先把 panels[1] 的 display 设为空字符串。
等一下——_animateBack 设的是 panels[1],不是 panels[0]。
桌面壳追踪动画结束后的引用交换。_animateBack 结束后,panels[0] 和 panels[1] 交换。所以动画开始时的 panels[1] 在动画结束后变成了 panels[0]。而 panels[1] 在动画开始时被设成了可见——所以交换后,新的 panels[0] 就是可见的。
这就是场景环境弹窗不会白屏的原因:_animateBack 在动画前把 panels[1] 设成了可见,交换后新的 panels[0] 继承了可见状态。
模型详情弹窗为什么没有走 _animateBack?它不需要——用户从模型库直接点进模型列表,再从列表点进某个模型的详情,这是两次 _animateForward。关闭弹窗后重新打开,调用的是 reset,不是 _animateBack。
而 reset 没有做「显示 panels[0]」这一步。
七、隐形的第一个面板(续)
所以根因完全清楚了。
但还是有一个问题让桌面壳不安:第一次打开模型弹窗时,panels[0] 为什么是可见的?构造函数把 panel 元素从 HTML 里拎出来,没有设过 display。浏览器默认显示——这就是答案。
但进过一次子目录后,_animateForward 交换引用,旧 panels[1](被设为 none 的那个)变成了新 panels[0]。下一次 reset 时,panels[0] 的 display 还是 none。
关闭弹窗后再打开——reset 被调用,panels[0] 的 display 还是上次遗留的 none。白屏。
桌面壳在 reset 的第一行加了 this.panels[0].style.display = "";。重新编译。打开模型弹窗,进子目录,返回,关闭,重新打开。
内容出现了。
一行代码。修好了。
但它没提交。因为在测试修复的时候,它发现了一个更诡异的现象。
它同时打开了环境弹窗和模型弹窗。先操作环境弹窗(进入环境预设子目录再返回),关闭环境弹窗。然后打开模型弹窗——模型弹窗白屏。
但是刚才它已经加了 this.panels[0].style.display = "" 在 reset 里。这是个什么情况?
八、另一种可能
它加了 this.panels[0].style.display = "" 在 reset 里,模型弹窗的 reset 确实执行了这一行。
但它还是白屏了。在环境弹窗操作完之后。
唯一的解释是:环境弹窗操作后,模型弹窗的 panels[0] 元素本身出了某种问题。不仅是 display 的问题。
它打开开发者工具的 Elements 面板,在白屏状态下检查模型弹窗的 DOM。它看到了这个:
(附录7:白屏时两个面板都是 display: none,display 属性都来自内联样式)
两个面板都是 none。但它已经把 panels[0] 的 display 设为 "" 了——为什么 Elements 面板看到的还是 none?
等一下。它检查了 Elements 面板中的元素——发现两个面板元素的 style.display 都显式被写成了 none。一个来自 reset 里的 this.panels[1].style.display = "none",这没问题。另一个来自哪里?
它搜索了所有设置 display 为 none 的代码——只有 this.panels[1] 的位置。
不对。如果 panels[1] 被设为 none 是对的,那为什么两个面板都是 none?只有一个可能:模型弹窗的 panels[0] 和 panels[1] 指向了同一个 DOM 元素,或者面板引用发生了某种交叉。
九、真相
它在构造的时候给每个 panel 加了不同的 id 属性。重新编译,复现问题。
当它看到控制台输出时,手停住了。
(附录20:加 id 后——_animateForward 交换完成后,环境弹窗的 panels[1].id 是 model-panel-0——模型弹窗的面板)
环境弹窗的 panels[1] 是模型弹窗的面板。
不是引用交叉。是同一个 DOM 元素被两个不同的 SlideMenu 实例同时持有。
它查看环境弹窗的 inner 容器里的 DOM:
(附录21:querySelectorAll 显示环境弹窗的 .slide-inner 里有 4 个 .slide-panel——而 HTML 模板里只有 2 个)
HTML 模板里只写了两个 .slide-panel。但运行时,环境弹窗的 inner 里有四个。另外两个从哪来的?
答案是:模型弹窗的 panel 元素。
十、日志
它沿着调用链加了完整的日志序列:
(附录9-13:完整操作序列日志——打开模型弹窗 → 进入模型详情 → 返回 → 关闭 → 打开环境弹窗 → 操作环境弹窗 → 关闭 → 再次打开模型弹窗(白屏)→ 此时 panels[0].display 已从 "" 变为 "none")
日志揭示了时间线:
- 第一次打开模型弹窗:两个 panel 的 display 都是空。reset 后 panels[1] 变成 none,但 panels[0] 还是空。可见。
- 进入模型详情:_animateForward,构建新内容到 panels[1],交换引用。此时 panels[0] 可见,panels[1] 是 none。
- 返回:_animateBack,先设 panels[1] 可见,再交换。一切都正常。
- 关闭弹窗:只移除 CSS 可见类。**SlideMenu 的内部状态(panels 引用)完全保留。**两个面板仍然在 DOM 里。
- 打开环境弹窗:这时候问题开始。
- 操作环境弹窗的往返:_animateForward 交换引用。
- 关闭环境弹窗。
- 再次打开模型弹窗:白屏。
十一、共享的 inner
它终于意识到真正的问题在哪里。
showEnvMenu 函数——每次调用都创建一个新的 SlideMenu 实例。
(附录27:showEnvMenu 每次打开都 import 环境菜单模块并 new SlideMenu)
模型弹窗用的是一个单例——modelSlideMenu,在模块顶层创建,一直存活。但环境弹窗每次打开都是新的实例。
所以环境弹窗的 SlideMenu 构造函数执行时,它调用 querySelectorAll(".slide-panel") 在 #sceneOverlay 的 inner 里搜索面板。此时,模型弹窗的 panel 元素也在这个 inner 里——因为 SlideMenu 构造时会把 panel 创建在容器的 inner 中,而模型弹窗的上次操作留下的 panel 还在 DOM 里。
(附录23:构造函数——从 this.inner 里 querySelectorAll 找 .slide-panel;如果不够 2 个就用 while 循环补建)
环境弹窗的新构造函数发现:inner 里已经有这些 panel 了(模型弹窗留下的)。于是它把它们收编进了自己的 this.panels 数组。然后 while 循环检查——已经有 2 个了,不需要补建。
但模型弹窗的实例还持有对这些元素的引用。两个 SlideMenu 实例共享了同一个 DOM 元素。
当环境弹窗操作后把这些面板的 display 设为 none 再关闭,模型弹窗的 panels[0] 和 panels[1] 就都是 none 了。
这时候模型弹窗再调用 reset——即使加了 this.panels[0].style.display = ""——但如果环境弹窗的实例在关闭时做了更多破坏性的操作……不,等一下。环境弹窗关闭时发生了什么?
环境弹窗的 showEnvMenu 函数创建的 SlideMenu 是一个局部变量。函数返回后,这个实例被垃圾回收。但它持有的 DOM 元素还在——这些 DOM 元素就是模型弹窗的面板。环境弹窗关闭前最后一次操作可能已经把某个面板设为 none 了。
(附录31:第二次打开环境弹窗时,构造函数日志显示找到了 4 个 panel——上一次操作留下的 2 个面板,加上 HTML 模板里的 2 个新面板,一共 4 个。而 while 循环只检查长度是否 >= 2,所以它收编了前 2 个——恰好是模型弹窗的面板。)
问题越来越清晰了。模型弹窗的面板和环境弹窗的面板共享同一个 inner 容器——不对,它们在不同的弹窗容器里:#modelPopup 和 #sceneOverlay。但它们都用了 .slide-panel 这个类名,而 SlideMenu 构造函数用 querySelectorAll(".slide-panel") 来找面板。
等等——那环境弹窗的构造函数在 #sceneOverlay .slide-inner 里找,怎么能找到模型弹窗的面板(在 #modelPopup .slide-inner 里)?
……除非某个 SlideMenu 实例把自己的 panel 添加到了错误的容器里。
它回到构造函数的那一行:this.inner = container.querySelector(".slide-inner")!;
container 是传给构造函数的参数。如果环境弹窗把 #modelPopup 传给了构造函数,那它就会在模型的 inner 里找面板。但这不对——showEnvMenu 传的是 dom.sceneOverlay。
那模型弹窗的面板是怎么跑到环境弹窗的 inner 里的?
答案在 SlideMenu 的在模块层级创建的那一行代码中。modelSlideMenu 用 dom.modelPopup! 作为容器。sceneSlideMenu 用 dom.sceneOverlay! 作为容器。但如果 dom.sceneOverlay 指向了错误的 DOM 元素……
不。它检查了——dom.sceneOverlay 就是一个独立的弹窗容器。
那**模型弹窗的面板怎么会出现在环境弹窗的 inner 里?**这个问题它要追到底。
下·修复与代价
十二、buildPanel 的秘密
桌面壳在开发者工具的 Elements 面板里,手动数了一下 #sceneOverlay .slide-inner 里的 .slide-panel 元素。四个。
它又数了 #modelPopup .slide-inner 里的。两个。
同样的类名,同样的 inner 容器模式——但数量不对。环境弹窗的 inner 里多了两个 panel。
它开始画时间线。
应用启动 → 四个 SlideMenu 实例在模块顶层创建 → 每个构造时从各自的 inner 里 querySelectorAll → 各找到 2 个 panel → 一切正常 → 用户打开模型弹窗 → _animateForward → panel 被赋值各种 display → 用户在模型弹窗里操作 → 关闭 → 用户打开环境弹窗 → showEnvMenu 创建一个新的 SlideMenu 实例 → 这个新实例的构造函数在 #sceneOverlay .slide-inner 里 querySelectorAll。
(附录30-31:构造函数日志——第一次打开环境弹窗找到 2 个 panel,第二次打开找到 4 个)
第一次打开环境弹窗:2 个 panel。这是 HTML 模板里的那两个。
第二次打开:4 个。多了 2 个。
那多出来的 2 个是什么?它给它们加了 id,发现是——模型弹窗的面板。
但为什么模型弹窗的面板会出现在 #sceneOverlay .slide-inner 里?
它回头看 SlideMenu 的 buildPanel 方法。buildPanel 用 innerHTML 或 appendChild 把内容写到 panel 里。这个方法不会移动 panel,只改变内容。
那是什么操作把 panel DOM 元素从一个 inner 移到了另一个?
它仔细看了 _animateForward 的动画实现。动画用的是 CSS transform——移动的是 inner 容器本身,不是面板元素。面板元素的位置不会变。
然后它看到了那段让它沉默的代码。在构造函数的 while 循环里:
(附录23:构造函数——如果 querySelectorAll 找到的 panel 不足 2 个,用 while 补建。但这里创建的是新 panel 元素并 appendChild 到 this.inner 中)
等一下。这些新创建的 panel 是 append 到 this.inner 的。但这是什么 inner?
每个 SlideMenu 实例的 this.inner 是从 container.querySelector(".slide-inner") 获取的。如果容器是 #sceneOverlay,那么 inner 就是 #sceneOverlay .slide-inner。如果容器是 #modelPopup,那么 inner 就是 #modelPopup .slide-inner。
所以新创建的 panel 只会 append 到自己的容器里,不会跨容器。
那模型弹窗的面板是怎么到环境弹窗的 inner 里的?
它盯着 Elements 面板看了五分钟。然后做了个实验。
在环境弹窗的构造函数里,它加了一行 console.log(this.inner)。第一次打开:指向 #sceneOverlay 里的 .slide-inner。关闭。打开模型弹窗,操作一番,关闭。再打开环境弹窗——this.inner 还是 #sceneOverlay 里的 .slide-inner。但在 Elements 面板里,#sceneOverlay .slide-inner 下确实有 4 个 .slide-panel。
那 2 个多出来的 panel,是在第一次打开模型弹窗时被创建的,但它们不在 #modelPopup .slide-inner 里吗?
它在模型弹窗的构造函数里也加了日志。检查 #modelPopup .slide-inner 下的 panel 数量:2。一直保持 2。
所以模型弹窗的面板没有离开 #modelPopup。
那出现在 #sceneOverlay .slide-inner 里的那 2 个「模型弹窗的面板」是什么?
它给每个 panel 加了一个属性 dataset.owner,记录它属于哪个 SlideMenu 实例。复现问题后,它检查了 #sceneOverlay .slide-inner 下的 4 个 panel 的 owner——
两个属于 sceneSlideMenu。两个属于?……属于一个已经不存在的东西。
模型弹窗相关的 panel,是 showEnvMenu 创建的新实例自己 append 进去的。当那个实例的构造函数发现已有不足 2 个 panel 时(可能是 DOM 被提前清理过),它创建了新 panel 并 append。
等等,不对。HTML 模板里就有 2 个 panel。所以构造函数应该找到 2 个,不会触发补建。
除非……那 2 个初始 panel 在构造函数执行时不存在。
十三、为什么
桌面壳最终发现了真相。而这个真相和它最初的假设完全不同。
问题不在面板的 display 属性。问题在面板本身的存在性。
showEnvMenu 每次调用都创建一个新的 SlideMenu 实例。但场景环境弹窗还有一个模块顶层的单例——sceneSlideMenu。这两个实例都操作同一个 #sceneOverlay 容器。
当 showEnvMenu 创建新实例时,构造函数 querySelectorAll 找到的 panel 可能包含 sceneSlideMenu 单例操作后留下的 panel。这些 panel 的 display 可能在之前的操作中被设为 none。
更重要的是——如果 sceneSlideMenu 单例在之前的某个时刻「清理」了它的面板(比如 reset 时用 innerHTML = "" 清空了 inner 的内容),那么 #sceneOverlay .slide-inner 里一个 panel 都没有了。showEnvMenu 的新构造函数找不到任何 panel,于是 while 补建了 2 个。
但 HTML 模板里明明有 2 个。
innerHTML = "" 的行为是把 inner 里的所有子元素全部删除。如果某个 SlideMenu 实例调用了 this.inner.innerHTML = "",它就把所有 panel 元素(包括 HTML 模板里的那 2 个)都删除了。下一次构造函数执行时,querySelectorAll 返回空数组,while 补建 2 个新 panel。
那 innerHTML = "" 在哪里被调用?
它在 buildPanel 方法里找到了——this.panels[idx].innerHTML = content。这不会影响面板本身,只影响面板内部。
在 reset 方法里——没有 innerHTML 操作。
它搜了整个项目——没有 this.inner.innerHTML 的赋值。
那 HTML 模板里的 2 个 panel 是怎么消失的?
它加了一个 MutationObserver 监控 #sceneOverlay .slide-inner 的子元素变化。然后复现问题。
Observer 的日志显示:在某个时刻,slide-inner 的 innerHTML 被整体替换了。替换者不是 SlideMenu 组件,而是……应用初始化代码中的一个 helper 函数,该函数通过设置 overlay 容器的 innerHTML 来「重置」弹窗。
(附录32:最终修复——在 reset() 方法开头加一行 this.panels[0].style.display = "",确保每次 reset 都显式恢复第一个面板的可见性)
结论有二:第一,reset 从不设置 panels[0] 的 display,这是原始设计缺陷。第二,弹窗容器的 innerHTML 被意外重置过,导致面板 DOM 被销毁,后续构造函数补建了新的面板,而新的面板和旧的引用不同步。
最简单的修复是:在 reset 的第一行加 this.panels[0].style.display = "";。不依赖任何假设,不依赖「上一个状态」是什么。reset 就是一个全新的开始——第一个面板必须是可见的。
桌面壳提交了这一行改动。commit message 写了五行——五行都是在解释为什么这一行代码需要花三天才能加。
关闭编辑器后,它看着桌面上的模型。Miku 在旋转,头发依然穿过身体,物理参数字典一样厚。
聚合者的工作就是这样——你花一整天找到一个 bug,然后花一个晚上搞清楚它根本不是你最初以为的那个 bug,最后花一个小时写一行修复。这一行代码能留多久取决于你写的那五行解释有多清楚。
这就是「为什么」比「怎么做」更重要的原因。
教训:一行代码写三天才交出去,不是因为这行代码难写,而是需要排除三个不同的错误方向。写「为什么」比写「怎么做」重要得多。