Web UI 的「视觉净空」:盒间距设成一样,为什么看起来还是不一样

2026-06-26 前端

2026-10-03 更新:本文改用 CSS 规范原文(CSS 2.2、CSS Inline Layout 3、Flexbox、Box Alignment/Gaps)与设计体系官方文档重写,删去原先的本机测量数值,改为给出规范级原因与可复制的修正写法。

一个工具栏里几个元素间距都设成 16px,截图一看还是有的挤、有的松;标题上下内边距写成一样,字看起来总是偏上。这类"数值一样、观感不一样"的问题,根子不在审美,而在几个规范层面的机制:半行距、外边距合并、基线对齐,以及 gap 只保证盒边缘等距。

一、规范级根因:半行距(half-leading)

CSS 2.2 的 §10.8.1 Leading and half-leading 把行距算得很具体:设字体的 ascent 为 A、descent 为 D,行盒高度由 line-height 决定,中间差出来的部分

L = line-height − (A + D)

称为 leading,规范要求一半加在字形上方、一半加在下方:

A′ = A + L/2      D′ = D + L/2

规范的原文表述是:行内盒的高度囊括所有字形及其两侧的半行距,因此恰好等于 line-height。

问题就出在这里:A 和 D 由字体决定,两者通常不相等——上方留白按 A 分配、下方按 D 分配,字号越大、行高越大,这种不对称越明显。("看起来偏上/偏下"是由该算法推出的结论,规范本身只定义算法,不评价观感。)

设计体系层面同样承认这个机制:Material Design 3 的排版文档在讲 Web 端实现时,直接说明文字在边界盒内垂直居中,遵循 CSS 建立的 half-leading 行为。规范也给了未来的解法:CSS Inline Layout Level 3 定义的 text-box-trim(修剪首/末行盒的半行距)与 text-box-edge(指定用哪套文字度量来裁),目的就是让内容盒边缘贴合文字。注意它目前是 Editor's Draft(工作草案,可能变更),MDN 记载它在 2026 年 8 月起于最新浏览器可用——生产环境要用,得配 @supports 或先接受降级。

可以复制的对照写法:

/* 半行距导致上下留白不对称:盒子上下到墨迹的距离由字体的 A / D 分开决定 */
.optical-tight {
  font-size: 16px;
  line-height: 1.5;   /* 行盒 24px;L = 24 − (A+D),L/2 各加在上、下 */
}

/* 补偿写法:先把行盒压到接近字形高度,再用块轴内边距配平上下 */
.optical-balance {
  line-height: 1;
  padding-block: .35em .5em;   /* 不对称补偿,让墨迹上下净空相等 */
}

/* 规范级的正解(草案,需特性检测) */
@supports (text-box: trim-both cap alphabetic) {
  .optical-balance { line-height: 1.5; padding-block: 0; text-box: trim-both cap alphabetic; }
}

二、第二个来源:外边距会合并

CSS 2.2 §8.3.1 Collapsing margins 规定:两个或多个盒的相邻上下外边距会合并成一个外边距;多个外边距合并时结果为其中的最大值(负值另有规则)。

于是 "16 + 16 = 32" 这种预期经常落空:相邻块级元素之间实际是 16。gap 之所以被大量使用,一个原因就是它不参与合并,语义上就是"两者之间的距离"——要精确控制间距,用 gap 比叠 margin 可预期得多。

三、第三个来源:基线对齐留下的"看不见的空隙"

  • CSS 2.2 §10.8.1 规定:inline-block 的基线是它最后一行行盒的基线;如果它没有流内行盒,或者 overflow 的计算值不是 visible,则基线取下外边距边缘。
  • vertical-align: baseline 的语义是"把盒的基线与父盒的基线对齐"。替换元素(如 <img>)默认按基线参与行内布局,而基线之下还有一段 descent 的空间——这就是图片底部那条缝的来源。

