博客横向溢出排查实录
前言
博客最近连收两起「事故报告」: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 | |
模板里的数据由 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 | |
1 | |
中途还踩了个假线索值得记录:最初用 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 | |
于是两个 $ 之间的五百多个汉字被渲染成了一个行内数学公式。公式是不可分割的整体(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 里的 1fr 是 minmax(auto, 1fr) 的语法糖:
1 | |
窗宽 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 | |
效果:轨道不再有 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 只负责事后收拾、而「容器说了算」必须由作者显式声明的定价体系。