CSS 容器样式查询有什么用?


在响应式 CSS 领域,我们长期依赖媒体查询,但它们也有局限性,并且逐渐将重点从响应性转向了无障碍性。这就是CSS容器查询的用武之地。它们彻底改变了我们处理响应性的方式,将视角从基于视口的心态转向更考虑组件上下文的方式,比如组件的大小或内联大小。

通过元素的尺寸进行查询是CSS容器查询的两大功能之一,我们称之为容器尺寸查询,以此与基于组件当前样式的查询功能区分开来,我们称之为容器样式查询。

现有的容器查询内容主要集中在容器尺寸查询上,截至本文撰写时,它们在全球浏览器的支持率达到 90%。而样式查询目前仅在 Chrome 111+Safari 技术预览版中通过启用特性标志可用。

关于CSS容器样式查询,为什么要使用它们?答案通常是复杂的,可能取决于具体情况。但我想更深入地探讨样式查询,不在语法层面,而是它们具体解决了什么问题,以及在浏览器支持后我们会在哪些用例中使用它们。

为什么需要容器查询

仅从响应式设计的角度来看,媒体查询在某些方面确实有所不足,主要原因是它们在应用样式时只考虑视口大小,而不考虑元素父级的大小或所包含的内容,这使得它们缺乏上下文意识。

这通常不是问题,因为我们只有一个主元素,它在 x 轴上不与其他元素共享空间,所以我们可以根据视口尺寸来设计内容。然而,如果我们将一个元素放入一个较小的父级中并保持相同的视口,当内容变得拥挤时,媒体查询不会生效。这迫使我们编写和管理一整套针对特定内容断点的媒体查询。

容器查询打破了这一限制,允许我们查询远超视口尺寸的内容。

网页浏览器中组件的图示,展示如何查询其尺寸和视口尺寸。

当媒体查询只关注视口时,它们忽略了视口中元素的重要细节,特别是它们的尺寸

容器查询一般是如何工作的

容器尺寸查询的工作原理类似于媒体查询,但它允许我们根据容器的属性和计算值应用样式。简而言之,它允许我们基于元素的计算宽度或高度进行样式更改,而不管视口大小。这种功能以前只能通过JavaScriptjQuery实现,

1
2
3
4
5
6
$(document).ready(function(){    
var contentHeight = $('.content').outerHeight();
if ( contentHeight < 700) {
$( "#footer" ).css( "position", "absolute" );
};
});

容器查询除了可以查询元素的尺寸外,还可以查询其样式。换句话说,容器样式查询可以查看和跟踪元素的属性,并在这些属性满足某些条件时将样式应用于其他元素,比如当元素的background-color设置为 hsl(0 50% 50%) 时。

这就是我们所说的CSS容器样式查询。它是与 CSS 容器尺寸查询在同一 CSS 容器模块第 3 级规范中定义的一个提议特性,目前没有任何主流浏览器支持,因此样式查询和尺寸查询之间的区别可能有点混淆,因为我们实际上是在讨论同一大类下的两个相关功能。

我们最好先回过头来了解一下“容器”是什么。

容器

元素的容器是任何具有包含上下文的祖先;它可以是元素的直接父元素,也可以是祖父元素或曾祖父元素。

包含上下文意味着某个元素可以用作查询的容器。非正式地说,有两种类型的包含上下文:尺寸包含和样式包含。

尺寸包含意味着只要某个元素被注册为容器,我们就可以使用容器尺寸查询来查询和跟踪该元素的尺寸(即长宽比、块大小、高度、内联大小、方向和宽度)。跟踪元素的尺寸需要在客户端进行一些处理。处理一两个元素轻而易举,但如果我们必须不断跟踪所有元素的尺寸——包括调整大小、滚动、动画等——这将对性能造成巨大影响。这就是为什么没有任何元素默认具有尺寸包含的原因,当我们需要时,必须使用 CSS container-type 属性手动注册尺寸查询。

另一方面,样式包含允许我们通过容器样式查询查询和跟踪容器特定属性的计算值。目前,我们只能检查自定义属性,例如 –theme: dark,但很快我们可以检查元素的计算背景颜色和显示属性值。与尺寸包含不同,我们是在浏览器处理之前检查原始样式属性,从而减轻了性能负担,使所有元素默认具有样式包含。

