与买桂花同载酒 🌙

博客横向溢出排查实录

前言

博客最近连收两起「事故报告」:Windows 蓝屏修复、父母的美国之旅两篇文章,点进去阅读时整个页面被横向撑开,要左右拖动才能看全;而同一主题、同一布局的其他文章(有无相生、双仓库迁移实录)纹丝不动。

同一套模板,有的文章撑爆、有的安然无恙——这种「按内容变化」的 bug 最值得查:布局是常量,内容是变量,差异必然藏在内容与布局的交互里。排查下来发现是两起完全独立的事故,恰好串起了 CSS 定宽体系的一整条知识链。

预备知识:页面是怎么从 markdown 变出来的

讲事故之前,先把涉及的几块地基码平——从你写下 markdown 到浏览器渲染出页面,中间是一条流水线。

HTML 元素先贴标记,CSS 才能认领。 HTML 元素靠 class / id 属性携带标签:<main class="content-mid"> 就是给这个元素贴上 content-mid 标签。标签本身不产生任何效果,它只是挂钩。而 CSS 规则的前半段——选择器,如 .content-mid { min-width: 0 }——就是拿这个标签去全文档匹配:「找贴了 content-mid 标签的元素,套上这些样式」。两侧靠同一个字符串对接,一个字符都不能差,改了一侧忘了另一侧,样式静默失效——这是前端最常见的「改了没反应」bug。几个语法要点:.类名 选类、#id名 选唯一元素、A B(空格)选 A 内部任意层级的 B、.content.markdown(连写)选同时带两个类的元素——所以主题里那条 .content.markdown figure.highlight 的意思是「正文区里那些代码块」,前半串是围墙,防止误伤侧栏和其他页面的元素。

每个元素是一个四层套盒。 从里到外:content(内容区)→ padding(内边距,内容与边界的软垫)→ border(边框)→ margin(外边距,与邻居的空地)。宽度算账时这些层都要参与:视口 477px − 外框容器左右 padding 20 − 卡片左右 padding 30 = 正文实际可用 427px——后文的账全部这样逐层做减法。

页面骨架由 EJS 模板拼装。 模板 = HTML 骨架 + 三种注入语法:<% %> 写控制逻辑(if/for,不输出可见内容),<%= %> 把数据转义后印成纯文本(防注入,适合不可信内容),<%- %> 把数据原样当 HTML 塞进去(文章正文是渲染好的可信 HTML,必须用它);<%- partial('_partial/head') %> 则引入抽离出去的组件(页头、侧栏),像拼图一样组合。我的博客里这条流水线的完整走向:

1
2
3
4
5
6
Windows蓝屏修复.md(我写的)
→ hexo 渲染成 HTML 片段(<h2>/<p>/<figure class="highlight">,
代码块的标记由渲染器自动贴上,不劳我手)
→ 塞进 post.ejs 的 <div class="content markdown">(文章页结构模板)
→ 产物作为 body 变量,注入 layout.ejs 的 <main class="content-mid">(全站外壳)
→ 输出 public/post/2026/8/Windows蓝屏修复.html

模板里的数据由 Hexo 注入,分五个来源。 模板只管排版,数据另有供应商——Hexo 构建时扫描 source/_posts 建起内存数据库,再把几个对象喂给每张模板:page.*当前页(文章页 = front-matter 字段 + 渲染产物;归档页 = 分页器对象,page.posts 是本页要展示的那 30 篇);site.*全站数据快照——site.posts 是所有文章的集合、site.categories 是全部分类,分类页那句 <% site.categories.forEach(...) %> 遍历的就是它,归档页、标签云本质上都是对 site.* 的查询;theme.* 是主题目录的 _config.yml(主题作者留的旋钮);config.* 是站点根目录的 _config.yml(permalink、插件等);body 只在外壳模板里存在,即内层模板的渲染产物。一句话:模板是排版程序,这五个对象是它全部的输入。

