Ruixe Blog

移动端弹窗溢出排查记

记录一次移动端弹窗溢出的完整排查过程:文章里的一张 GFM 表格把布局视口从 390px 撑到 482px,导致固定定位的分享与搜索弹窗以错误的参照系居中并溢出屏幕。包含移动端设备模拟的复现方法、三个被实测否决的候选方案,以及最终的三处修复——MDX 表格滚动容器、超长 token 断行与遮罩层的 100vw 宽度上限。

前端开发

前几天在手机上看自己的博客,点开文章头部的「分享」按钮,弹窗的右侧被切掉了一截:URL 显示不全,「Copy link」按钮也明显偏右,整个弹窗不是以屏幕为中心,而是"往右溢出"。

但奇怪的是,同一篇文章在桌面浏览器里把窗口拖到 375px 宽,一切正常——弹窗会乖乖地缩到刚好放下。

这个「桌面窄窗口不复现」的细节,最后成了破案的关键线索之一。

一、先复现,再解释

一开始我没有急着改代码,而是先要求 Agent 在真实的移动端设备模拟下复现问题,并且给出可验证的数字,而不是先给解释。

复现方式很重要:

  • ❌ 把桌面浏览器窗口拖窄 —— 不会触发移动端的布局视口行为,永远复现不了
  • ✅ 新建一个移动端上下文(viewport: 390×844、deviceScaleFactor: 3、isMobile: true、hasTouch: true),用真实设备规则渲染

复现之后,第一组数字就足够说明问题了:

测量项实测值
documentElement.clientWidth(屏幕宽)390
visualViewport.width390
window.innerWidth(布局视口)482
documentElement.scrollWidth482
document.body 宽度390
100vw 实测390

屏幕只有 390px,但 window.innerWidth 是 482px。文档比屏幕宽了 92px,于是移动端浏览器把布局视口撑到了最宽内容——这就是整个问题的源头。

二、为什么页头正常,弹窗却错位

最反直觉的一点是:截图里页头完全正常(汉堡菜单、居中的标题、右侧的下拉箭头都在该在的位置),只有弹窗错位。原因是常规流和 position: fixed 用了不同的参照系:

屏幕可视宽 = 390 CSS px          布局视口 = 482 CSS px
+------------------------------+  +--------------------------------------+
| body / header 按 390 排版     |  | .modal__backdrop  fixed inset-0      |
|  (所以页头看起来完全正常)      |  |  width:100%  ->  482px 宽            |
+------------------------------+  |  +--------------------------------+  |
                                  |  | .modal__dialog  w-full          |  |
    ^ 弹窗右边界 465,超出屏幕 75px |  | max-w-md = 448px (size="md")     |  |
                                  |  | x=17 .............. right=465    |  |
                                  |  +--------------------------------+  |
                                  +--------------------------------------+

具体到 HeroUI v3 的 Modal:

  • .modal__backdrop 是 fixed inset-0 w-full,100% 相对初始包含块(也就是被撑宽的布局视口 482px)解析,而不是屏幕;
  • .modal__dialog 默认 size="md",即 max-w-md(448px)。容器内容盒是 482 − 32(p-4)= 450 ≥ 448,于是弹窗取到 448px;
  • 我在组件里写的 sm:max-w-sm 只在 ≥640px 生效,在 390px 的屏幕上完全不起作用。

结果就是弹窗 448px 宽、以 482px 为居中参照,右边界落在 465px——超出屏幕 75px,右侧被裁掉。

三、是谁把文档撑宽的

接下来做逐元素扫描:找出所有右边界超过屏幕宽度、且不在滚动容器内的元素。

元素宽度说明
GFM <table>(API 端点表)466px唯一撑破文档的元素(scrollWidth 482 就是它)
裸 URL 链接385px在 385px 以下的屏幕上会独自撑破文档
<pre> 代码块554 ~ 1015px无事,CodeBlock 自己就是横向滚动容器

顺手扫了全站:22 个页面(zh/en 各 6 篇文章 + 首页、列表、关于、分类、标签)在 390px 下,只有 1 篇文章溢出——就是这篇带 API 表格的 Modbus 文章。

根因也很朴素:mdx-components.tsx 映射了 img、a、pre、code,没有映射 table;globals.css 里也没有任何针对 .prose table 的规则。于是 GFM 表格的 min-content 宽度直接撑破了文档。

四、三个被实测否决的方案

排查过程中最有价值的部分,其实是那些"看起来很有道理、但一测就废"的方案。每个候选方案都先用样式注入验证,再决定要不要写进代码:

方案 1:html { overflow-x: clip } —— 无效。

想法是"从根上禁止横向溢出"。实测 window.innerWidth 仍然是 482,弹窗仍然溢出。浏览器计算布局视口宽度时并不看 overflow。

