Skip to content

空白的弹窗

背景:弹窗 buildPanel 正常调用、innerHTML 有内容,但屏幕上空白。

过程:排查发现 viewport.style.height = 0px——absolute 面板的 scrollHeight 返回 0,改用 offsetHeight 强制回流 + 三道防线修复。


窗外已经亮了两次又暗了两次。Riku 盯着屏幕上一个完全空白的弹窗,光标在 menu.ts 的 370 行代码上来回扫。

它在。class 在。buildPanel 调用了。innerHTML 里确定有内容。

但屏幕上什么都没有。

他揉了揉眼睛,以为是视网膜的错觉。开发者工具开了又关,关了又开。DOM 结构完整得像一座精心搭建的纸牌屋——每一个 <div> 都在它该在的位置,每一段文本都在它该在的标签里。他用指针选中面板元素,hover 上去,高亮线框准确地落在那个矩形区域上。

但那个区域是透明的。像一张没有被墨水浸湿的纸,字全印在纸的背面,谁也看不见。

「它在,但它不在。」

Riku 喃喃自语。屏幕上没有回应,只有光标在 370 行的 reset() 函数里沉默地闪烁。

Jieling 的反馈只有一行:

菜单一片空白。什么都没显示。

这次没有 aria-expanded 正常切换的诡计,没有幽灵引用的陷阱,没有 overlay-fade-out 的残留。只有空白。纯粹的、空洞的、彻头彻尾的空白。


追查:躺在地上的量尺

Riku 打开开发者工具,深吸一口气,开始一层层剥开这个谜题。

首先是 DOM 结构。他顺着 viewportinnerpanel 的父子链往上查。每一层都完整,每一层都有内容。面板的 innerHTML 里躺着他亲手写的 47 行 HTML——搜索框、图标、分组标题、菜单项——全部都在。

然后是 CSS computed styles。

position: absolute;
top: 0;
left: 0;
width: 100%;
display: block;
visibility: visible;
opacity: 1;

全部正常。没有 display: none,没有 visibility: hidden,没有 opacity: 0。面板明明穿好了所有衣服,站在舞台中央,灯光打下来——但就是看不见。

Riku 继续往下翻。elements panel 的 computed styles 很长,他一直滚到最底下,那里藏着一个他之前没注意到的数字:

viewport.style.height = "0px"

他的手指停住了。

0px。

不是 100px,不是 214px,不是任何一个正常的数字。是 0。整个视口的高度是零。所有内容——不管面板有多高——全部被这个零高度给吞掉了。就像一条毛巾被拧紧了最后一滴水,什么都挤不出来。

他往上翻到设置视口高度的代码:

typescript
// reset() 中
this.viewport.style.height = p0.scrollHeight + "px";

scrollHeight。0。

Riku 盯着这行代码,眉头拧成了死结。面板刚刚被 buildPanel 填满内容,buildPanel 用的是 panel.innerHTML = "" 然后 panel.appendChild(list)。DOM 已挂载到父链上,文本内容清晰可见——按理说,此时 scrollHeight 应该返回内容的真实高度。

但不。

scrollHeight 返回了 0。

他想起很久以前学过的一个比喻:绝对定位的元素是一把躺在地上的量尺。正常文档流里的元素是站立的量尺,刻度朝上,谁路过都能读。但 absolute 面板是躺着的,刻度朝向地面,除非有人蹲下去,否则根本看不见上面的数字。

scrollHeight,就是那个躺在地上读数的人——但它读的是流内元素的高度。躺着的量尺不在流内,所以它读不到。

这个顿悟来得太晚了。Riku 在 p0.scrollHeight 的前一行加了一行日志:

typescript
console.log("p0.scrollHeight", p0.scrollHeight);
// → 0
console.log("p0.offsetHeight", p0.offsetHeight);
// → 214

两行数字,一个 0,一个 214。中间差了一整个菜单的高度。差了一次布局。差了一个「蹲下去读数」的动作。

他盯着 214 这个数字,想起刚才在开发者工具里看到的高亮线框——那个透明的、看不见任何东西的矩形区域,原来一直都存在,只是他用的量尺不对,所以读不出它的高度。


真実:offsetHeight 的强制回流

scrollHeight 的实际含义是"可滚动内容的高度"。它依赖于浏览器的布局引擎已经完成流式计算。

