使用 Flexbox 实现等宽列:比你想象的更复杂

Avatar of Kevin Powell
Kevin Powell 发布

DigitalOcean 为你的旅程的每个阶段提供云产品。立即开始使用 200 美元的免费信用额度!

你拿到一个看起来不错的设计,它有一个非常棒的英雄区,下面紧跟着一个三列布局。你知道,就像你以前做过的几乎所有其他网站一样。

你快速完成了英雄区,开始着手制作三列布局。现在是拿出我们可靠的朋友 Flexbox 的时候了!然而,你写下了 display: flex,结果却得到了一团糟,而不是你期望的三列等宽布局。

出现这种情况是因为 Flexbox 计算元素基本大小的方式。你可能已经阅读过许多 Flexbox 教程,其中许多教程(包括我自己写的)都过于简单地介绍了 Flexbox 的功能,而没有真正深入探讨 Flexbox 为我们完成的那些我们认为理所当然的复杂操作。

我相信你已经看到过类似这样的例子和教程,其中三个 div 围绕里面的内容缩小

换句话说,我们看到一些块级元素缩小并并排排列。感觉上 Flexbox 希望元素尽可能小。但实际上,Flexbox 实际上希望元素尽可能大。

等等,什么?Flexbox 默认情况下会缩小元素——这不可能吧!对吧?

尽管 Flexbox 非常棒,但它在幕后进行的操作实际上有点奇怪,因为默认情况下,它同时执行两个操作。首先,它会查看内容大小,即我们在元素上声明 width: max-content 时会得到的大小。但除此之外,flex-shrink 也会发挥作用,允许元素变小,但前提是需要时才变小。

为了真正理解正在发生的事情,让我们将这两个方面分解开来,看看它们是如何协同工作的。

深入 max-content

max-content 在某些情况下是一个非常方便的属性值,但我们需要了解它在这个特定情况下是如何工作的,我们试图获得三个看似简单的等宽列。所以让我们暂时忽略 Flexbox,看看 max-content 本身是如何工作的。

MDN 对此进行了很好的 解释

max-content 大小关键词表示内容的内在最大宽度。对于文本内容,这意味着即使会导致溢出,内容也不会换行。

内在可能让你感到困惑,但它基本上意味着我们让内容决定宽度,而不是我们显式设置一个固定的宽度。Uri Shaked 恰当地描述了这种行为,他说:“浏览器假装它有无限的空间,并在测量宽度时将所有文本放在一行中。”

所以,底线是,max-content 允许内容本身定义元素的宽度。如果有一个段落,该段落的宽度将是它里面的文本,没有任何换行。如果段落足够长,那么最终会出现一些水平滚动。

让我们重新看一下那个过于简单的例子,其中三个块级元素缩小并并排排列。这并非因为 flex-shrink;而是因为当元素声明的宽度为 max-content 时,这就是它们的大小。这确实是它们的最大宽度,因为这与每个元素内组合内容的宽度一样。

这里,看一下这些元素,没有 Flexbox 执行 Flexbox 操作,但它们却有一个 width: max-content

所以,当只有一小段文本时,内在 max-content 会缩小元素,而不是 flex-shrink。当然,Flexbox 也带来了它的默认 flex-direction: row 行为,它将 Flex 项变成列,并将它们并排放置。这里再看一下,但这次突出了空白区域。

flex-shrink 添加到方程式中

因此,我们看到声明 display: flex 会将该 max-content 内在大小应用于 Flex 项。这些项希望变得与它们的内容一样大。但是,这里还有另一个默认设置,那就是 flex-shrink

flex-shrink 基本上会查看灵活容器中的所有 Flex 项,以确保它们不会溢出父容器。如果 Flex 项能够在不溢出灵活容器的情况下并排排列,那么 flex-shrink 不会执行任何操作……它已经完成了自己的工作。

但如果 Flex 项确实溢出了容器(这要归功于 max-content 内在宽度设置),则允许 Flex 项缩小以防止溢出,因为 flex-shrink 会注意这种情况。

这就是 flex-shrink 的默认值为 1 的原因。如果没有它,事情会很快变得很混乱。

这就是列没有等宽的原因

回到我们的设计场景,我们想要在英雄区下面获得三个等宽的列,我们发现这些列没有等宽。这是因为 Flexbox 会先查看每个 Flex 项的内容大小,然后再考虑缩小它们。

使用 Firefox 的 DevTools(因为它有一些其他工具没有的独特 Flexbox 可视化效果),我们实际上可以看到 Flexbox 是如何计算所有内容的。