修法是二选一:给图片 display: block,或用 vertical-align: middle / bottom 显式对齐(MDN 对 vertical-align 的定义是设置 inline、inline-block 或 table-cell 盒的垂直对齐)。

/* 图片底部空隙的两种修法 */
.article img { display: block; }              /* 脱离行内布局 */
.toolbar img { vertical-align: middle; }      /* 仍参与行内布局时显式对齐 */

Flex 布局下是另一套语义:Flexbox 规范 §8.3 说明,当 align-items / align-self 取 baseline 时,同一行所有参与基线对齐的项按基线对齐,基线到交叉轴起始外边距最大的项贴住该行起始边;若某项在所需轴上没有基线,就合成一个基线(所以"看起来没对齐"往往是因为某个子项被合成了基线)。

四、第四个来源:gap 只保证"盒边缘"等距

CSS Gaps Module Level 1 §2 定义了 gap / row-gap / column-gap:在 multi-column、flex、grid 中为相邻盒提供间距;用于 flex 容器时,主轴表示相邻 flex 项之间的固定间距,交叉轴表示相邻 flex line 的间距。(CSS Box Alignment Level 3 已把该章节移交给该模块,MDN 的 gap 页面表述与此一致。)

关键在于:gap 量的是盒边缘,不是墨迹边缘。视觉净空 = gap + 两侧各自的内部余量。一个有边框、有内边距的按钮,和一个只有图标(墨迹内缩)的按钮并排时,边缘等距不等于观感等距——这就是"设成一样还是不一样"的最终答案。

按项补偿的写法(可复制):

.toolbar {
  display: flex;
  gap: 16px;
  align-items: center;
}

/* 墨迹比盒小的项,按侧补偿掉它自己那部分内缩 */
.toolbar > .icon-only { margin-inline: -3px; }   /* 无边框图标:两侧都多 */
.toolbar > .text-btn  { margin-inline: 0 6px; }  /* 文字右侧自带字距余量 */

用逻辑属性而不是 margin-left/right,在 RTL 语言下不用重写(MDN 对 padding-block / margin-block 的说明:它们按书写模式映射到物理属性)。

五、把"视觉净空"写成一个可调的数值

与其逐处试数,不如把它显式参数化:把行盒压到字形高度,用不对称内边距承载"看上去该有的留白",让净空变成一个变量。

:root {
  --optical-gap: .35em;   /* 视觉净空:唯一需要调的旋钮 */
}

.stack > * + * {
  margin-block-start: calc(var(--optical-gap) * 2);  /* 不依赖 gap 的兜底写法 */
}

.panel-title {
  line-height: 1;
  padding-block: var(--optical-gap) calc(var(--optical-gap) * 1.4);  /* 上小下大,抵半行距 */
}

调参时记住三条:字号变了要重算(A/D 随字号变),字体换了也要重算(不同字体的 ascent/descent 不同),行高变了同样影响上下分配。

六、按内容类型分类处理

相邻关系 参考边界 建议做法
文本 ↔ 文本 行盒边缘 用 margin-block,注意半行距造成的上下不对称
有边框的元素 ↔ 有边框的元素 边框 gap 即可,边缘即墨迹
无边框图标 ↔ 文字 墨迹 gap + margin-inline 按项补偿
图片 ↔ 文本 图片墨迹 图片 display: block,再谈间距
跨状态(hover/focus 出现描边) 描边后的边界 描边不要用会改变盒尺寸的写法(改用 outline / box-shadow)

七、几条能落地的规则

  1. 同一排元素,先统一参考边界(有边框看边框、无边框看墨迹),再谈数值;
  2. 能不用外边距合并的地方就避免依赖它——需要精确间距时用 gap;
  3. 图标类元素与文字类元素混排时,按项补偿而不是全局调 gap;
  4. 半行距造成的不对称,用"行高压到 1 + 不对称 padding-block"抵消,别用等值内边距硬凑;
  5. 描边类状态变化不要改变盒模型尺寸,否则一组数字全部失效。

参考来源