请说说如何实现垂直居中?

前端面试,垂直居中总是一个绕不开的问题,在座的各位可能可以说出不下10种解决方案,最常见的 flex、margin、绝对定位,「复古」一点的用 padding、vertical-align,甚至伪类 after、before 来实现,但是,垂直居中真的那么简单嘛?

无特殊说明,本文示例都在小程序运行环境中

我们来看个需求:

最终要实现的效果如上图,左侧文本右侧图片,简简单单,朴实无华,拿到视觉稿的第五分钟,代码已经写完。

/* index.axml */
<view class="rate">
  <text class="font-alipay-number">4.5</text>
  <image src="https://gw.alicdn.com/tfs/TB1Ujh9LEz1gK0jSZLeXXb9kVXa-226-42.png" />
</view>
/* index.acss */
@font-face {
  font-family: "CommonNumber";
  src: url("https://gw.alipayobjects.com/as/g/antui/antui-static/1.0.3/fonts/AlipayNumber-Regular.ttf") format('truetype');
}

.font-alipay-number {
  font-family: "CommonNumber" !important;
  font-style: normal;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

.rate {
  display: flex;
  align-items: center;
  margin-bottom: 10rpx;
}

.rate text {
  color: #FF5900;
  font-size: 38rpx;
}

.rate image {
  display: block;
  width: 113rpx;
  height: 21rpx;
}

flex 布局,垂直居中,一顿操作,安卓、iOS预览一番,效果完美,今天工资到手,美滋滋。

但是现实往往不会如此简单,当视觉要求突出文本 or 突出星星,需要改变字体大小时,你会得到如下的结果:

排除视觉偏差因素,可以看到,文本与图片在垂直方向上并未对齐,这是为什么呢? align-items: center; 之后,子元素在父容器整个容器框内应该居中才对,带着这个疑问,继续往下看。

用自带的 debug 工具查看各个元素后,可以发现,文本本身在整个父容器中未垂直居中,为了更清晰的看到效果,加上背景色,效果如下图:

可以看到,文本的字框和图片在整个容器都已经垂直居中,但是文本主体并未垂直居中,如图所示,绿线为容器中心线,字符偏绿线下方,星星完全居中,这就导致了在视觉上,整个视图并未垂直居中,这是什么原因?又如何解决呢?

在这之前,先简单学习一下字体排版中的相关术语:

与中文「方块字」不同,西文字体为了提升可读性与排版的需要,将字体分为不同的部分,如上图:

  • 以小写字母「x」为基准得到 BaseLine、MeanLine

  • BaseLine 与 MeanLine 之间的高度为 x-height

  • MeanLine 到字框上部称为升部(ascener)

  • BaseLine 到字框下部称为降部(decender)

从这几个概念可以得到,文本其实并非占满整个字框,上下往往有间距存在(升部、降部),所以如果升部、降部所占空间不一致,字体越大时,视觉上不居中的情况越严重。

如何解决?呼之欲出:

  • 方案一:改字体,让整个字体的升部、降部为0,充满整个字框

  • 方案二:改图片,让图片根据 x-height 的高度为基准上下偏移,实现「视觉」上的垂直居中

方案一需要用到字体排版软件,这里我们使用 Mac 上的 Glyphs,首先用 Glyphs 打开 Alipay Number 字体:

可以看到, Alipay Number 除了包含 0-9 的阿拉伯数字外,还包含常用运算符号以及人民币符号(注意看,羊角符号的字形是 underscore,也就是说使用 Alipay Number 时,需要用 _ 代替 才能使用正确的 font-family)。

双击打开字符,可以查看字符的详细信息:

这张图可以清晰的看到字符「0」在字框中的布局,整体在基线以上,调整升部、降部,可以得到充满整个字框的文本:

以此类推,改完全部字符后,在代码中引用后查看效果:

可以看到,虽然已经将升部、降部改为0,让文本充满整个字框,但是文本上下依旧有空隙,文本和图片依旧无法「完美」的垂直居中,什么原因呢?

答案是 line-height ,因为 line-height 默认值为 normal ,而 normal 对应的具体数值在不同系统、不同字体的值是不一致的,并且即使字体的升降部为0, normal 的取值依旧不为 1

关于 normal 的值具体是多少,w3c 规范中的原话是:

normal
Tells user agents to set the used value to a “reasonable” value based on the font of the element.
The value has the same meaning as . We recommend a used value for ‘normal’ between 1.0 to 1.2.
The computed value is ‘normal’.

建议值在 1.0 到 1.2 之间,但是具体实现,依旧跟各个渲染引擎的排版逻辑有关,实测后,这个值一般在 1.2 左右。

除了默认值 normalline-height 还支持数字、带单位的长度、百分比,这些相对比较常用,不再展开。为了解决上面行高原因导致文本未充满字框的问题,只需设置字体大小与行高大小一致即可,也就是 line-height: 1;

.font-alipay-number {
  font-family: "CommonNumber" !important;
  font-style: normal;
  line-height: 1; /* 行高设为 1,与字体大小保持一致 */
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

再来看结果:

基本上实现了想要的效果,但是仔细看,还是有问题:

  • 安卓字体过大时,字符会溢出字框(右侧绿虚线,图片点开放大看)

  • 字体大小难以统一,正常字体的 30rpx 才相当于无升降部字体的 24 rpx

  • 成本太高,业务中无法重置所有字符的升降部

方案一宣告失败,再看方案二。

方案二需要右侧图片按照 x-height 的高度做上下偏移,以达到在视觉上的垂直居中,要实现这个目的,得先知道 x-height 的具体高度,恰巧,css 中有个极少用的单位可以获取 x-height 的高度, ex

1ex 代表当前元素所应用字体的一个 x-height 高度,是一个兼容性极好的单位,利用 ex 可以方便的控制元素高度与字体的关系,所以使当前元素的高度等于 1ex 时,在整个容器中会保持「视觉」上的垂直居中,因为 x-height 的高度在整个文本中处于中间位置,如下图:

小程序中,图片是以背景方式实现,为了减少高度对背景图的影响(需要根据实际情况调整 mode),这里为 view 标签设置背景图来模拟效果:

/* index.axml */
<view class="rate">
  <text class="font-alipay-number">4.5</text>
  <!-- <image src="https://gw.alicdn.com/tfs/TB1ZKs4aBFR4u4jSZFPXXanzFXa-177-38.svg" />  -->
  <view class="image" />
</view>
/* index.acss */
.rate {
  margin-bottom: 10rpx;
}

.rate text {
  display: inline-block;
  color: #FF5900;
  font-size: 38rpx;
}

.rate .image {
  height: 1ex; /* 重点,1ex 高度 */
  display: inline-block;
  background-image: url("https://gw.alicdn.com/tfs/TB1Ujh9LEz1gK0jSZLeXXb9kVXa-226-42.png");
  background-size: 113rpx 21rpx;
  background-position: left center;
  background-repeat: no-repeat;
  width: 113rpx;
  /* 为了与文本的 1ex 高度保持一致,为图片元素赋值与文本一致的文字属性 */
  font-family: "CommonNumber" !important;
  font-style: normal;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  font-size: 58rpx;
}

重点关注 Image 元素的 1ex 高度,并且去掉了父容器的 flex 布局,将子元素改为 line-block 行内元素(原因稍后继续分析),看效果:

效果完美!今天的工资拿的可以安心一点了,但是,就这样就结束了?

上面的例子只关注了文本比图片大的情况,如果文本小,图片大,这个时候的 1ex 还能用嘛?答案很明显,不可以, 1ex 高度的容器无法容纳整个图片。即使用各种 hack 手段,实现了在一个比图片实际高度小的容器内完整展示图片,并垂直居中,那整个容器的高度也难以计算,而且 hack,我所不欲也,继续!

按照方案二的思路,需要调整图片,让图片按照 x-height 来布局,ex 的路子已验证无法走通,但是和 x-height 相关的属性可不止 ex 一个 —— 有请 vertical-align 上场。

其实在字体中讲解到 BaseLine 时,对 css 熟悉的同学应该就会想到 vertical-align ,因为 vertical-align 的默认值就是 base-line ,「巧合」的是,这里的 base-line 就是指的字体排版中的 BaseLine。

多个行内元素在同一个父元素内参与布局时,默认的排版方式就是以当前元素文本的 BaseLine 对齐,举个例子,如果想要实现不同大小的文本混排,并且文本底端对齐时,只要将子元素设为行内元素即可,如下图

价格中的羊角或小数,通常与主价格大小不一致,经常见到用 flex 布局,然后使用各种 hack 手段,给不同的端、不同的系统添加上下边距,使文本底端「看起来」对齐,其实给各个子元素 display: inline-block; 之后,天然对齐,不用做任何处理。

讲完 vertical-alignbase-line 的基本应用,再回到我们的话题中,讲一讲 vertical-align 中的其他属性 。

首先看 middle ,与 base-line 同理, middle 也是相对于 BaseLine 的概念,是指 「基线往上的 x-height/2 处」,也就是指将当前元素的垂直中心位置与 x-height 二分之一处重合,也就是与小写字母「x」的交叉点对齐,伪代码如下(减少 rpx 单位换算为 rem 后小数位不精确问题,采用 px 单位):

/* index.axml */
<view class="rate rate-1">
  <text>0.1</text>
  <image src="https://gw.alicdn.com/tfs/TB1Ujh9LEz1gK0jSZLeXXb9kVXa-226-42.png" />
</view>
<view class="rate rate-2">
  <text>2.3</text>
  <image src="https://gw.alicdn.com/tfs/TB1Ujh9LEz1gK0jSZLeXXb9kVXa-226-42.png" />
</view>
<view class="rate rate-3">
  <text>4.5</text>
  <image src="https://gw.alicdn.com/tfs/TB1Ujh9LEz1gK0jSZLeXXb9kVXa-226-42.png" />
</view>
/* index.acss */
.rate {
  margin-bottom: 10px;
}

.rate text {
  font-family: "CommonNumber" !important;
  font-style: normal;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  line-height: 1;
}

.rate text {
  display: inline-block;
  color: #FF5900;
  vertical-align: middle; /* 重点 */
}

.rate-1 text {
  font-size: 24px;
}

.rate-2 text {
  font-size: 13px;
}

.rate-3 text {
  font-size: 44px;
}

.rate image {
  display: inline-block;
  vertical-align: middle; /* 重点 */
  margin-left: 4px;
}

.rate-1 image {
  width: 113px;
  height: 21px;
}

.rate-2 image {
  width: 113px;
  height: 21px;
}

.rate-3 image {
  width: 113px;
  height: 21px;
}

看下实际效果:

视觉上看着是「居中」了,为了更精确,放大再看下:

(有几个像素的偏差,深究之后发现是字体原因,数字升部有一些偏差,不改字体此处无解)

蓝色和紫色色块的高度一致,绿线为整个容器的垂直居中线,可以看到,不管在 iOS 还是安卓,效果都「近乎」完美(点开图片放大,可以数像素格),至此,终于可以长出一口气了。

不过都走到这了,让我们更进一步,上面讲到 middle 是将当前元素的垂直中心位置与 x-height 二分之一处重合,可是这句话忽略了一个问题,到底是跟哪个文本的 x-height 二分之一处重合呢?

首先肯定不是当前元素,因为文本和图片元素的 x-height 肯定不一致,设置 vertical-align: middle; 时无法保证在同一条平分线上,所以只能是父容器,但是问题来了,父容器并没有设置字体、字号,那 x-height 哪里来的呢?

为了验证这个问题,清空所有子元素,只保留一个设置了 inline-block 的文本元素,父容器也无任何属性,伪代码如下:

<view class="rate">
  <text></text>
</view>
.rate text {
  display: inline-block;
}

看下实际渲染结果:

很神奇,子元素无任何内容,整个父容器居然也有高度,是什么因素影响了呢,答案是「幽灵空白节点」(来源于张鑫旭老师命名),在内联元素之前,永远存在着一个「空白节点」,不占据宽度,无法获取,但是真实存在,并且表现和文本节点一样,这就是内联元素设置 vertical-align 时 x-height 对齐的对象。

这样,就很好理解当为两个内联子元素设置 vertical-align 时,它们能够严格对齐,就是因为它们所对齐的「基准」是同一个「节点」。

插句题外话,使用内联元素时,元素与元素之间,会有缝隙出现,也是这个「幽灵空白节点」在作祟,一般处理就是让这个「节点」「消失」,比如将父容器的字号设为0、行高设为0等等。

所以,很多 CSS 问题在各个场景都需要具体分析,上面的例子主要用来讲解 CSS 字体排版的一些问题,除了用 vertical-align 实现基线对齐,其实也有其他办法实现。比如星星用 icon font 实现,然后改造 icon font 的升降部与文本完全一致,这样不管如何排版,基线永远一样,可以省去很多事情,有兴趣可以试下。

好了,垂直居中的问题终于可以完美解决了,再看下最终结果,美滋滋拿今天的工资:


讲了这么多,其实最终的知识点都还是在 css 的盒模型范围内。

关于盒模型,前端经常提到 BFC(Block Formatting Context),但是在块级盒模型之外,还有 IFC(Inline Formatting Context)行内盒模型。

IFC 的使用场景大部分聚焦在字体排版上,而且使用起来极其复杂,原理性内容也更多,所以 BFC 的使用场景更广,小程序内甚至默认 Flex 布局,但是实际应用中,Flex 会带来不少问题,开发中还是需要具体问题具体分析。

如果想要了解更多关于 IFC 的知识,推荐张鑫旭老师的「CSS世界」。