你明白了吗?尺寸包含是我们在元素上手动注册的,而样式包含是所有元素的默认行为。不需要注册样式容器,因为所有元素默认就是样式容器。

那么我们如何注册一个包含上下文?最简单的方法是使用 container-type 属性。container-type 属性会赋予元素一个包含上下文,它接受的三个值——normalsize inline-size——定义了我们可以从容器中查询哪些属性。

1
2
3
4
/* Size containment in the inline direction */
.parent {
container-type: inline-size;
}

这个示例正式建立了尺寸包含。如果我们什么都不做,.parent 元素已经是具有样式包含的容器。

尺寸控制

最后一个示例展示了基于元素的 inline-size(这实际上就是宽度)的尺寸包含。当我们谈论网页上的正常文档流时,我们谈论的是元素在内联方向和块方向上的流动,分别对应于水平书写模式下的宽度和高度。如果我们将书写模式旋转为垂直,那么“内联”将指代高度,“块”将指代宽度。

考虑以下 HTML 代码:

1
2
3
4
5
<div class="cards-container">
<ul class="cards">
<li class="card"></li>
</ul>
</div>

我们可以为 .cards-container 元素提供一个内联方向的包含上下文,这样当它的宽度变得太小时,可以对其后代进行更改以在当前布局中正确显示所有内容。我们保持与普通媒体查询相同的语法,但将@media替换为 @container

1
2
3
4
5
6
7
8
9
.cards-container {
container-type: inline-size;
}

@container (width < 700px) {
.cards {
background-color: red;
}
}

容器语法与媒体查询几乎相同,因此我们可以使用 andor not 运算符将不同的查询链接在一起以匹配多个条件。

1
2
3
4
5
@container (width < 700px) or (width > 1200px) {
.cards {
background-color: red;
}
}

尺寸查询中的元素会查找最近的具有尺寸包含的祖先,以便对 DOM 中更深层次的元素进行更改,如我们之前示例中的.card元素。如果没有尺寸包含上下文,则 @container 规则不会生效。

1
2
3
4
5
6
7
8
/* 👎 
* Apply styles based on the closest container, .cards-container
*/
@container (width < 700px) {
.card {
background-color: black;
}
}

只查找最近的容器会很混乱,所以最好使用 container-name 属性命名容器,然后在 @container 规则之后指定我们要跟踪的容器。

1
2
3
4
5
6
7
8
9
10
.cards-container {
container-name: cardsContainer;
container-type: inline-size;
}

@container cardsContainer (width < 700px) {
.card {
background-color: #000;
}
}

我们可以使用简写属性 container 在单个声明中设置容器名称和类型:

1
2
3
4
5
6
7
.cards-container {
container: cardsContainer / inline-size;

/* 等效于: */
container-name: cardsContainer;
container-type: inline-size;
}

我们可以设置的另一个 container-typesize,它的工作原理与inline-size完全相同,只是包含上下文既包括内联方向也包括块方向。这意味着我们还可以查询容器的高度尺寸以及宽度尺寸。

1
2
3
4
5
6
7
8
9
10
11
12
13
/* 当容器宽度小于 700px 时 */
@container (width < 700px) {
.card {
background-color: black;
}
}

/* 当容器高度小于 900px 时 */
@container (height < 900px) {
.card {
background-color: white;
}
}

值得注意的是,如果两个独立的(未链接的)容器规则匹配,最具体的选择器将获胜,这与CSS层叠的工作方式一致。

到目前为止,我们已经了解了CSS容器查询的最基本概念。我们定义了在元素上需要的包含类型(我们具体查看了尺寸包含),然后相应地查询该容器。

容器样式查询

container-type 属性接受的第三个值是 normal,它在元素上设置样式包含。inline-size 和 size 在所有主要浏览器中都是稳定的,但 normal 是较新的,目前只有少量支持。

我认为 normal 有点特别,因为我们不必在元素上显式声明它,因为所有元素默认都是具有样式包含的样式容器。你可能永远不会自己写出来或在实际中看到它。

1
2
3
4
.parent {
/* 没必要 */
container-type: normal;
}

如果你确实写了它或看到了它,可能是为了撤销其他地方声明的尺寸包含。但即使这样,也可以使用全局的 initial 或 revert 关键字重置包含。