排查:先测量,再谈原理

猜测是廉价的。第一步是让浏览器亲口报出数据:用无头 Edge 加载页面,注入一段探针脚本,把 document.documentElement.scrollWidth(页面实际需要的宽度)和 clientWidth(视口宽度)写进 <title> 再读回来,顺带找出右边缘越界的元素链:

1
2
3
4
5
6
var hits = [];
document.querySelectorAll('body *').forEach(function (el) {
var r = el.getBoundingClientRect();
if (r.right > vw && !clipped(el)) hits.push(/* 元素及祖先链 */);
});
document.title = 'docSW=' + document.documentElement.scrollWidth + ' || ' + hits.join(' || ');
1
msedge --headless=new --dump-dom --window-size=414,900 <页面URL>

中途还踩了个假线索值得记录:最初用 file:// 协议直接打开本地构建产物,测出来的 CSS 计算值全是错的——页面里 /css/style.css 这种根相对路径在 file:// 下解析到盘符根目录,整份站点样式根本没加载,我测的是一份裸奔的无样式页面。凡是依赖相对路径资源的测量,必须起本地 HTTP 服务器(python -m http.server -d public)再测。这个教训值一次返工。

换 HTTP 重测,拿到干净的数据(视口宽 477px 时):

文章 页面实际宽度 元凶元素
有无相生(对照组) 477 ✓
博客双仓库迁移实录 761 代码块表格
父母的美国之旅 1047 span.katex
Windows 蓝屏修复 1188 代码块表格

对照组干净、实验组溢出,测量可靠。且两位「元凶」一个在代码块、一个在正文段落——两条独立线索。

元凶:两起独立事故

A. 代码行撑开了 grid 轨道。 Windows 篇有个代码块,单行等效 148 个半角字符宽(bcdboot 命令带中文注释),实测渲染约 1092px。主题明明给代码块配了 overflow-x: auto(超宽时块内滚动),为什么没拦住?因为溢出发生在更外层——布局是三栏 grid,中栏 1fr,而 grid item 默认带着一条「内容保底」条款(下文详述),最宽代码行的需求被逐层上报,直接把中栏轨道撑到 1112px+,轨道超出了视口。代码块的滚动规则根本没轮到出场。

B. 两个 $ 被 KaTeX 配对成公式。 父母篇是纯散文,没有代码没有图片,却也溢出到 1047px——元凶是一个 span.katex。回查正文:一段话里同时出现了「$5000」和「$800」,而主题的 KaTeX 初始化显式开了单美元定界符:

1
2
3
4
renderMathInElement(document.body, {
delimiters: [{left: '$$', right: '$$', display: true},
{left: '$', right: '$', display: false}]
});

于是两个 $ 之间的五百多个汉字被渲染成了一个行内数学公式。公式是不可分割的整体(white-space: nowrap),一段近千像素宽的「中文公式」横在段落里,页面应声撑爆。

顺带做了个全站扫描:数学笔记里的 都在代码块内(auto-render 默认忽略 code 标签,安全);散文里中招的仅此一段

原理:CSS 是怎么决定宽度的

要理解事故 A,得先知道 CSS 定宽的本质:每个盒子的宽度由三个候选者定价——作者(显式 width)、容器(可用空间,外定)、内容(自我测量,内定)。各布局模式只是合成规则不同。

内定靠两个基本测量。 浏览器对盒子做内禀测量时算两个数:max-content(完全不断行时的铺开宽度)和 min-content(不溢出前提下的最窄宽度)。关键在 min-content断行点决定:

内容 断行点在哪 min-content
中文段落 每两个字之间 ≈ 一个字宽
英文段落 空格处 ≈ 最长单词
white-space: pre 的代码行 不存在 整行宽度
KaTeX 公式(nowrap) 不存在 整个公式

