初探CSS容器查询
CSS的原生嵌套、作用域(@scope
)、级联层(@layer
)和容器查询(@container
)被称为 下一代 CSS,并且在整个社区得到热议。比如,刚举办的 W3C TPAC 会议就专门有一个这方面的话题。尤其是容器查询(@container
),更令人兴奋。从基于页面的响应式设计到基于容器的响应式设计的转变,将对Web设计生态系统的发展产生巨大的影响。因为,容器查询将使单个元素有可能根据局部的环境进行调整,而不是依赖整个视窗的尺寸。从今天开始,我将和大家一起进入容器查询的世界,和大家一起了解为什么需要容器查询,它们将来何使Web设计和开发变得更轻松,最重要的是,容器查询将现更强大的组件和布局,尤其是响应式的布局。
容器查询的发展历程
自 2010 年 @Ethan Marcotte 首次提出 Web 响应式设计(RWD) 的设计理念(概念),Web设计就进入现代Web布局时代。开发者可以根据 CSS 媒体查询特性 (通常是视窗宽度、媒体设备特性等)来为Web页面定制不同的表现形式,比如可以根据用户浏览内容的设备特性来呈现不同的布局、不同的字体大小和不同的图片等。
但对于 Web 设计师或Web开发者来说,在现代Web设计或布局中仍然缺少一特性,页面的设计不能够响应其容器的宽度(或其他特性)。也就是说,如果Web开发者能够根据容器宽度来改变UI样式,那就更好了。容器查询将在很大程度上帮助 Web 开发者更好的完成他们的工作,在为Web开发基于组件代码时,其遗漏(容器查询特性的缺失)是一个巨大的限制。
正因此,有关于容器查询的特性在社区中的探讨就没有停止过。
早在 2019 年底,@Zach Leatherman 在寻找容器查询起源时,找到的最早有关于容器查询的解决方案是 @Andy Hume 的基于 JavaScript 的选择器查询和响应式容器的解决方案。
2015 年, @Mat ‘Wilto’ Marquis 在响应式图片社区小组引入了 <picture>
元素,将响应式图片带到了响应式 Web 设计的世界,他在《Container Queries: Once More Unto the Breach》一文中概述了元素查询的挑战和使用案例演示了容器查询的特性。
然后,在2017年,@Ethan Marcotte写了一篇关于容器查询相关的文章,并提出了这样的看法:
在他最初关注的响应式设计的文章之后的几年里,Web设计师和开发人员的工作越来越集中在组件上,而不是整个页面,这使得媒体查询不那么理想。
从那时起,虽然有很多人主张使用媒体查询,但容器查询向前推进的速度还是不够理想。@L. David Baron在《Thoughts on an implementable path forward for Container Queries》中简明扼要地解释了容器查询向前推进慢的问题出在哪?
容器查询要求样式取决于组件的大小,但考虑到 CSS 的工作原理,组件中的样式会影响其大小。任意打破这个循环,既会产生奇怪的结果,又会干扰浏览器的工作,还会增加浏览器优化的成本。
除了 @David Baron 之外,2018年6月,@Greg Whitworth在荷兰阿姆斯特丹举办的 CSS Day + UX Special 活动上的主题分享《Over the moon for container queries》中也解释了容器查询在Web平台上推进慢的相关原因。更重要的是,@Greg Whitworth还提供了使用新的 JavaScript API 和 CSS 的新技术来实现容器查询的特性。@David Barrrron 也提出了一个可以避免这种困境的策略,更重要的是 @Miriam Suzanne 在 @David Baron 的策略基础上提出了 @container
方法。
@container
方法通过对被查询的元素应用大小和布局的限制来实现。任何具有尺寸和布局限制的元素都可以通过一个新的@container
规则进行查询,其语法与现有的媒体查询类似。
这个提议已经被 W3C 的 CSS 工作组采纳,并已经添加到 CSS Containment Level 3 模块中。有关于该功能的相关问题和各网格平台推进进度,可以点击这里查阅。
到目前为止,@container
已经是 Chrome 的一个实验性属性,即开启浏览器实验标记,就可以使用@container
。
快速体验容器查询特性
虽然 @container
还只是一佧实验性属性,但在社区中有关于这方面的案例已经非常多。@Miriam Suzanne 在 Codepen 上整理了一个容器查询案例集合:
来看两个关于 @container
的具体案例,@Una Kravets 使用容器查询写的有关于一个产品卡片的案例,卡片在网站不同位置使用的是同一个 UI 组件,但UI效果是有差异的:
示例效果如下:
再来看一个 @Jhey 使用容器查询特性写的案例,当容器的高度和宽度都达到一定的阈值时,示例中的衬衫尺寸会改变:
拖动右下角的滑块,改变容器宽高:
是不是对容器查询特性有点感觉了呀!是不是很好奇,想一探其究竟!
开启浏览器的容器查询特性
到目前为止,容器查询特性(@container
)还只是Chrome Canary实验性特性。在Chrome Canary 浏览器的URL地址栏中输入chrome:://flags
,在搜索框中输入#enable-container-queries
,把该选项设置为Enabled
。
重启浏览器,就开启了容器查询的功能。我们就可以使用 @container
根据元素的父容器的大小来确定其样式。
@container
的 API 并不稳定,而且会有语法变化。如果试用该 API 的话,你可能会遇到一些 Bug。如果你在试用的时候,碰到相应的浏览器引擎的 Bug,可以给相应浏览器(Chrome、Firefox 和 Safari)报告这些 Bug。另外,到目前为止,除了 Chrome Canary浏览器,你还可以直接在 Chrome 浏览器中开启@container
实验性特性,也可以正常使用容器查询特性。
容器查询调试
自 Chrome 93浏览器开始,开发者调试工具推出了一个新功能,可以在开发者调试工具中查看和编辑CSS容器查询。CSS 中新增的容器查询特性,为响应式设计提供了一个更加动态的方法(@container
的工作方式与 @media
的媒体查询类似)。然而,@container
不是查询视窗大小和用户代理的信息,而是查询符合某些标准的祖先容器。
在 Chrome 浏览器的开发者工具的元素选项中,点击一个带有 @container
的 DOM 元素,开发者工具现在会在样式面板中显示 @container
信息。点击它来编辑尺寸。样式选项也会显示相应的容器信息。将鼠标悬停在它上面,以突出页面上的容器元素,并检查容器的大小。点击它来选择容器元素。
或者查看下面这个视频,仔细看看开发者调试工具的右上角。当浏览器视窗大小调整时,可以看到样式选项面板自动自动更新,显示出被应用的容器查询。在开发者工具中,你也可以直接编辑容器查询,并跳到相关的容器。
容器查询快速入门
接下来,我们使用 @container
来实现下图这样的一个效果:
这是一个卡片组件(Card
),并且卡片宽度不一样时 UI 的效果略有差异,但构建这个卡片的元素是相同的:
构建这样的卡片组件,所需要的 HTML 结构大致像下面这样:
<!-- HTML -->
<div class="card">
<img src="" class="card__thumbnail" alt="" />
<div class="card__badge">Badge</div>
<h3 class="card__title">Card Title</h3>
<p class="card__describe">Card Describe</p>
<button class="card__button">Button</button>
</div>
在没有容器查询特性之前,构建这几个卡片,需要同样的HTML结构,并且使用 BEM 的方式,在.card
容器上添加一修饰符,比如:
- ①:
class="card"
(默认) - ②:
class="card card--small"
(卡片宽度大于400px
) - ③:
class="card card--medium"
(卡片宽度大于550px
) - ④:
class="card card--large"
(卡片宽度大于700px
)
CSS 容器查询将如何帮助我们?
容器查询最大的特性是 容器是被查询的元素,但容器查询中的样式只影响容器后代元素!换句话说,你可以把任何一个元素定义成为一个容器,然后,容器查询将允许在不同容器尺寸中为其后代元素定义不同的样式规则。因此,我们需要在上面的 HTML 外面添加一个容器,比如.container
:
<div class="container">
<div class="card">
<img src="" class="card__thumbnail" alt="" />
<div class="card__badge">Badge</div>
<h3 class="card__title">Card Title</h3>
<p class="card__describe">Card Describe</p>
<button class="card__button">Button</button>
</div>
</div>
在使用容器查询时首要的就是创建一个容器。即,在容器元素上显式使用 contain
属性声明该元素为一个容器。
.container {
contain: layout inline-size style;
}
contain
属性会告诉浏览器只对受影响的区域进行重绘,而不是整个页面。
contain
是 CSS Containment Module (目前为止主要为分为 Level 1 和 Level 2中的一个属性(另一个是 content-visibility
)。contain
属性允许我们指定特定的 DOM 元素和它的子元素,让它们能够独立于整个 DOM 树结构之外。目的是能够让浏览器有能力只对部分元素进行重绘、重排,而不必每次针对整个页面。这对于 Web 性能来说是非常有利的。
contain
属性可接受的值有:
contain: none | strict | content | [ size || layout || style || paint ]
其中none
是contain
的默认值,在实际使用的时候,我们可以通过contain
的其他五个值中的某一个来规定元素以何种方式独立于文档树:
layout
:该值表示元素的内部布局不受外部的任何影响,同时该元素以及其内容也不会影响以上级paint
:该值表示元素的子级不能在该元素的范围外显示,该元素不会有任何内容溢出(或者即使溢出了,也不会被显示)size
:该值表示元素盒子的大小是独立于其内容,也就是说在计算该元素盒子大小的时候是会忽略其子元素content
:该值是contain: layout paint
的简写strict
:该值是contain: layout paint size
的简写
在上述这几个值中,size
、layout
和paint
可以单独使用,也可以相互组合使用;另外content
和strict
是组合值,即content
是layout paint
的组合,strict
是layout paint size
的组合。
有关于该模块更详细的介绍可以阅读《初探CSS的容器模块》一文。
不过,在 CSS Containment Module Level 3 草案中为 contain
属性新增了 inline-size
和 block-size
属性。
inline-size
:在容器的内联轴(Inline Axis)上建立一个查询容器,用于尺寸查询。对元素运用layout
、style
和inline-size
的限制block-size
:在容器的块轴(Block Axis)上建立一个查询容器,用于尺寸查询。对元素运用layout
、style
和block-size
的限制
该草案还新增了 container-type
、container-name
和 container
等属性:
container-type
:将一个元素定义为一个查询容器。后代可以查询它的尺寸(size
)、布局(layout
)和 样式(style
)等container-name
:为@container
规则指定一个查询容器名称的列表,用来过滤哪些查询容器是目标container
:是container-type
和container-name
的简写属性
其中,container-type
有 size
、inline-size
、block-size
、style
和 state
等属性值。
回到容器查询中:
.container {
contain: layout size style;
}
注意,目前这是容器查询实验属性原型,未来可能会发生变化,包括使用什么值,以及有可能引入一个新的属性来帮助我们定义容器。
目前,contain
需要一个或多个值,layout size
是一种初始组合,而且这种组合的支持正在增加,比如 layout size style
。layout
值创建了一个块格式化的上下文(Block Formatting Context),它将包含其所有的边距,这样内容就不会影响另一个容器中的内容。size
值需要设置显式的height
和width
或像 Flexbox 和 Grid 父代那样从外部接收尺寸,因为有了这个值,元素将不再从其子代确定尺寸。
浏览器首先实现这些特定值的主要目的是为了容器查询的可能性做准备。contain
的这些值的设置是为了解决无限循环的问题,这可能是 由于子元素的宽度改变了其父元素的宽度,而父元素的宽度又改变了子元素的宽度。
其中 size
的值包括了两个轴(Inline Axis 和 Block Axis)的尺寸,即宽度和高度。不过,在大多数情况下,开发者一般是试图根据宽度来包含元素。同时,高度被允许是内在的(根据元素的内容来增长或缩小)。因此,更多的时候,使用inline-size
来替代contain
中的size
。
inline-size
的使用为该容器中的元素创建了一个包含的上下文(Containment Context)。一个被查询的元素将使用其最近的祖先,并应用包含。这很重要,因为它允许对容器进行嵌套。因此,如果你不知道容器是什么,或者创建一个嵌套的容器,后代的结果可能会改变。
另外,在contain
中加入style
,用来避免因设置属性(比如计数器)而产生潜在的无限循环,这些属性有可能在容器外触发变化并影响其大小,从而造成大小循环。
这样,我们可以像下面这样来声明一个查询容器:
.container {
contain: layout inline-size style;
}
有了这个基础之后,可以像平时那样来写CSS了,完成 UI 的效果。这个有点类似于响应式Web设计,可以考虑从移动端先行的原则(对应图 Default状态)。
.container {
inline-size: 300px;
overflow: hidden;
resize: horizontal;
max-inline-size: 100vw;
min-inline-size: 280px;
}
.card {
display: grid;
border-radius: 24px;
background-color: #fff;
color: #454545;
gap: 10px;
box-shadow: 0 0 0.35em 0 rgb(0 0 0 / 0.5);
}
.card__thumbnail {
aspect-ratio: 16 / 9;
object-fit: cover;
object-position: center;
border-radius: 24px 24px 0 0;
grid-area: 1 / 1 / 2 / 2;
z-index: 1;
}
.card__badge {
grid-area: 1 / 1 / 2 / 2;
z-index: 2;
background-color: #2196f3;
border-radius: 0 10rem 10rem 0;
align-self: start;
justify-self: start;
margin-top: 2rem;
color: #fff;
padding: 5px 12px 5px 8px;
text-shadow: 0 0 1px rgb(0 0 0 / 0.5);
filter: drop-shadow(0px 0px 2px rgb(0 0 0 / 0.5));
}
.card__title {
font-weight: 700;
font-size: clamp(1.2rem, 1.2rem + 3vw, 1.5rem);
padding: 0 20px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.card__describe {
color: #666;
line-height: 1.4;
padding: 0 20px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
.card__button {
display: inline-flex;
justify-content: center;
align-items: center;
border: none;
border-radius: 10rem;
background-color: #feca53;
padding: 10px 20px;
color: #000;
text-decoration: none;
box-shadow: 0 3px 8px rgb(0 0 0 / 7%);
transition: all 0.2s linear;
font-weight: 700;
justify-self: end;
margin: 0 20px 20px 0;
cursor: pointer;
}
.card__button:hover {
background-color: #ff9800;
}
添加上面的CSS之后,UI的效果会像下图这样:
示例代码采用了CSS Grid 布局,如果你从未接触过 CSS Grid,或者对 CSS Grid 布局技术感兴趣的话,欢迎阅读 图解CSS 的 CSS Grid 系列教程。整个卡片的 UI 效果实现思路以及细节,可以参阅《Grid布局案例之构建重叠布局》一文。
有了这个基础,我们就可以开始编写容器查询了。
每个查询容器都会创建一个可以被它们的后代元素所查询的包含上下文!
我们可以使用与@media
相似的@container
规则来查询当前的包含上下文(最近的祖先内容),@container
规则会告诉浏览器在每个容器内何时以及如何改变样式。其语法如下:
@container
[ <container-name> | [ name(<container-name>) || type(<container-type>+) ] ]?
<container-query> {
<stylesheet>
}
<container-condition> = not <container-query>
| <container-query> [ and <container-query> ]*
| <container-query> [ or <container-query> ]*
<container-query> = ( <container-condition> )
| size( <size-query> )
| style( <style-query> )
<size-query> = <size-feature> | <size-condition>
<size-condition> = not ( <size-query> )
| ( <size-query> ) [ and ( <size-query> ) ]*
| ( <size-query> ) [ or ( <size-query> ) ]*
<style-query> = <style-feature> | <style-condition>
<style-condition> = not ( <style-query> )
| ( <style-query> ) [ and ( <style-query> ) ]*
| ( <style-query> ) [ or ( <style-query> ) ]*
注意,@container
也是 CSS 条件规则之一。如果你不过多的深究其语法规则每个参数的具体含义的话,你可以把他当作 CSS 的媒体查询 @media
来使用。
可以使用断点(查询容器的size
,也可以是inline-size
或block-size
)来做查询条件,比如我们示例中的:
可以使用 max-width
、min-width
,或者使用逻辑属性,比如 max-inline-size
、min-inline-size
来描述上面的断点:
/* 容器宽度大于 400px (width > 400px)*/
@container (min-width: 400px) {
.card {
}
}
/* 容器宽度大于 550px (width > 550px)*/
@container (min-width: 550px) {
.card {
}
}
/* 容器宽度大于 700px (width > 700px)*/
@container (min-width: 700px) {
.card {
}
}
你也可以使用查询的新语法,使用 >=
或 <=
来替代max-*
和min-*
,比如:
@container (width > 400px) {
}
也样也可以使用逻辑属性:
@container (inline-size > 400px) {
}
就我们示例而言,在不同查询条件下,修改元素的样式即可:
/* 查询容器宽度大于 400px */
@container (min-width: 400px) {
.card {
grid-template-columns: 180px auto;
grid-template-areas:
"thumbnail title"
"thumbnail button";
}
.card__thumbnail {
grid-area: thumbnail;
aspect-ratio: 1 / 1;
border-radius: 24px 0 0 24px;
}
.card__badge,
.card__describe {
display: none;
}
.card__title {
grid-area: title;
margin-top: 20px;
align-self: start;
}
.card__button {
grid-area: button;
align-self: end;
}
}
/* 查询容器宽度大于 550px */
@container (min-width: 550px) {
.card {
grid-template-columns: 240px auto;
grid-template-rows: min-content min-content auto;
grid-template-areas:
"thumbnail title"
"thumbnail describe"
"thumbnail button";
gap: 0;
}
.card__thumbnail {
grid-area: thumbnail;
aspect-ratio: 1 / 1;
border-radius: 24px 0 0 24px;
z-index: 1;
}
.card__badge {
grid-area: thumbnail;
z-index: 2;
display: flex;
}
.card__describe {
grid-area: describe;
align-self: start;
display: flex;
margin-top: -24px;
}
.card__title {
grid-area: title;
margin-top: 20px;
align-self: start;
}
.card__button {
grid-area: button;
align-self: end;
}
}
/* 查询容器宽度大于 700px */
@container (min-width: 700px) {
.card {
grid-template-areas:
"title title title"
"describe describe describe"
"button button button";
grid-template-rows: none;
grid-template-columns: none;
grid-auto-rows: auto;
align-items: center;
justify-content: center;
align-content: center;
}
.card::after,
.card::before {
content: "";
display: block;
grid-area: 1 / 1 / 4 / 4;
z-index: 2;
background-image: linear-gradient(
-45deg,
#ee7752,
#e73c7e,
#23a6d5,
#23d5ab
);
background-size: 100vw 100vw;
background-color: rgb(0 0 0 / 0.5);
opacity: 0.5;
width: 100%;
height: 100%;
border-radius: 24px;
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
backdrop-filter: blur(4px);
}
.card::before {
z-index: 3;
}
.card > * {
z-index: 4;
justify-self: center;
}
.card__thumbnail {
grid-area: 1 / 1 / 4 / 4;
aspect-ratio: 16 / 9;
border-radius: 24px;
max-height: 280px;
z-index: 1;
}
.card__badge {
grid-area: 1 / 1 / 4 / 4;
justify-self: start;
z-index: 5;
}
.card__title {
grid-area: title;
align-self: end;
margin-top: 0;
color: #fff;
font-size: clamp(2rem, 2rem + 4vw, 3rem);
}
.card__describe {
grid-area: describe;
margin-top: 0;
max-width: 60vw;
color: #fff;
text-align: center;
font-size: clamp(1.2rem, 1.2rem + 2vw, 1.5rem);
}
.card__button {
--purple: #7f00ff;
--pink: #e100ff;
grid-area: button;
margin: 0;
align-self: start;
padding: 0.75em 4em;
font-size: clamp(1.2rem, 1.2rem + 2vw, 1.5rem);
text-shadow: 1px 1px 1px rgb(0 0 0 / 50%);
text-transform: uppercase;
background: linear-gradient(to right, var(--purple), var(--pink));
position: relative;
color: #fff;
}
.card__button::after {
content: "";
position: absolute;
z-index: -1;
inset: 0;
opacity: 0.8;
border-radius: 10rem;
background: inherit;
filter: blur(30px);
transition: all 0.2s;
}
.card__button:hover::after {
filter: blur(32px);
bottom: -5px;
}
.card__button:hover:active::after {
filter: blur(10px);
bottom: -6px;
}
}
将这些代码整合到一起,最终效果如下:
拖动示例中容器右下角滑块,你可以看到卡片组件随着查询容器(.container
)的宽度变化向用户呈现不同的UI效果:
想象一下,是不是在同一个网站上不同位置,放置同一个组件,能有不同的UI效果,比如:
上面这个示例,你调整视窗大小时,查询容器宽度也会随着变化,因此你也能看到相应卡片的 UI 也会有所变化:
是不是很有意思。如果是的话,不妨自己动手一试!
容器查询的Polyfill
CSS容器查询特性还只是一个实验性属性,虽然他很强大,将会给Web设计和开发以及组件封装带来巨大的变化,但你可能也会因为他现在无法运用于实际生成而感到遗憾。
不过,@Jonathan Neal 为 CSS 容易查询写了一个 Polyfill,即 CQFill。它可以与你现有的 CSS 代码一起使用。这是因为不支持容器查询的浏览器会丢弃这些特定的语句和声明。
CQFill 要求你用一种替代的语法来复制一些 CSS:
- 将
contain
属性的值复制到一个名为--css-contain
的 CSS 自定义属性中 - 将
@container
规则复制为带有--css-contain
的@media
规则中
像下面这样:
/*创建一个查询容器*/
.container {
contain: layout inline-size style; /* 用于支持容器查询的浏览器 */
--css-contain: layout inline-size style; /* CQFill,容器查询的Polyfill */
}
/* 容器查询 */
/* 用于支持容器查询的浏览器 */
@container (min-width: 700px) {
.card {
/* … */
}
}
/* CQFill,容器查询的Polyfill */
@media --css-container and (min-width: 700px) {
.card {
/* … */
}
}
使用上面代码中的命名方式是非常重要的。自定义属性必须命名为 --css-contain
,媒体查询必须包含 --css-contain
。如果没有按照这样的方式命名,CQFill将无法识别它们。
当然,要让上面的代码生效,需要在你的项目中引入 CQFill 所需的脚本。最直接的方式是引入该Polyfill的脚本:
<script src="https://unpkg.com/cqfill"></script>
你也可以使用 NPM 或 Yarn 的方式引入 CQFill:
# 安装CQFill
npm install cqfill
// 引入CQFill
import { cqfill } from "cqfill";
cqfill()
或者使用PostCSS:
//postcss.config.js
import postcss from 'postcss'
import postcssCQFill from 'cqfill/postcss'
postcss([ postcssCQFill ])
有关于 CQFill 更详细的介绍,可以点击这里查阅。
虽然 CQFill 可以帮助你在不支持容器查询的浏览器正常使用,但就我个人还是建议,目前先不要在实际项目中使用该特性。
小结
虽然 CSS 容器查询还只是一个实验属性,但我很喜欢这个特性。CSS 容器查询特性的到来,对于设计系统、组件库和框架开发者来说,容器查询将改变以往的开发方式,而且也能更好的提供自我防御的能力。组件在任何给定的空间内自我管理的能力将减少在实际将它们添加到布局中引入的复杂情况。当然,该特性对于某些组件而言,利弊可能是相同的,而且你可能并不总是想要一个动态布局。
当然,到目前为止,容器查询还有很多特性有待改善。但对于 Web 开发者来说,是庆幸的。CSS 容器查询的发展还是很快的,我也很确信,容器查询将迎来 Web 设计和开发的一个新阶段,就像响应式网页设计一样的重要。在接下来的一段时间,我将还会为大家带来更多有关于CSS容器查询方面的介绍。如果你喜欢的话,请持续关注后续的相关更新。