1
2
3
4
5
6
.parent {
/* All of these (re)set style containment */
container-type: normal;
container-type: initial;
container-type: revert;
}

让我们看一个简单且有点人为的示例来说明这一点。我们可以在容器中定义一个自定义属性,比如 --theme

1
2
3
.cards-container {
--theme: dark;
}

从这里开始,我们可以检查容器是否具有该属性,如果有,则将样式应用于其后代元素。我们不能直接为容器设置样式,因为这可能会引发无限循环,导致不断更改样式和查询样式。

1
2
3
4
5
6
7
8
9
.cards-container {
--theme: dark;
}

@container style(--theme: dark) {
.cards {
background-color: black;
}
}

看到了那个style()函数吗?将来,我们可能希望通过样式查询检查元素是否具有 max-width: 400px,而不是在尺寸查询中检查元素的计算值是否大于 400px。这就是我们使用 style() 包装器来区分样式查询和尺寸查询的原因。

1
2
3
4
5
6
7
8
9
10
11
12
13
/* 尺寸查询 */
@container (width > 60ch) {
.cards {
flex-direction: column;
}
}

/* 样式查询 */
@container style(--theme: dark) {
.cards {
background-color: black;
}
}

两种类型的容器查询都会查找具有相应包含类型的最近祖先。在 style() 查询中,它总是父元素,因为所有元素默认都有样式包含。在我们的示例中,.cards 元素的直接父元素是.cards-container元素。如果我们想查询非直接父元素,我们需要使用 container-name 属性在查询时区分容器。

1
2
3
4
5
6
7
8
9
10
.cards-container {
container-name: cardsContainer;
--theme: dark;
}

@container cardsContainer style(--theme: dark) {
.card {
color: white;
}
}

令人困惑的事情

样式查询是全新的,并且在 CSS 中从未见过,所以当我们理解它们时,难免会有一些令人困惑的特性——有些是完全有意且深思熟虑的,有些可能是无意的,并可能在规范的未来版本中更新。

样式包含和尺寸包含不是互斥的

一个有意的优点是,一个容器可以同时具有尺寸和样式包含。没有人会责怪你认为尺寸和样式包含是互斥的,所以将元素设置为 container-type: inline-size 似乎会使所有样式查询无效。

然而,另一个关于容器查询的有趣之处在于,元素默认具有样式包含,并且实际上没有办法移除它。看一下下一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.cards-container {
container-type: inline-size;
--theme: dark;
}

@container style(--theme: dark) {
.card {
background-color: black;
}
}

@container (width < 700px) {
.card {
background-color: red;
}
}

看到了吗?即使我们明确将 container-type 设置为 inline-size,我们仍然可以通过样式查询元素。这乍一看似乎是矛盾的,但考虑到样式查询和尺寸查询是独立计算的,这实际上是有意义的。这种方式更好,因为两种查询不一定会相互冲突;样式查询可以根据自定义属性更改元素的颜色,而容器查询在元素变得太小时更改元素的布局方向。

如果活动的样式查询和尺寸查询配置为应用冲突的样式,根据级联规则,最具体的选择器获胜,因此元素会有黑色背景色,直到容器小于700px时,尺寸查询(在此示例中比上一个示例更具体)生效。

未命名样式查询会检查每个祖先是否匹配

在最后一个示例中,你可能注意到了样式查询中的另一个奇怪现象:.card 元素在一个未命名的样式查询中,因此由于所有元素都有样式包含,它应该查询其父元素 .cards。然而,.cards 元素没有任何 --theme 属性;相反,.cards-container 元素有这个属性,但样式查询仍然是生效的!

1
2
3
4
5
6
7
8
9
10
.cards-container {
--theme: dark;
}

@container style(--theme: dark) {
/* 这个仍然是活跃的! */
.card {
background-color: black;
}
}

这是怎么回事?未命名的样式查询不是只查询它们的父元素吗?其实不完全是。如果父元素没有自定义属性,那么未命名的样式查询会查找更高层级的祖先。如果我们在父元素上添加一个具有另一个值的 --theme 属性,你会发现样式查询会使用该元素作为容器,并且查询不再匹配。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.cards-container {
--theme: dark;
}

.cards {
--theme: light; /* 现在这是查询的匹配容器 */
}

