过去十年我们写响应式布局,脑子里想的是组件怎么根据自身宽度变化,手上写的却是 @media (max-width: 768px) 这种盯着视口宽度的全局断点。组件被塞进侧边栏变窄了,样式不会自动调整,你得额外加一套父级选择器的覆盖规则。CSS 容器查询在 2023 年初全线浏览器落地,一年半过去,配合同样刚站稳脚跟的 CSS 嵌套语法和 :has() 选择器,已经能支撑一套完全不同的响应式写法——把断点逻辑真正放进组件内部。这篇文章用两个完整案例把这条新链路串起来,配置、代码、效果都给你拆开看清楚。
一、概念先行:容器查询解决了什么问题
媒体查询监听的是视口宽度。视口宽度和组件实际占据的宽度是两码事。一个卡片组件在整页布局里可能占 400px,在侧边栏里可能只占 220px,在移动端全宽又变成 360px。传统做法要么给组件写多套变体类名,要么在父级选择器里不断覆盖组件内部的样式规则。
容器查询换了一种思路:直接监听组件自身容器的宽度。你定义一个容器,然后在这个容器内部写一套基于容器宽度的断点规则。容器有多宽,样式就按哪套规则来,和外层布局完全解耦。组件可以被放到任何地方,它的外观始终跟随自身空间自适应,不需要上层关心。
CSS 嵌套语法和 :has() 是容器查询的绝佳搭档。嵌套让容器查询的规则书写更紧凑、层级更清晰;:has() 则能根据组件内部状态(比如是否包含图片、是否有空状态)动态调整容器查询的行为。三者叠在一起,能写出以前必须靠 JavaScript 才能实现的动态响应逻辑。
二、核心语法速览
在进入实战案例之前,先把三个核心 API 的语法要点过一遍。
2.1 定义容器
不是任意元素都能被容器查询监听。你需要显式声明某个元素为容器:
/* 定义一个内联尺寸容器,容器的宽度变化会触发内部查询 */
.card-wrapper {
container-type: inline-size;
container-name: card;
}
/* 简写形式 */
.card-wrapper {
container: card / inline-size;
}
container-type 设为 inline-size 表示只监听宽度方向的尺寸变化,这也是最常用的模式。设为 size 则同时监听宽度和高度,但需要显式指定容器的高度值,否则高度会塌陷。给容器命名是可选的,不命名的话后面用匿名查询也行,但命名后在嵌套容器的场景里能避免规则冲突。
2.2 编写容器查询
/* 匿名查询,就近找祖先容器 */
@container (min-width: 300px) {
.card-title {
font-size: 1.2rem;
}
}
/* 命名查询,精确指定容器 */
@container card (max-width: 280px) {
.card-title {
font-size: 0.9rem;
}
}
和媒体查询的写法几乎一样,只是 @media 换成了 @container。支持的逻辑运算符也一致:and、or、not 都能用。
2.3 CSS 嵌套语法
嵌套在原生 CSS 里已经可用,写法类似 Sass 但规则稍有不同:
.card {
padding: 16px;
/* 嵌套子选择器 */
& .card-title {
font-weight: 600;
}
/* 嵌套伪类 */
&:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
/* 嵌套容器查询 */
@container (min-width: 400px) {
& .card-body {
display: grid;
grid-template-columns: 1fr 1fr;
}
}
}
关键规则:嵌套的子规则必须以 & 开头,或者以一个符号开头(如 .、#、:)。直接把元素标签名写在嵌套里是不允许的,这和 Sass 不一样。习惯了之后会发现这个限制其实让嵌套的意图更加明确——你在写的是对父选择器的扩展,不是凭空创建新规则。
2.4 :has() 选择器
:has() 被称为”父选择器”,实际上它的能力远不止选父级:
/* 包含图片的卡片 */
.card:has(img) { }
/* 包含空状态子元素的容器 */
.container:has(.empty-state) { }
/* 后方兄弟元素是按钮的输入框 */
input:has(+ button) { }
三、实战案例一:自适应卡片组件
第一个案例做一个产品卡片。卡片被用在页面主体区域时通常较宽,要展示横向布局——图片在左、信息在右。被放在侧边栏时变窄,要切换为纵向布局——图片在上、信息在下,字号和间距也相应缩小。所有变化完全由容器查询驱动,不写一行媒体查询。
3.1 HTML 结构
<!-- 页面上可以同时放多个卡片,各自独立响应 -->
<div class="card-container">
<article class="product-card">
<div class="product-card__image">
<img src="product.jpg" alt="产品图片">
</div>
<div class="product-card__body">
<h3 class="product-card__title">降噪耳机 Pro</h3>
<p class="product-card__desc">自适应降噪,续航40小时</p>
<span class="product-card__price">¥899</span>
</div>
</article>
</div>
3.2 CSS 完整实现
/* 第一层:定义容器 */
.card-container {
container: product-card / inline-size;
}
/* 第二层:基础样式 */
.product-card {
display: flex;
gap: 16px;
padding: 16px;
border-radius: 12px;
background: #fff;
border: 1px solid #e5e7eb;
/* 嵌套图片区域样式 */
& .product-card__image {
flex-shrink: 0;
width: 120px;
height: 120px;
border-radius: 8px;
overflow: hidden;
& img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
/* 嵌套文字区域 */
& .product-card__body {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
justify-content: center;
}
& .product-card__title {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: #111;
}
& .product-card__desc {
margin: 0;
font-size: 0.9rem;
color: #666;
}
& .product-card__price {
font-size: 1.2rem;
font-weight: 700;
color: #e74c3c;
}
/* 容器查询:容器宽度小于 350px 时切换为纵向布局 */
@container (max-width: 350px) {
flex-direction: column;
align-items: center;
text-align: center;
& .product-card__image {
width: 100%;
height: 180px;
}
& .product-card__body {
align-items: center;
}
& .product-card__title {
font-size: 1rem;
}
& .product-card__price {
font-size: 1.1rem;
}
}
/* 容器查询:容器宽度大于 500px 时加大尺寸 */
@container (min-width: 500px) {
padding: 24px;
gap: 24px;
& .product-card__image {
width: 160px;
height: 160px;
}
& .product-card__title {
font-size: 1.3rem;
}
}
}
注意看结构:基础样式定义在 .product-card 内部,通过嵌套语法直接包含子元素的规则。两个 @container 块也嵌套在同一个父级下,阅读时不需要在文件里来回跳转,整个组件的完整样式逻辑都在一个代码块内。
这个卡片可以被放在任何宽度的容器里——200px 的侧边栏、400px 的主内容区、600px 的全宽区域——不需要在父级写任何覆盖样式,卡片自身会根据容器宽度自动切换布局和字号。
四、实战案例二:根据内容动态调整的数据表格
第二个案例更进一步。我们要做一个数据表格,规则有三条:当容器宽度足够时展示标准表格;容器变窄时表格自动折叠为卡片列表样式;当表格里没有数据时(存在空状态元素),容器整体隐藏摘要信息区域。最后一条用 :has() 来实现。
4.1 HTML 结构
<div class="table-container">
<div class="table-summary">共 3 条记录</div>
<table class="data-table">
<thead>
<tr>
<th>订单号</th>
<th>客户</th>
<th>金额</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr>
<td data-label="订单号">ORD-20250101</td>
<td data-label="客户">张三</td>
<td data-label="金额">¥1,280</td>
<td data-label="状态">已完成</td>
</tr>
<tr>
<td data-label="订单号">ORD-20250102</td>
<td data-label="客户">李四</td>
<td data-label="金额">¥3,600</td>
<td data-label="状态">处理中</td>
</tr>
<tr>
<td data-label="订单号">ORD-20250103</td>
<td data-label="客户">王五</td>
<td data-label="金额">¥820</td>
<td data-label="状态">已完成</td>
</tr>
</tbody>
</table>
<!-- 空状态元素,默认隐藏 -->
<div class="table-empty" hidden>暂无数据</div>
</div>
4.2 CSS 实现
.table-container {
container: data-table / inline-size;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.95rem;
& th,
& td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #eee;
}
& th {
background: #f9fafb;
font-weight: 600;
color: #374151;
white-space: nowrap;
}
& td {
color: #4b5563;
}
/* 窄容器:表格变身卡片列表 */
@container (max-width: 480px) {
& thead {
display: none; /* 隐藏表头 */
}
& tr {
display: block;
margin-bottom: 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 0;
}
& td {
display: flex;
justify-content: space-between;
padding: 10px 16px;
border-bottom: 1px solid #f3f4f6;
&:last-child {
border-bottom: none;
}
/* 用 data-label 属性生成标签 */
&::before {
content: attr(data-label);
font-weight: 600;
color: #6b7280;
flex-shrink: 0;
margin-right: 16px;
font-size: 0.85rem;
}
}
}
}
/* 摘要区域:基础样式 */
.table-summary {
padding: 8px 0;
font-size: 0.85rem;
color: #9ca3af;
}
/* 关键一步:当容器内包含 .table-empty 时,隐藏摘要 */
.table-container:has(.table-empty:not([hidden])) .table-summary {
display: none;
}
/* :has 也可以反过来用——有数据时才显示摘要 */
.table-container:not(:has(.table-empty:not([hidden]))) .table-summary {
display: block;
}
这个案例里,容器查询负责了表格在不同宽度下的形态切换,:has() 负责了基于内容状态的显示逻辑。以前做”表格为空时隐藏摘要”这种事,要么在服务端模板里判断,要么用 JavaScript 操作 DOM。现在纯 CSS 就完成了,而且不需要给组件加额外的状态类名。
当表格数据由前端动态渲染时,你只需要根据数据是否为空切换 .table-empty 的 hidden 属性,摘要区域的显隐 CSS 会自动响应——这个自动响应的机制就来自 :has() 的实时性,它不是页面加载时计算一次就固定了,而是 DOM 变化时动态生效。
五、三个特性的协同效应
把容器查询、嵌套和 :has() 放在一起看,它们之间的关系不是简单的叠加,而是相互补充:
容器查询负责”有多宽”。组件根据自身容器尺寸切换布局,这是整套方案的核心引擎。
嵌套负责”写在哪”。所有和这个组件相关的样式——基础样式、容器查询、子选择器、伪类——全部收纳在一个选择器块里,代码组织从”按功能分文件”变成了”按组件分块”。一个组件的 CSS 从一个散布在样式表各处、需要靠命名约定来关联的碎片集合,变成了一个内聚的单元。
:has() 负责”有什么”。组件内部的状态变化(有数据/无数据、有图片/无图片、有错误/正常)现在可以被 CSS 感知到,不再需要 JavaScript 来给父级添加状态类名。
六、浏览器支持与渐进增强策略
截至 2025 年 7 月,这三项特性的浏览器支持情况已经非常乐观:
- 容器查询:Chrome 105+、Edge 105+、Safari 16+、Firefox 110+。移动端 Safari 16+ 也全面支持。
- CSS 嵌套:Chrome 120+、Edge 120+、Safari 17.2+、Firefox 117+。
- :has() 选择器:Chrome 105+、Edge 105+、Safari 15.4+、Firefox 121+。
三项特性已经覆盖了绝大多数活跃用户。如果你还需要兼容更老的浏览器,可以采用渐进增强策略:
/* 兜底样式:所有浏览器都生效 */
.product-card {
display: flex;
flex-direction: column;
}
/* 支持容器查询的浏览器用这套更精细的逻辑 */
@supports (container-type: inline-size) {
.card-container {
container-type: inline-size;
}
.product-card {
flex-direction: row;
}
@container (max-width: 350px) {
.product-card {
flex-direction: column;
}
}
}
用 @supports 包裹容器查询相关的代码块,不支持容器查询的浏览器直接使用降级后的基础布局。这保证了任何浏览器下组件都可用,只是在新浏览器里体验更精细。嵌套语法和 :has() 也可以用同样的思路处理。
七、把旧的媒体查询迁移过来要花多少成本
如果你手里有一个已经写了两年的项目,把所有媒体查询一股脑改成容器查询显然不现实。一个务实的迁移策略是这样:
不改全局布局的媒体查询。页面的整体栅格、导航栏的展开收起、侧边栏的显示隐藏,这些确实和视口宽度相关,继续用全局媒体查询是合理的。
先把组件拆出来。在页面中找出那些被不同页面复用的组件——卡片、表格、列表项、弹窗、表单区块。从这些组件开始,把它们的响应式样式从全局媒体查询中剥离出来,改成容器查询,同时给它们的直接父级加 container-type。这是一个可以逐步推进的过程,每改一个组件就验证一个,不需要一次性全部迁移。
八、总结
容器查询并不是要完全取代媒体查询,而是补上了媒体查询做不到的那一部分——组件级的响应式自治。配合 CSS 嵌套语法和 :has() 选择器,你可以写出真正自包含的组件样式:不管被放在页面的哪个位置,不管内部内容如何变化,组件总能根据自身的空间和状态做出正确的样式决策。
文中两个案例的代码可以直接复制到项目里运行,唯一需要确认的是浏览器版本。如果还没用过容器查询,挑一个项目里最简单的卡片组件试一下——把原来那堆基于父级类名的覆盖规则删掉,换成组件内部的 @container,那种解脱感试过就知道了。