为了简便起见,随着我们更深入地研究,让我们使用一些漂亮的整数。我们可以通过在 Flex 项上声明宽度来做到这一点。当我们在 Flex 项上声明宽度时,我们会将该内在大小抛诸脑后,因为我们现在已经声明了一个显式值。这使得弄清楚到底发生了什么变得容易得多。

在下面的 Pen 中,我们有一个宽度为 600px 的灵活容器(display: flex)作为父容器。我已经删除了可能影响数字的所有内容,因此没有 gappadding。我还用 outline 替换了 border,这样我们仍然可以轻松地可视化所有内容。

第一个和第三个 Flex 项的宽度为 300px,中间一个是 600px。如果我们将它们加起来,总计为 1200px。这比父容器内的 600px 大,因此 flex-shrink 会发挥作用。

flex-shrink 是一个比率。如果所有元素都具有相同的 flex-shrink(默认情况下为 1),那么它们都会以相同的速率缩小。这并不意味着它们都会缩小到相同的大小或缩小相同的量,但它们都会以相同的速率缩小。

如果我们回到 Firefox DevTools,可以看到基本大小、flex-shrink 和最终大小。在这种情况下,两个 300px 元素现在是 150px,而 600px 元素现在是 300px

两个基本宽度为 300px 的元素变成了 150px
基本宽度为 600px 的较大元素变成了 300px

如果我们将所有三个 Flex 项的基本大小加起来(我们为它们声明的实际宽度),总计为 1200px。我们的 Flex 容器的宽度为 600px。如果我们将所有内容除以 2,它就适合了!它们都以相同的速率缩小,将它们自己的宽度除以 2

在现实世界中,我们并不经常遇到这样的漂亮的整数,但我认为这很好地说明了 Flexbox 在确定元素大小时的工作原理。

使列等宽

有几种不同的方法可以使我们想要的三个列的宽度相等,但有些方法比其他方法更好。对于所有方法,基本思想是我们希望所有列的基本大小都相同。如果它们具有相同的基本大小,那么当 Flexbox 执行 Flex 操作时,它们会以相同的速率缩小(或增大,如果我们使用 flex-grow),理论上,这应该使它们的大小相同。

有一些常见的实现方法,但我在深入研究这一切的过程中发现,我认为这些方法是有缺陷的。让我们看看两种我在实际应用中见到的最常见的解决方案,我将解释为什么它们不起作用。

方法 1:使用 flex: 1

我们可以尝试让所有 Flex 项具有相同基本大小的一种方法是在所有 Flex 项上声明 flex: 1

.flex-parent { display: flex; }
.flex-parent > * { flex: 1; }

在我以前制作的一个教程中,我使用了不同的方法,我一定有 100 个人问我为什么不使用 flex: 1。我回答说我不想深入探讨 flex 简写属性。但后来我在一个新的演示中使用了 flex: 1,令我惊讶的是,它不起作用

这些列没有等宽。

这里的中间列比另外两列要宽。虽然宽度差距不大,但我创建的整个设计模式就是为了确保每次都能获得完全相等的列,无论内容如何。

那么为什么这种情况行不通呢?**罪魁祸首是中间列组件上的padding**。

也许你会说,给其中一个项目添加填充而其他项目不添加填充很傻,或者我们可以嵌套元素(我们以后会讲到)。但在我看来,我应该能够拥有一个无论我们放置什么内容都能正常工作的布局。毕竟,如今的网页都是关于我们可以即插即用的组件。

当我第一次设置它的时候,我确信它会奏效,看到这个问题出现后,我迫切地想要了解这里到底发生了什么。

flex简写不仅仅设置了flex-grow: 1。如果你还不知道,flexflex-growflex-shrinkflex-basis的简写。

这些组成属性的默认值是

.selector {
  flex-grow: 0;
  flex-shrink: 1;
  flex-basis: auto;
}

我不会在这篇文章中深入探讨flex-basis,因为Robin 已经很好地解释了它。现在,为了简单起见,我们可以把它看成width,因为我们没有玩flex-direction

我们已经看到flex-shrink是如何工作的。flex-grow则相反。如果所有弹性项目沿主轴的大小都小于父元素,则它们可以增长以填满该空间。

所以默认情况下,弹性项目

  • 不会增长;
  • 如果它们会溢出父元素,则允许它们收缩;
  • 它们的宽度就像max-content

综上所述,flex简写的默认值为

.selector {
  flex: 0 1 auto;
}

flex简写有趣的一点是,你可以省略值。因此,当我们声明flex: 1时,它将第一个值flex-grow设置为1,这实际上打开了flex-grow

**这里奇怪的是,省略的值会发生什么**。你可能会认为它们会保持默认值,但实际上并非如此。不过,在我们了解发生了什么之前,让我们先深入了解一下flex-grow究竟做了什么。