/* 这个查询不再生效! */
@container style(--theme: dark) {
.card {
background-color: black;
}
}

我不知道这种行为有多么有意,但它显示了在使用未命名容器时事情可能变得多么混乱。

元素默认是样式容器,但样式查询不是默认的

样式查询是默认的容器类型,而尺寸查询是默认的查询类型,这似乎有点不匹配,因为我们必须显式声明一个 style() 函数来编写默认类型的查询,而没有对应的 size() 函数。这不是对规范的批评,而是 CSS 中需要记住的一件事。

容器样式查询有什么用?

乍一看,样式查询似乎是一项开辟了无限可能性的功能。但在实际操作后,你可能并不清楚它们能解决什么问题——至少在我花时间研究之后是这样。我见过或想到的所有用例都不太实用,最明显的那些问题已经有了成熟的解决方案。基于样式响应其他样式来编写 CSS 的新方式看起来很自由(编写 CSS 的方式越多越好!),但如果我们在命名方面已经遇到了困难,想象一下管理和维护这些状态和样式会有多困难。

我知道这些都是大胆的声明,但它们并非没有根据,所以让我详细说明。

最有效的用例

正如我们讨论的那样,目前我们只能用样式查询自定义属性,因此最清晰的用途是存储状态位,这些状态可以在改变时用于更改 UI 样式。

假设我们有一个网络应用程序,可能是一个包含顶级玩家排行榜的游戏。排行榜中的每一项都是一个基于玩家的组件,需要根据该玩家在排行榜上的位置进行不同的样式设计。我们可以用金色背景为第一名的玩家设计,用银色为第二名设计,用青铜色为第三名设计,而其余玩家都使用相同的背景颜色。

假设这个排行榜不是静态的。随着分数的变化,玩家的排名也在变化。这意味着我们很可能使用服务器端渲染(SSR)框架来保持排行榜与最新数据同步,因此我们可以通过内联样式将数据插入 UI,并将每个位置作为自定义属性 --position: number

以下是我们可能的标记结构:

1
2
3
4
5
6
7
8
9
10
11
12
<ol>
<li class="item-container" style="--position: 1">
<div class="item">
<img src="..." alt="Roi's avatar" />
<h2>Roi</h2>
</div>
</li>
<li class="item-container" style="--position: 2"><!-- etc. --></li>
<li class="item-container" style="--position: 3"><!-- etc. --></li>
<li class="item-container" style="--position: 4"><!-- etc. --></li>
<li class="item-container" style="--position: 5"><!-- etc. --></li>
</ol>

现在,我们可以使用样式查询来检查当前项的位置,并为排行榜应用相应的闪亮背景。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
.item-container {
container-name: leaderboard;
/* 不需要应用 container-type: normal */
}

@container leaderboard style(--position: 1) {
.item {
background: linear-gradient(45deg, yellow, orange); /* 金色 */
}
}

@container leaderboard style(--position: 2) {
.item {
background: linear-gradient(45deg, grey, white); /* 银色 */
}
}

@container leaderboard style(--position: 3) {
.item {
background: linear-gradient(45deg, brown, peru); /* 青铜色 */
}
}

两种排行榜 UI。一个在应用样式查询之前,一个在应用样式查询之后

当元素的样式匹配特定条件时,我们可以将样式应用到该元素的子元素和后代。

我们可以通过 CSS 类和 ID 实现相同的效果

我见过的大多数容器查询指南和教程都使用类似的示例来演示一般概念,但无论样式查询多么酷炫,我总觉得我们可以使用类或 ID 并通过更少的样板代码实现相同的结果。我们可以直接将状态作为类传递,而不是作为内联样式。

1
2
3
4
5
6
7
8
9
10
<ol>
<li class="item first">
<img src="..." alt="Roi's avatar" />
<h2>Roi</h2>
</li>
<li class="item second"><!-- etc. --></li>
<li class="item third"><!-- etc. --></li>
<li class="item"><!-- etc. --></li>
<li class="item"><!--etc. --></li>
</ol>

或者,我们可以将位置编号直接放在id中,这样我们就不需要将数字转换为字符串:

