[译] 以友好的方式来介绍 CSS 容器查询

332 阅读14分钟

很长一段时间以来,人们最需要的 CSS 功能一直是容器查询。这是我们的圣杯,也是 CSS 功能中最大的缺失。

令人高兴的是,容器查询终于到来了。近两年来,所有主流浏览器都支持它,我们的诉求已经得到满足!

现在我们可以把有条件的 CSS 应用在元素容器上,类似 CSS 媒体查询一样:

@container (min-width: 40rem) {
  .some-elem {
    font-size: 1.5rem;
  }
}

但奇怪的是,很少有人真正使用过容器查询。 与我交谈过的大多数开发人员即使尝试过,也只是做了一些简单的实验。我们终于有了一直想要的工具,但我们还没有将它使用起来。

造成这种情况的原因有很多,但我认为最大的原因之一是,大家对它的工作原理一直存在很多困惑。 容器查询不像媒体查询那样简单明了,为了有效地使用它,我们需要了解其中限制条件是什么,以及它是如何在限制条件下起作用。

我使用容器查询已经有几个月了,一旦你有了正确的心智模型,它确实非常好用。在这篇博文中,我们将解读所有这些内容,以便你能在工作中开始使用它!

基本概念

在过去的几十年中,我们进行响应式设计的主要工具一直是媒体查询。最常见的情况是,我们使用视口宽度来有条件地应用一些 CSS:

@media (min-width: 40rem) {
  .mobile-only {
    display: none;
  }
}

关于单位的说明 大多数开发人员在媒体查询中使用像素,但这在用户增大浏览器默认字体大小时,往往会产生较差的用户体验。在本博文中,我将在所有媒体/容器查询中专门使用 rem 单位。 你可以在我的博文“关于像素和可访问性的惊人真相”中了解更多信息。

媒体查询非常棒,但它只与全局属性有关,比如视口尺寸或操作系统的颜色主题。但是有时,我们需要根据本地属性(如元素容器的大小)有条件地应用 CSS。

例如,假设我们有一个 ProfileCard 组件,用于显示用户个人资料的关键信息:

image-20241114211135270

在上面这种情况下,每张 ProfileCard 都非常窄,因此信息会垂直堆叠成一列。

不过,在其他情况下,我们可能会有更多的可用空间。如果我们的 ProfileCard 能根据不同布局下的可用空间来自动切换样式,那岂不是很酷?

也许可以这样展示:

image-20241114211159754

某些情况下,如果我们的 ProfileCard 随视口大小缩放,我们可以使用媒体查询来实现这一点……但情况并非总是如此。

例如,我们可能要像这样将这些卡片排列在一个 flex 布局中:

css-container1

在这样的动态布局下,每个 ProfileCard 要根据可用空间的大小使用最合理的布局,这将与视口的大小无关!

显然,媒体查询并不能完成这个要求。相反,我们可以使用容器查询来解决这个问题,就像下面这样:

.child-wrapper {
  container-type: inline-size;
}

.child {
  /* 这里写较窄下的布局代码 */

  @container (min-width: 15rem) {
    /* 这里写较宽下的布局代码e */
  }
}

很酷吧?我使用原生 CSS 嵌套将 @container 规则放在 .child 块内,这样该元素的所有 CSS 声明都在同一块样式代码中。

等等,.child-wrapper 是怎么回事? 那个 container-type 属性是做什么用的?

对的,这就是事情变得有点复杂的地方。要使用容器查询,我们首先需要明确定义其容器,不然将会产生一些意想不到的后果。

这值得花几分钟时间来研究一下, 了解这一核心机制将使我们在今后的工作中少走弯路。让我们来谈谈容器查询的 “不可能实现问题”。

深入了解代码
如果你想查看 ProfileCard 组件的完整代码,可打开原文,在浏览器开发工具的 “源代码” 选项卡中查看。

解决一个不可能的问题

自从“响应式设计”出现以来,大约 20 年时间里,开发人员一直希望有容器查询功能。那么,为什么现在才引入容器查询呢?

在这 20 来年的时间里,CSS 工作组一直在说同样的话:实现容器查询是不可能的,不可能实现。

举个例子就更容易理解了。 考虑一下这种情况:

image-20241114211604520

如果你不熟悉 fit-content 关键字,你只要知道它是一个动态值,会根据元素的内容增大和缩小。如果在段落中添加或删除一些字,你会发现段落的大小会发生变化:

css-container2

现在,假设我们想根据容器的大小来增大粗体文本的 font-size,我们可能会这样做:

p {
  width: fit-content;

  @container (max-width: 10rem) {
    strong {
      font-size: 3rem;
    }
  }
}

