前几天在手机上看自己的博客,点开文章头部的「分享」按钮,弹窗的右侧被切掉了一截:URL 显示不全,「Copy link」按钮也明显偏右,整个弹窗不是以屏幕为中心,而是"往右溢出"。
但奇怪的是,同一篇文章在桌面浏览器里把窗口拖到 375px 宽,一切正常——弹窗会乖乖地缩到刚好放下。
这个「桌面窄窗口不复现」的细节,最后成了破案的关键线索之一。
一、先复现,再解释
一开始我没有急着改代码,而是先要求 Agent 在真实的移动端设备模拟下复现问题,并且给出可验证的数字,而不是先给解释。
复现方式很重要:
- ❌ 把桌面浏览器窗口拖窄 —— 不会触发移动端的布局视口行为,永远复现不了
- ✅ 新建一个移动端上下文(
viewport: 390×844、deviceScaleFactor: 3、isMobile: true、hasTouch: true),用真实设备规则渲染
复现之后,第一组数字就足够说明问题了:
| 测量项 | 实测值 |
|---|---|
documentElement.clientWidth(屏幕宽) | 390 |
visualViewport.width | 390 |
window.innerWidth(布局视口) | 482 |
documentElement.scrollWidth | 482 |
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.innerWidth | 482 | 390 |
| 390px:分享弹窗 | 448px,右边界 465(溢出 75px) | 358px,右边界 374 ✅ |
| 390px:搜索弹窗 | 450px,右边界 466(溢出 76px) | 358px,右边界 374 ✅ |
| 320px:分享 / 搜索弹窗 | 448 / 450px,右边界 465 / 466(溢出 145 / 146px) | 288px,右边界 304 ✅ |
| 桌面端:分享 / 搜索弹窗 | 384 / 592 | 384 / 592(不变) |
| 桌面端:表格列宽 | 67 / 390 / 497 | 67 / 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 通过。
七、复盘
- 移动端的"屏幕宽度"有多个值,选错一个就会写出看似合理但无效的兜底。 这次是
100vw(390)与100%/innerWidth(482)的分歧:同一段 CSS 里换一个单位,修复就从"有效"变成"完全无效"。 - 页面横向溢出不只是观感问题。 它会把布局视口撑宽,进而让所有
position: fixed覆盖层(弹窗、抽屉、遮罩)错位——一个"内容排版"的小毛病,最终表现为"组件坏了"。 - 上游有个可以反馈的缺口: React Aria 的遮罩高度跟随可视视口(
--visual-viewport-height),宽度却是w-full(布局视口),两者不一致。结果是每个 Modal / Drawer 都得自己兜一层宽度上限。 - 测量陷阱: HeroUI 弹窗的入场动画会带
zoom-in-105,也就是给容器加transform: scale(1.05)。此时getBoundingClientRect()返回的是放大后的值(448 → 470),必须在动画结束后(>250ms)再测量,否则整组数据都是错的。 - 用 Agent 排查 Bug 的正确姿势:让它先给出可验证的数字,而不是先给解释。 这次它的第一版方案(只压弹窗宽度)就是错的,而推翻它的正是它自己测出来的
x=62 / 右边界 420。把"每个候选方案都要用注入实测验证"作为硬性要求,比要求它"给出正确方案"更可靠。