正如我们所见,flex-shrink允许元素收缩,如果它们的基准大小加起来的值大于父容器的可用宽度。flex-grow则相反。如果元素基准大小的总和小于父容器宽度值,则它们会增长以填满可用空间。

如果我们以一个非常基本的示例为例,我们在其中有三个并排的小div,并添加flex-grow: 1,它们会增长以填满剩余空间。

但如果我们有三个宽度不相等的div——就像我们开始使用的那些div——向它们添加flex-grow根本不会有任何作用。它们不会增长,因为它们已经占用所有可用空间了——事实上,空间太多了,以至于flex-shrink需要介入并收缩它们以适应!

但是,正如人们向我指出的那样,设置flex: 1可以用来创建相等的列。嗯,有点像我们上面看到的!在简单的场景中,它确实有效,正如你下面所看到的。

当我们声明flex: 1时,它有效是因为,不仅它将flex-grow设置为1,而且它还改变了flex-basis

.selector {
  flex: 1;
  /* flex-grow: 1; */
  /* flex-shrink: 1; */
  /* flex-basis: 0%; Wait what? */
}

是的,设置flex: 1flex-basis设置为0%。这覆盖了我们之前拥有的像max-content一样的内在大小。我们所有的弹性项目现在都想拥有0的基准大小!

在 DevTools 中查看flex: 1简写的扩展视图显示flex-basis已更改为0

因此它们的基准大小现在是0,但由于flex-grow的存在,它们都可以增长以填满空闲空间。实际上,在这种情况下,flex-shrink不再起任何作用,因为所有弹性项目的宽度现在都是0,并且正在增长以填满可用空间。

FireFox 的 DevTools 显示具有flex: 1的元素的内容大小为0,并且正在增长以填满可用空间。

就像之前的收缩示例一样,我们正在获取可用的空间,并让所有弹性项目以相同的速度增长。由于它们的基准宽度都是0,因此以相同的速度增长意味着可用空间被它们平均分配,并且它们都具有相同的大小!

除了,正如我们所看到的,**并非总是如此……**

原因在于,当弹性盒子完成所有这些操作并分配空间时,无论是收缩还是增长弹性项目,它都在查看元素的内容大小。如果你还记得盒模型,我们有内容大小本身,然后是paddingbordermargin在它外面。

并没有,我没有忘记* { box-sizing: border-box; }

**这是 CSS 的一个奇怪之处**,但它确实有道理。如果浏览器查看了这些元素的宽度,并将它们的paddingborders包含在计算中,它如何才能收缩它们?要么padding也必须收缩,要么东西会溢出空间。这两种情况都很糟糕,因此,在计算元素的基准大小时,它只查看内容框,而不是查看元素的box-size

因此,设置flex: 1会导致在某些元素上有borderpadding的情况下出现问题。我现在有三个内容大小为0的元素,但中间元素有填充。如果我们没有这个填充,我们将拥有与我们查看flex-shrink如何工作时相同的数学运算。

一个600px的父元素和三个宽度为0px的弹性项目。它们都有flex-grow: 1,因此它们以相同的速度增长,并且最终每个宽度都为200px但填充把一切都搞砸了。相反,我最终得到了三个内容大小为0的div,但中间元素具有padding: 1rem。这意味着它的内容大小为0,加上32px的填充。

我们有600 - 32 = 568px需要平均分配,而不是600px。所有 div 都想以相同的速度增长,因此568 / 3 = 189.3333px

就是这样!

但是……请记住,那是它们的内容大小,而不是总宽度!这导致两个div的宽度为189.333px,另一个的宽度为189.333px + 32 = 221.333px这是一个相当大的差异!

方法 2:flex-basis: 100%

我一直这样处理它

.flex-parent {
  display: flex;
}

.flex-parent > * {
  flex-basis: 100%;
}

我以为这方法很长时间了。实际上,这应该是我最终的解决方案,在展示了flex: 1不起作用之后。但当我写这篇文章的时候,我意识到它也遇到了同样的问题,只不过没有那么明显。我用肉眼没有注意到它。

所有元素都试图成为100%宽度,这意味着它们都想要匹配父元素的宽度,该宽度又是600px(在正常情况下,它通常要大得多,这会进一步减少可感知的差异)。

问题是,100%包含计算值中的填充(因为* { box-size: border-box; },为了这篇文章,我假设每个人都在使用它)。因此,外部div最终得到600px的内容大小,而中间div最终得到600 - 32 = 568px的内容大小。

当浏览器计算如何均匀分配空间时,它不是在查看如何将1800px均匀地压缩到600px空间中,而是在查看如何压缩1768px。此外,正如我们之前看到的,弹性项目不会以相同的量收缩,而是以相同的速度收缩!因此,有填充的元素在总收缩量上比其他元素略少。