position: absolute 的面板脱离了文档流。脱离意味着浏览器不会在正常的布局周期里为它计算 scrollHeight——至少不是在你刚填完内容的那一帧里。它需要一次完整的 layout pass,而 JS 是同步执行的。你在同一个事件循环里修改 DOM 然后立即读取,浏览器还没来得及算。

这不是 bug。这是契约。

scrollHeight 承诺的是「流的测量」。absolute 面板不参与流,所以流不承诺测量它。

Riku 想起那句在联邦里流传很广的话:

脱离流者,不享流之度量。

他以前觉得这是某种哲学。现在他发现这是技术规格说明。

offsetHeight 不同。它在读之前强制回流(force reflow),确保拿到的是已布局的真实高度。代价是性能——但这笔交易对低频的弹窗打开来说,连"成本"都算不上。

他把这个发现浓缩成了一行注释:

typescript
// 🔥 关键:强制回流 + 取实际渲染高度
void p0.offsetHeight;
const h = Math.max(p0.offsetHeight, 100);

void p0.offsetHeight ——这不是赋值,这是诅咒。它告诉浏览器:"停下你的一切,先把这个元素的布局算清楚"。然后 offsetHeight 才能给出真实值。


修复:三道防线

问题的根因清楚了,但 Riku 知道,只换一个 API 不够。scrollHeightoffsetHeight 是治标,真正的问题是:absolute 面板在布局线程面前是隐形的,你需要制度来确保它被看见

他设了三道防线。

防线一:display: block 内联强制

Riku 想起一个可能的噩梦场景:某位未来的维护者在 global CSS 里加了一条 .slide-menu { display: flex !important; },然后整个弹窗系统神秘地消失了。display 属性在 CSS 优先级金字塔里有很多层,全局样式、class、inline style——如果没有一道硬性规定,谁知道最后一层会是谁?

他在 initPanelStyle 中写死:

typescript
p.style.display = "block"; // 内联 style,比任何 class 都强

不是 className,不是 setAttribute,是内联 style.display。它在 CSS 优先级金字塔的最顶端,任何全局样式都不能覆盖。面板永远以 block 身份参与布局计算,即使它此刻还看不见。

为什么这道防线是必要的? 因为 CSS 的优先级战争没有尽头。你今天加了 .slide-menu { display: block },明天就有人加 .menu-container .panel { display: flex },后天又有人加了 !important。内联 style 是一张王牌,永远不要在王牌上下注。

这道防线挡住了什么? 挡住了样式层叠顺序的幽灵。挡住了 CSS 重构时不知从哪里冒出来的覆盖。挡住了弹窗在某些特定页面上神秘消失的噩梦。

这道防线会不会有漏洞? 会的。如果未来有人直接在 JS 里写 p.style.display = "none",这条防线拦不住。所以这条防线的职责不是"永远 visible",而是"永远以 block 的身份参与布局计算"——即使被设为 visibility: hidden,它依然占位、依然可测量。


防线二:offsetHeight 替代 scrollHeight

所有读取面板高度的地方——resetpushpoppopToreRender——全部改为 offsetHeight

Riku 逐个函数检查:

typescript
// reset
const h = Math.max(p0.offsetHeight, 100);

// push
const hFrom = fromPanel.offsetHeight;
const hTo = toPanel.offsetHeight;

// pop
const hFrom = fromPanel.offsetHeight;
const hTo = toPanel.offsetHeight;

// popTo
const hFrom = fromPanel.offsetHeight;
const hTo = toPanel.offsetHeight;

// reRender
const h = Math.max(p0.offsetHeight, 100);

五个函数,九处替换。每一处替换,都是对 layout 线程的一次低头——「对不起,我又来读数了,麻烦你蹲一下」。

为什么这道防线是必要的? 因为 scrollHeight 读到的是「懒汉值」——浏览器在它方便的时候才会算。offsetHeight 读到的是「现在就给我」——它会强制浏览器停下来说实话。弹窗的打开和关闭是用户能感知的关键路径,在这条路径上,懒汉不值得信任。

这道防线挡住了什么? 挡住了「空白弹窗」的最直接原因——viewport 高度为 0。挡住了在某些极端情况下(DOM 修改后立即读取、浏览器渲染管道被阻塞)读到过期值的风险。

这道防线会不会有漏洞? 会的。如果 offsetHeight 返回的值本身是错的(比如元素有 transform: scale(0)),那兜底也无用。但对于普通的 absolute 面板,这个风险接近零。


