前几天改博客主题,遇到一个很小的视觉问题:文章页里,作者署名到正文第一行之间,空了一大块。
一开始以为是 padding 设大了,量了一下——158 像素。这个数字说明不只是 padding 的问题,因为那块区域的内边距加起来只有 100 像素。
一、把 158 像素拆开
打开开发者工具,量了几个盒子的位置,拆出来是这样:
署名底部 → 标题区底部 44px (标题区的 padding-bottom)
标题区底部 → 正文区顶部 0px
正文区顶部 → 正文内容顶部 56px (正文区的 padding-top)
正文内容顶部 → 第一个字 57.6px ← 这 57.6px 是哪来的?
前两项正常,最后一项就很奇怪了:正文容器的 padding 和 margin 都是 0,第一个元素却比容器顶部低了 57.6 像素。
二、根因:外边距塌陷
看正文的 DOM 结构,发现是这样:
<div class="gh-content">
<p></p> ← 空段落
<h2 id="abstract">Abstract</h2> ← 第一个有内容的元素
<p>This study investigates...</p>
</div>
第一个元素是一个空段落。而后面那个 h2 的样式里有:
.gh-content h2 { margin-top: 2.4em; } /* 24px × 2.4 = 57.6px */
问题就出在这。CSS 里有一条规则叫外边距塌陷(margin collapsing):相邻的两个外边距会合并成一个,取较大的那个。
而一个高度为 0 的空元素,它的上下外边距还会穿透它继续往上合并。
于是 h2 的 57.6 像素上边距,穿过那个空段落,又穿过没有 padding、没有 border 的 .gh-content,最后变成了整个正文容器上方的间距。内容整体被往下顶了 57.6 像素。
44 + 56 + 57.6 ≈ 158。对上了。
三、一个更麻烦的细节
第一反应是用 CSS 处理掉空段落:
.gh-content > p:empty { display: none; }
结果发现有的页面好了,有的没好。原因在于 :empty 这个选择器——它要求元素完全没有任何子节点,而浏览器把空白字符也算作文本节点。也就是说:
<p></p> ← 匹配 :empty
<p> </p> ← 只有空格,不匹配 :empty
两篇文章里,一篇开头是真空段落,另一篇开头是带空格的段落,所以表现不一致。(Selectors Level 4 里的 :blank 本来是为了解决这个的,但浏览器一直没有实现。)
四、最后的做法
纯 CSS 没法判断「这个段落里有没有可见文字」,所以改用几行 JS,在页面加载时把正文开头连续的空段落删掉:
var content = document.getElementById('post-content');
if (content) {
var blank = content.firstElementChild;
while (blank && blank.tagName === 'P'
&& !blank.textContent.trim()
&& !blank.querySelector('img, video, iframe, figure')) {
var after = blank.nextElementSibling;
blank.remove();
blank = after;
}
}
只处理开头,碰到第一个有内容的元素就停,所以不会误删正文中间那些有意为之的空行。querySelector 那个判断是防止把「只放了一张图的段落」当成空段落删掉。
同时补两条 CSS 兜底:
.gh-content > *:first-child,
.gh-content > p:empty + * { margin-top: 0; }
再把两处 padding 收紧(44→28、56→34),最后署名到正文第一行是 62 像素,正常了。
五、小结
外边距塌陷大概是 CSS 里最容易让人困惑的机制。它其实是为了让段落间距看起来自然——相邻段落的上下边距不会叠加成双倍。但它的副作用值得记住:
- 给父元素加
padding或border就能挡住塌陷 - 给父元素加
overflow: hidden或display: flow-root也行 - 空元素会让上下边距直接穿透,这是最容易踩的一种
遇到「莫名其妙多出来一块空白」,先量尺寸、把数值拆成能对上号的几段,然后往「是不是塌陷了」这个方向想,通常比翻样式表快得多。
评论