这就是「文字能换行、代码不换行」的全部原因:普通文本是 white-space: normal,断行点密集;代码块是 pre,换行和缩进是语法的一部分,浏览器无权替它折行——一行 1092px 的代码,在断行算法眼里就是一个巨型单词。KaTeX 公式同理。

grid 的轨道定价分两阶段。 grid-template-columns: 200px 1fr 220px 里的 1frminmax(auto, 1fr) 的语法糖:

1
2
3
阶段一(保底):轨道基础尺寸 = item 上报的 min-content
阶段二(分余量):视口若有剩余,按 fr 比例分配
轨道最终宽 = max(保底, 份额)

窗宽 477px 时,阶段二给中栏的份额是 457px(477 − 外框左右 padding 20;再扣卡片自身 padding 30,代码块容器实得 427px)——但阶段一的保底是 1092px 起步,max(1092, 457) = 1092。轨道超出视口,页面横向溢出。桌面宽屏时中栏有 800px+,多数代码的 min-content 够不到保底线,走的是阶段二分支,所以看起来一直正常——直到一篇代码行特别长的文章遇上一个窄窗口。

为什么 overflow-x: auto 沉默? 滚动条出现的条件是「内容宽 > 容器宽」这个比较成立。而容器多宽由轨道谈判决定——保底条款已经把容器撑到和内容一样宽,1092 > 1092 永远为假,滚动条自然一次都不出。overflow 参与的是布局完成后的比较,管不了上游的宽度谈判。规范确实给 scroll 容器开了「免保底」的口子,但那条规则只在 scroll 容器自己是 grid item 时生效;这里的 item 是外层普通块,figure.highlight 藏在四层之下,min-content 经由「pre → table(表格的布局协议本身就建立在 min-content 不可侵犯上)→ … → item」逐层上卷,畅通无阻。

修复与验证

事故 A 的修复是一行 CSS——收回 item 的内容否决权:

1
2
3
.content-mid {
min-width: 0; /* grid 的 1fr 轨道默认 minmax(auto,1fr),会被超宽内容撑破页面 */
}

效果:轨道不再有 1092px 保底,按视口收敛到 457px;代码块容器 427px、内容 1092px,比较终于成立,overflow-x: auto 上线,滚动发生在代码块内部。等价写法还有两扇门:轨道层写 minmax(0, 1fr)(最「官方」),或给 item 设 overflow ≠ visible(副作用大,不取)。注意它不是「不上报」也不是「定死宽度」——测量照做、轨道依然随窗口流动,只是宽度裁判权从内容移交给了视口,装不下的部分由滚动条记账。

事故 B 的修复在内容侧:三处 ?因为 kramed 渲染时会把 \$ 还原成 $ 再输出到 HTML,auto-render 看到的仍是裸美元符号,防不住。全角字符从根本上不是定界符。

验证:修复后重测,四篇文章 × 四种视口宽度(414/768/1000/1350px)全部 scrollWidth = clientWidth,横向溢出清零;代码块在窄视口下出现块内滚动条,正文钉在原地不动。

写在最后

三点可迁移的经验。

一是测量优先于推断。这次从「感觉是代码块太宽」到拿到 1188 vs 477 的实测数字和越界元素链,之后所有原理分析都有锚点——和之前迁移博客时「ls-remote 轮询、deployments API 查状态」是同一个信条:每个环节可观测,就不需要靠胆量。

二是不可断行内容是一切横向溢出的公因数。代码行、公式、不含空格的长 URL,在断行算法眼里都是「巨型单词」,min-content 巨大。以后遇到任何横向撑开,先找页面上最长的那个「单词」。

三是 min-width: 0 是 flex/grid 世界的经典咒语。凡是弹性轨道/弹性项目里装着 pre、长链接、公式的布局,这行都是标配补丁——它背后的整个故事,是 CSS 默认相信内容、overflow 只负责事后收拾、而「容器说了算」必须由作者显式声明的定价体系。