防线三:Math.max(..., 100) 高度兜底

不管 offsetHeight 返回什么,viewport 的高度永远不小于 100px。

这是最后一道安全网。

Riku 想象一个极端场景:面板内容正在加载,buildPanel 还没有完全执行完毕,但用户的眼睛已经盯在屏幕上了。如果 offsetHeight 在这个时间窗口里返回了 0——因为内容还没到、布局还没完成——那 0 会被写入 viewport.style.height,然后整个面板就消失了。

Math.max(..., 100) 是对这个场景的认输。

typescript
const h = Math.max(p0.offsetHeight, 100);
this.viewport.style.height = h + "px";

100px。这是一个弹窗在最小状态下也应该具有的高度。即使内容完全为空,这 100px 也能保证:弹窗可见、边框完整、关闭按钮在可点击范围内。

为什么这道防线是必要的? 因为没有绝对可靠的 API。offsetHeight 再强,也有被浏览器骗的时候。CSS 动画正在执行吗?GPU 合成层正在切换吗?浏览器的布局树正在更新吗?这些问题没有人能 100% 回答。所以永远留一个兜底值,永远不要说「不可能是 0」。

这道防线挡住了什么? 挡住了「完全空白的弹窗」。挡住了 viewport 被设成 0px 后导致的所有子元素被裁剪的问题。挡住了用户面对一个「看不见所以也关不掉」的弹窗时的恐慌。

这道防线会不会有漏洞? 会的。如果内容的高度本身就小于 100px(比如一个只有图标的工具提示),Math.max 会强行把它撑开,浪费空间。不过对于弹窗场景,100px 的最小值几乎不会触发。


三道防线到位后,menu.ts 里与高度相关的代码从 7 处变成了 13 处——多了 6 处,每一处都是一道检查点。每一道检查点都是一次对布局引擎的确认:「你还在吗?你算对了吗?我可以继续了吗?」


构建与验证

vite build  →  ✅ 通过(127 modules)

Riku 打开应用。点击 Model。弹窗。

弹出。

菜单的每一个条目都完好地站在面板上,viewport 的高度精确匹配内容。从模型弹窗到场景弹窗,从一级菜单到二级子菜单,每一个推入和弹出都带着 0.15 秒的滑动和淡入——这次是真的能看见了。

他退出应用,又打开。切了五个弹窗,每个弹窗进了三层子菜单,反复推入弹出。空白没有回来。

Jieling 的回复只有一个词:

活了。


尾注

Riku 在提交信息里写:

scrollHeight 不承诺在脱离文档流时返回真值。offsetHeight 会在读取前强制回流。加上 display:block 内联兜底和 100px 最小高度,三重防线确保绝对定位面板在任何时候都可被测量、可见。

这个问题在 MDN 的 scrollHeight 文档里只有半句话的暗示:"The Element.scrollHeight read-only property is a measurement of the height of an element's content, including content not visible on the screen due to overflow." 它没说「不包括脱离文档流的元素」,但也没说包括。这种含混是 API 文档最危险的写法——不会让你错,但会让你不知道自己是错的。

联邦的议会(MenuStack)在第六天学会了三件事:

  1. 绝对定位的面板不是"离开",是"不存在于流计算中"。你不能用流的工具测流外之物。
  2. scrollHeight 是懒汉,offsetHeight 是急性子。在关键路径上,急性子更可靠。
  3. 兜底不是冗余,是谦卑Math.max(..., 100) 这六个字符,是对浏览器布局引擎的认输——"我知道你会失误,我替你兜着"。

四轮 SlideMenu 重构,六天时间,三次方案更替(Grid 叠层 → absolute 叠层 → offsetHeight 强制回流),终于让议会的每次展开不再是一场赌博。

议会学会了站立。不是悬浮着等人来量,是站稳了,让量尺自己来。

脱离流者,不享流之度量。

这句话后来被刻在联邦议会的门口。每当有新成员问起这句话的含义,长老们就会指着菜单系统说:

去打开一个弹窗,然后按住 F12 看看它的 viewport 高度。你看到的是什么?

新成员通常会回答:某个具体的数字。

长老摇摇头:

你看到的是那把量尺读出的数。但真正的量尺,是 offsetHeightscrollHeight 只是量尺上的影子。


教训:脱离流者,不享流之度量。