这似乎可行……当我们的父标签 <p> 宽度为 10rem 或更小时, font-size: 3rem 这条 CSS 规则就会作用于其中的 <strong> 标签。

但是,让我们仔细想想。 当我们改变元素的 font-size 时,影响的不仅仅是字体的高度,它还会影响宽度,从而影响元素的宽度

css-container3

当我们的容器宽度为 10rem 或更小时,我们应用的样式(font-size: 3rem)会使容器的宽度超过 10rem,这时在 max-width: 10rem 规则下的 CSS 将不再满足条件!

接下来的演示展示了这种情况下会发生什么。 减少字数,直到容器小于 10rem,看看会发生什么:

闪烁警告

此演示会导致用户界面闪烁,闪烁的速度大约为每秒 2 次(相对较慢)。

css-container4

这真是让人匪夷所思,我花了一分钟才真正明白这里的问题所在。

当我们的 <section> 宽度小于 10rem 时,我们就满足了条件,一些 CSS 将生效,增大了字体大小。但这会导致我们的 <p> 元素盒子变大,从而使父 <section> 超过 10rem 的阈值!我们在容器查询中编写的 CSS 会影响容器本身,从而导致无限循环的 UI 闪烁。

这就是 CSS 工作组所说的无法解决的核心问题,也是我们直到现在才有容器查询的原因。

媒体查询不会遇到这个问题,因为媒体查询的条件是基于不可改变的全局状态。CSS 并不赋予我们改变视口宽度或用户运动偏好等权限。因此,我们无法在媒体查询中使其应用的相关规则失效。

在这里,我使用 fit-content 属性来演示这个问题,但问题不仅仅会出现在这个属性上。CSS 中的很多东西都是这样工作的,子元素会动态地影响父元素的大小。

不过这个棘手的问题的解决方案突然出现了,通过引入一个完全不相关的 API 来解决。

Containment API

几年前发布的 Containment API 允许我们指定 DOM 的某些片段是独立的,不会拓展出去并影响 DOM 的其他部分。

我不想在这里扯得太远,但这里有一个快速演示,可以显示这个 API 是如何工作的:

css-container5

默认情况下,我们的红框会随着子元素的大小变化而变化,这正是会给容器查询带来问题的动态行为。

通过在父元素上设置 contain: size,我们切断了这种联系。因此,容器的高度不再取决于其内容。如果不指定明确的 height,容器将折叠为 0px(除了 padding)。

Containment API 的设计是考虑到了性能优化。CSS 是一种非常动态的语言,这意味着当元素发生变化时,浏览器往往需要做大量的工作。例如:当我们调整上面图像的高度时,不仅会影响演示中的元素,还会影响本篇文章后面的所有内容。像这样的段落在每次尺寸变化时都会上下移动,导致布局重新计算和重新绘制。

因此,如果我们知道某个元素是独立的,不会影响其他任何元素,我们就可以使用 contain 属性让浏览器跳过某些计算。对于 React 开发人员来说,这里有一个很有用的类比:它有点像 React.memo()。对于已经确认的不必要的重新计算,我们可以使用 contain 来跳过。

老实说,我并不经常使用 contain 功能。现代浏览器已经进行了大量优化,会跳过明显不必要的计算。给我的感觉是,contain 主要用于边缘情况,或对性能要求极高的情况。

但这个 API 为容器查询提供了最后的基础!这就是我们解决上述问题的方法,通过指定父元素不对其内容作出动态响应,该 API 使我们能够 “短路” 上面的无限循环。

有关 Containment API 的更多信息

在这里,我们只是对包含 API 蜻蜓点水般地做了介绍。如果你想深入了解,我建议你看看 Rachel Andrew 的这篇文章:Helping Browsers Optimize With The CSS Contain Property

老实说,我不认为大多数前端开发人员需要使用这个 API,但如果你对它感到好奇,这是我找到的最好的入门资源。

我们的第一个容器查询

让我们来编写一个 “hello world” 容器查询:

image-20241114221251404

首先,我们声明 <section> 元素是一个容器。这样,它的任何后代都可以把它作为衡量标准,在满足特定条件时应用 CSS。

接下来,我们创建一个容器查询,选择容器中的 <p>,并在容器宽度为 12rem 或以下时调整其样式。当满足该条件时,将应用该块中的 CSS 规则,文字将变粗并变红。

如果你是在大屏幕设备上查看此内容,您可以亲自体验一下:通过单击并拖动分隔线,或聚焦分割线并使用左/右箭头键来调整 “预览区” 窗格的大小。

css-container6

不过,这种实现方式存在一个问题。当我们给容器添加一些外观样式时,问题就显现出来了:

image-20241114221406980

就像上图中看到的那样,父元素不再对其子元素做出动态响应。它的大小不再变化以适应其中的文本,而是塌陷得什么都没有;我们之所以能看到背景色,完全是因为这个元素碰巧有一些 padding

当我们设置 container-type: size 时,我们告诉浏览器这个元素的布局不依赖于它的子元素。这样就避免了我们之前看到的无限循环,但也打破了我们对 CSS 工作原理的一个核心假设!

我们通常不会想到这一点,但在 web 中,宽度和高度是有本质区别的:

  • 就宽度而言,元素倾向于扩展,填满父元素提供的空间
  • 在高度方面,元素往往会围绕其子元素收缩。

考虑一个没有应用 CSS 的空 <div>,它的高度为 0px,但宽度不会是 0px。无论是否有任何内容,它都会增长到填满整个水平空间。

当我们设置 container-type: size 时,我们会告诉 CSS 忽略其内容,这意味着它将不再使用默认规则,高度会折叠为零!(宽度仍然是填满父元素,不过如果你是 inline-block 盒子的话,宽度就会依赖子元素,此时宽度将变成 0。)

幸运的是,我们可以使用 container-type 属性的另一个值,即 inline-size

image-20241114222535246

这里的 inline-size 指的是内联尺寸,通常是宽度。

从根本上说,这时的元素宽度并不取决于其内容。因此,它可以被其后代用作衡量标准。相比之下,元素的高度则保持默认行为,即根据其内容增大或缩小。

容器查询的黄金法则是,如果你使用到 min-width/max-width,那么宽度将不受子元素影响,高度同理。container-type: inline-size 允许我们在容器查询中使用 min-width/max-width 条件,但不能使用 min-height/max-height 条件。

(感谢 Miriam Suzanne 提出这条黄金法则。Miriam 也是解决容器查询不可能问题的人,也是我们今天拥有容器查询的主要原因。她是最棒的。)

逻辑属性

你可能会想,为什么 CSS 规范的作者要使用 inline-size 这样的花哨术语来增加我们的记忆难度呢?为什么不采用更直观的方式,比如container-type: width

逻辑属性的原理在于,它们是抽象的,因此可以根据用户的语言进行动态变化。margin-inline-start 在英语中会应用一些左侧边距,但在乌尔都语等从右到左的语言中则会翻转为右侧边距

widthmargin-left 等传统属性可能不会被弃用或移除,但在涉及容器查询等全新语言特性时,它们将完全使用逻辑属性。

具体到 container-type: inline-size,我认为将其视为宽度是合理的。即使是传统上垂直书写的语言(如中文),在 wewb 上通常也是水平显示的:

image-20241115070155672

中国最受欢迎的网站之一 Baidu.com 是横着布局的,而不是竖布局的。

浏览器支持

所有 4 种主要浏览器都支持容器查询,从下面的版本开始:

  • Safari 16,2022 年 9 月推出
  • Chrome/Edge 105,2022 年 8 月推出
  • 火狐 110,2023 年 2 月推出

当我在 2024 年 11 月写这篇文章时,容器查询支持率已达到约 91%,下面是兼容情况:

image-20241115070450392

我还需要说明的是,本博文中的示例使用了CSS 嵌套。尽管 CSS 嵌套已经成为几乎所有 CSS 预处理器/框架的标准功能,但它最近才成为 CSS 的原生功能。如果你没有使用任何 CSS 工具,也可以查看浏览器对 CSS 嵌套的本地支持

一个新的响应式世界

正如我在引言中所说,容器查询的利用率之低令人惊讶。在我接触过的开发人员中,很少有人真正将它们集成到自己的工具包中。

这篇博文深入探讨了其中一个核心原因(它很复杂),但还有另一个重要原因,一个阻碍因素。

作为开发人员,我们要实现设计师为我们准备的设计稿。这一直是一个来来回回的过程,是设计师想要什么和开发人员能实现什么之间的协商过程。近 20 年来,我们一直明确表示,“响应式设计” 仅限于视口。

我认为大多数设计师甚至都没有意识到他们现在拥有这种令人兴奋的新功能。我们的工作就是与他们分享这些发展,让他们能够在自己的设计中使用它!

在我参与的项目(本博客和课程平台)中,我既是开发者又是设计者,我没有任何借口,夏天重新设计博客时,我有意识地使用了容器查询。一旦我开始用容器来思考,我就不断看到使用容器的机会!

在未来几周内,我将发布一篇后续博文,介绍我是如何使用容器查询的,并展示本博客中的几个示例。如果你想在我发布博文时收到通知,可以订阅我的 newsletter。