方案 2:只给弹窗加宽度上限 —— 无效。

这是 Agent 的第一版建议,也是我一开始最认可的方案:max-w-[calc(100vw-2rem)]。实测弹窗确实变窄了(448 → 358),但它仍然以 482px 为居中参照:x=62、右边界 420,依旧在屏幕外。真正的问题不是"弹窗太宽",而是"居中参照系错了"。

方案 3:表格用纯 CSS 修复 —— 有副作用。

.prose table { display: block; max-width: 100%; overflow-x: auto } 是流传很广的"可滚动表格"写法,实测也确实能消除溢出。但它会让表格内部的匿名 table box 变成 shrink-to-fit,表格不再撑满栏宽:桌面端实测表格宽度从 953px 缩到 849px,列宽从 67/390/497 变成 59/347/443。这是一个没人要求、但会被读者看见的变化。

五、最终修复

最终改了三处。

1. 给 MDX 表格加滚动容器(mdx-components.tsx)

table: ({ children, ...rest }: React.TableHTMLAttributes<HTMLTableElement>) => (
  <div className="overflow-x-auto">
    <table {...rest}>{children}</table>
  </div>
),

关键是包裹而不是改造表格本身:表格保持 display: table,typography 插件的 width: 100% 撑满和上下外边距完全不变(实测修复前后上下间距都是 12px / 48px),溢出交给外层 div 滚动。

2. 超长 token 允许断行(app/globals.css)

.prose :where(a, :not(pre) > code):not(:where([class~='not-prose'] *)) {
  overflow-wrap: anywhere;
}

必须用 anywhere,不能用 break-word:只有 anywhere 会参与 min-content 尺寸计算,也就是只有它能让外层盒子(以及文档)真正收缩回去。

3. 遮罩宽度跟随屏幕(ShareButton.tsx / SearchDialog.tsx)

<Modal.Backdrop className="max-w-[100vw]">

这里有个关键实测结论:在该状态下 100vw = 390(屏幕),而 100% / innerWidth = 482(被撑宽的布局视口)。所以基于 vw 的上限有效,基于 100% 的上限无效。而且 max-width 只可能收窄宽度,在页面没有溢出时它是完全惰性的——桌面端零风险。

顺带一提,HeroUI 自己的抽屉面板就是这么做的:.drawer__dialog[data-placement="left"] 用了 w-80 max-w-[85vw]。我算是抄了它的作业。

六、验证

修复前后的对比(移动端设备模拟 + 桌面端):

场景修复前修复后
390px:window.innerWidth482390
390px:分享弹窗448px,右边界 465(溢出 75px)358px,右边界 374 ✅
390px:搜索弹窗450px,右边界 466(溢出 76px)358px,右边界 374 ✅
320px:分享 / 搜索弹窗448 / 450px,右边界 465 / 466(溢出 145 / 146px)288px,右边界 304 ✅
桌面端:分享 / 搜索弹窗384 / 592384 / 592(不变)
桌面端:表格列宽67 / 390 / 49767 / 390 / 497(不变)
全站 22 页 × 390/360/320仅 Modbus 文章 2 个语言版本溢出0 处溢出
pnpm build—全部路由仍为 ● (SSG)

顺带把这次的结论写进了 OpenSpec 的 mdx-content、post-share、post-search 三个能力文档(新增需求 + scenario),openspec validate --specs 13/13 通过。

七、复盘

  1. 移动端的"屏幕宽度"有多个值,选错一个就会写出看似合理但无效的兜底。 这次是 100vw(390)与 100% / innerWidth(482)的分歧:同一段 CSS 里换一个单位,修复就从"有效"变成"完全无效"。
  2. 页面横向溢出不只是观感问题。 它会把布局视口撑宽,进而让所有 position: fixed 覆盖层(弹窗、抽屉、遮罩)错位——一个"内容排版"的小毛病,最终表现为"组件坏了"。
  3. 上游有个可以反馈的缺口: React Aria 的遮罩高度跟随可视视口(--visual-viewport-height),宽度却是 w-full(布局视口),两者不一致。结果是每个 Modal / Drawer 都得自己兜一层宽度上限。
  4. 测量陷阱: HeroUI 弹窗的入场动画会带 zoom-in-105,也就是给容器加 transform: scale(1.05)。此时 getBoundingClientRect() 返回的是放大后的值(448 → 470),必须在动画结束后(>250ms)再测量,否则整组数据都是错的。
  5. 用 Agent 排查 Bug 的正确姿势:让它先给出可验证的数字,而不是先给解释。 这次它的第一版方案(只压弹窗宽度)就是错的,而推翻它的正是它自己测出来的 x=62 / 右边界 420。把"每个候选方案都要用注入实测验证"作为硬性要求,比要求它"给出正确方案"更可靠。

评论

加载评论中...