1
2
3
4
5
6
7
8
9
10
<ol>
<li class="item" id="item-1">
<img src="..." alt="Roi's avatar" />
<h2>Roi</h2>
</li>
<li class="item" id="item-2"><!-- etc. --></li>
<li class="item" id="item-3"><!-- etc. --></li>
<li class="item" id="item-4"><!-- etc. --></li>
<li class="item" id="item-5"><!--etc. -></li>
</ol>

这两种方法都比容器查询方法留下更干净的 HTML。使用样式查询,我们必须将元素包装在一个容器内——即使在语义上不需要这样做——因为容器(理所当然地)不能对自己进行样式设置。

CSS 方面,我们的代码也更简洁:

1
2
3
4
5
6
7
8
9
10
11
#item-1 {
background: linear-gradient(45deg, yellow, orange);
}

#item-2 {
background: linear-gradient(45deg, grey, white);
}

#item-3 {
background: linear-gradient(45deg, brown, peru);
}


使用 ID 作为样式钩子通常被视为不推荐的做法,但这只是因为 ID 必须在页面上唯一。在这种情况下,页面上永远不会有多个第一名、第二名或第三名玩家,因此 ID 在这种情况下是安全且合适的选择。不过,我们也可以使用其他类型的选择器,比如 data-* 属性。

有一种可能会为样式查询增加很多价值的特性是:查询样式的范围语法。这是一个开放的特性,由 Miriam Suzanne 在 2023 年提出,理念是通过范围比较来查询数值,就像大小查询一样。

假设我们想为排行榜前十名的其余玩家应用浅紫色背景。与其为从第四到第十的位置添加单独的查询,我们可以添加一个检查范围值的查询。语法显然现在还不在规范中,但为了说明问题,可以假设它看起来像这样:

1
2
3
4
5
6
/* Do not try this at home! */
@container leaderboard style(4 >= --position <= 10) {
.item {
background: linear-gradient(45deg, purple, fuchsia);
}
}

在这个虚构的示例中,我们:

  • 跟踪一个名为 leaderboard 的容器,
  • 对容器进行 style() 查询,
  • 评估 --position 自定义属性,
  • 查找自定义属性设置为大于或等于 4 且小于或等于 10 的值的条件。
  • 如果自定义属性在这个范围内,我们将玩家的背景颜色设置为从紫色到品红色的线性渐变。

这非常酷,但如果这种行为很可能在现代框架中使用组件来完成,比如React Vue,我们也可以在 JavaScript 中设置一个范围,当条件满足时切换 .top-ten 类。

将样式逻辑与逻辑逻辑分开

到目前为止,样式查询似乎不是我们查看的排行榜用例的最方便解决方案,但我不会仅仅因为我们可以通过 JavaScript 实现相同的效果就认为它们没用。样式查询在与 UI 框架配合使用时可能最有用,在这种情况下,我们可以轻松地在组件中使用 JavaScript

然而,可以提出这样的观点:在组件内部实现样式逻辑是麻烦的。也许我们应该将样式相关的逻辑与其余的逻辑逻辑(即组件内部的状态变化,如条件渲染或 React 中的 useStateuseEffect 函数)分开。样式逻辑将是我们用来添加或删除类名或ID的条件检查,以改变样式。

回到我们的排行榜示例,检查玩家的位置以应用不同的样式将是样式逻辑。我们确实可以使用 JavaScript 检查玩家的排行榜位置是否在四到十之间,然后以编程方式添加 .top-ten 类,但这意味着将我们的样式逻辑泄漏到组件中。在 React 中(为了熟悉,但其他框架也类似),组件可能看起来像这样:

1
2
3
4
5
6
7
8
const LeaderboardItem = ({position}) => {
return (
<li className={`item ${position >= 4 && position <= 10 ? "top-ten" : ""}`} id={`item-${position}`}>
<img src="..." alt="Roi's avatar" />
<h2>Roi</h2>
</li>
);
};

除了这段代码看起来很丑陋外,在 JSX 中添加样式逻辑可能会变得很麻烦。与此同时,样式查询可以将 --position 值传递给样式,并在 CSS 中直接处理逻辑:

1
2
3
4
5
6
7
8
const LeaderboardItem = ({position}) => {
return (
<li className="item" style={{"--position": position}}>
<img src="..." alt="Roi's avatar" />
<h2>Roi</h2>
</li>
);
};

这种方法更简洁,更接近样式查询的价值主张。