这会导致.card最终宽度为214.483px,而其他元素则为192.75px。同样,这会导致宽度值不相等,尽管差异小于我们在flex: 1解决方案中看到的差异。

为什么 CSS Grid 是更好的选择

虽然所有这些都让人有点沮丧(甚至有点令人困惑),但它们都是有原因的。如果在 flex 参与时,边距、填充或边框改变了大小,那将是一场噩梦。

也许这意味着 CSS Grid 可能是这种非常常见的设计模式的更好解决方案。

我一直认为 flexbox 比 grid 更容易上手,但 grid 从长远来看给了你更多最终的控制权,但它更难理解。不过,最近我改变了主意,我认为**grid 不仅在这种情况下给了我们更好的控制权,而且它实际上也更直观**。

通常,当我们使用 grid 时,我们会使用grid-template-columns明确地声明我们的列。不过,我们不必这样做。我们可以使用grid-auto-flow: column让它像 flexbox 一样运行。

.grid-container {
  display: grid;
  grid-auto-flow: column;
}

就这样,我们最终获得了与在其中添加display: flex相同类型的行为。就像flex一样,列可能会不平衡,但使用 grid 的优势在于,**父元素对所有内容都具有完全控制权**。因此,与其像在 flexbox 中那样让项目的内容产生影响,我们只需要再添加一行代码就可以解决问题

.grid-container {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
}

我喜欢这一点都在父选择器上,我们不需要选择子元素来帮助获得我们想要的布局!

这里有趣的是fr单位是如何工作的。它们在规范中实际上被称为“弹性单位”,它们的工作方式与 flexbox 在划分空间方面一样。最大的区别在于:**它们会查看其他轨道以确定它们拥有多少空间**,而不会关注这些轨道内部的内容。

这才是真正的魔力。通过声明grid-auto-columns: 1fr,我们实际上是在说,“默认情况下,我的所有列都应该具有相等的宽度”,这就是我们从一开始就想要的!

但是小屏幕呢?

我喜欢这种方法,因为它非常简单

.grid-container {
  display: grid;
  gap: 1em;
}

@media (min-width: 35em) {
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
}

就这样,它完美地工作了。通过从一开始就声明 `display: grid`,我们可以包含 `gap` 来保持等间距,无论子元素是行还是列。

我还发现这比在媒体查询中更改 `flex-direction` 来获得类似结果更直观。尽管我很喜欢 flexbox(而且我认为它仍然有很好的用例),但声明 `flex-direction: column` 创建行,反之亦然,乍一看有点违反直觉。

当然,如果你喜欢不用媒体查询,没有什么能阻止你借助 `auto-fit` 将其提升到一个新的水平,这类似于 用 `flex-wrap` 设置一些东西(虽然不完全相同)

.grid-container {
  display: grid;
  gap: 1em;
  grid-template-columns: repeat(auto-fit, minmax(10em, 25em));
}

用 flexbox 实现

我意识到,我们可以通过将包含 `padding` 的元素嵌套在 flexbox 中来解决这个问题。自从我们开始使用浮动制作布局以来,我们一直在这样做,Bootstrap 真正将这种设计模式推广开来。

<div class="container">
  <div class="row">
    <div class="col"> <div class="">... </div>
    <div class="col"> <div class="element-with-padding">...</div> </div>
    <div class="col"> ... </div>
  </div>
</div>

这样做没有问题。它可以工作!但是浮动也起作用,近年来随着更好的解决方案的发布,我们已经停止使用它们进行布局。

我喜欢 grid 的原因之一是我们可以简化我们的标记。就像我们放弃了浮动以寻找更好的解决方案一样,至少我希望人们保持开放的心态,也许,也许,grid 可能是一个更好、更直观的解决方案,适用于这种设计模式。

当然,flexbox 仍然有它的位置

我仍然喜欢 flexbox,但我认为 **它真正的力量来自于我们想要依靠 flex 项目的内在宽度的时候**,例如导航,或者元素组,例如按钮或其他你想要并排放置的宽度不同的项目。

在这些情况下,这种行为是一个优势,它使不等项目之间的等间距变得轻而易举!flexbox 非常棒,我不会停止使用它。

但在其他情况下,当你发现自己在与 flexbox 的工作方式作斗争时,也许你可以转向 grid,即使它不是一个典型的“2D”网格,你通常不应该在那里使用它。

人们经常告诉我,他们很难理解 grid,因为它太复杂了,虽然它确实可以很复杂,但正如我们在这里看到的,它并不一定需要如此复杂。