前阵子在重构一个电商首页的产品推荐模块,设计稿给了三种卡片形态:侧边栏里是紧凑的单列卡片,主内容区是双列标准卡,全屏弹窗里又变成大图横向卡。按照老思路,我得写三套媒体查询,还要根据页面区域各自调整,没两天就乱成一团。后来干脆切换到容器查询,配合 :has() 和 CSS 嵌套,一套卡片组件扔到任何宽度容器里都能自动适应,这才觉得现代 CSS 真的到了能“组件化响应式”的阶段。整个过程踩了几个坑,整理出来供同样在折腾布局的朋友参考。
原来的方案为什么维持不下去了
以前写响应式卡片,习惯盯着视口宽度写一堆 breakpoint。比如小于 600px 就堆叠,600 到 900 两列,再宽就三列。这在整页布局里还行,可一旦要把同一套卡片塞进一个只占半屏的侧边栏,媒体查询立刻失效——侧边栏宽度只有 400px,但视口可能是 1440px 宽,卡片依然按三列渲染,挤成一团。
我们也试过用 CSS 变量配合 resize 事件动态切换类名,可维护起来太痛苦。每加一个展示场景,就得额外维护一套类名映射,JS 和 CSS 之间耦合得死死的。去年看到容器查询在主流浏览器全部可用之后,我就在等一个合适的机会把项目里那些脆弱的媒体查询换掉。
容器查询的本质:问容器多宽,别问视口多宽
用一句话概括容器查询:它让一个元素可以根据自身所在的容器元素的尺寸来调整样式,而不是根据整个页面视口。这意味着同一个卡片组件,放在 300px 宽的侧边栏里会自动切成单列紧凑样式,放到 800px 宽的主内容区会自动展开成双列带缩略图,放到 1200px 的弹窗里再用大图横向布局。所有适应逻辑跟着容器走,组件拿到任何一个洞里都能自己调整。
定义容器很简单,给父元素加上 container-type:
.product-grid-wrapper {
container-type: inline-size;
container-name: product-grid;
}
有了容器之后,子元素就能用 @container 规则来查询它:
@container product-grid (min-width: 500px) {
.product-card {
grid-template-columns: 1fr 1fr;
}
}
@container product-grid (min-width: 750px) {
.product-card {
grid-template-columns: 200px 1fr;
gap: 1.5rem;
}
}
容器查询的尺寸断点完全独立于视口,组件可以搬到页面的任意位置,只要容器的实际宽度到了条件,样式就自动生效。这一点直接解决了我们多场景复用的问题。
配合 :has() 让卡片根据自身内容调整
容器查询解决了宽度适应,但卡片内部还存在另一类需求:当卡片里有促销标签或者库存告急提示时,布局需要微调,比如标签出现时把标题缩小一行。以前这种逻辑得交给 JS,现在 :has() 选择器可以从父元素往下“看”有没有某个子元素,然后给父元素或兄弟元素设置样式。
在我的卡片设计里,如果卡片内包含 .badge-hot 这个促销标签,标题就需要限制宽度以避免和标签重叠。只加一行 CSS:
.product-card:has(.badge-hot) .product-title {
max-inline-size: 70%;
}
更进一步,我结合了容器查询和 :has() 来干掉一些冗余的类名拼接。以前后台返回的数据里带一个 isPromotion 字段,前端要手动给卡片加 is-promotion 类,现在直接通过 :has() 检测 DOM 里是否存在促销标签,连那个多余的类都不需要了。卡片样式全部由内容的实际组成驱动,控制逻辑收拢在 CSS 一处,后端和 JS 都轻松不少。
CSS嵌套让代码结构不再扁平到爆炸
以前写组件样式,尤其是涉及到多个状态和容器查询分支的时候,选择器会变得又长又散。容器查询的 @container 本身已经让样式位置与组件绑定,再配合 CSS 原生嵌套,可读性明显上一个层次。
下面是卡片组件样式的大致结构(省略具体数值):
.product-card {
display: grid;
gap: 0.8rem;
padding: 1rem;
border: 1px solid #eee;
border-radius: 8px;
& .product-image {
aspect-ratio: 1;
object-fit: cover;
}
& .product-info {
display: flex;
flex-direction: column;
gap: 0.4rem;
& .product-title {
font-weight: 600;
}
}
/* 容器查询分支直接嵌在组件内部 */
@container product-grid (min-width: 500px) {
grid-template-columns: 1fr 1fr;
& .product-image {
aspect-ratio: 4/3;
}
}
@container product-grid (min-width: 750px) {
grid-template-columns: 200px 1fr;
& .product-image {
aspect-ratio: auto;
}
}
/* :has() 也嵌进来 */
&:has(.badge-hot) .product-title {
max-inline-size: 70%;
}
&:has(.stock-low) {
border-color: #ffc107;
}
}
所有跟这个卡片有关的样式,不管是基础布局、容器查询分支还是基于子元素的状态调整,全部缩进在 .product-card 内部。找逻辑的时候不用在文件里上下翻飞,展开一层就全看明白了。而且嵌套里的 & 符号写起来非常顺手,比手写完整选择器少了很多重复劳动。
实际搭建的产品网格案例
为了更完整地演示这三样东西怎么配合,我把线上产品网格简化成了一个小 demo。HTML 结构大致是:
<div class="page-layout">
<aside class="sidebar">
<div class="product-grid-wrapper">
<!-- 侧边栏里的卡片容器,实际宽度大概300px -->
<div class="product-card">
<img class="product-image" src="..."/>
<div class="product-info">
<span class="badge-hot">热销</span>
<h3 class="product-title">产品名称</h3>
<p class="product-price">¥99</p>
</div>
</div>
</div>
</aside>
<main class="main-content">
<div class="product-grid-wrapper">
<!-- 主内容区卡片容器,宽度约700px -->
<div class="product-card">...</div>
<div class="product-card">...</div>
</div>
</main>
</div>
同样都是 .product-card,在侧边栏的容器宽度只有 300px 左右,不触发任何容器查询,卡片保持默认的单列堆叠布局,标题正常显示。在主内容区,容器宽度到了 700px 以上,触发了 min-width: 500px 和 min-width: 750px 两个查询,卡片自动变成两列并排,图片比例也做了调整。整个过程没有写任何媒体查询,没有额外的类名控制,卡片丢进哪个坑里就适应哪个坑。
再结合 :has(),如果某个卡片里带了 .badge-hot,标题自动缩短宽度给标签让位;如果带 .stock-low,边框自动变黄提醒。后端返回什么数据,页面就渲染什么结构,样式自动跟上。
浏览器兼容和渐进增强的策略
容器查询和 :has() 都是相对较新的特性,不过好消息是 2024 年所有主流浏览器都已经支持。如果项目还需要覆盖少数旧版本浏览器,写一个简单的渐进增强方案就行。
我把基础布局写在容器查询外面,保证在不支持容器查询的环境里,卡片至少保持一个可用的单列样式。然后用 @supports 检测容器查询支持:
/* 基础样式,所有浏览器都生效 */
.product-card {
display: flex;
flex-direction: column;
}
/* 只在支持容器查询的浏览器里启用增强布局 */
@supports (container-type: inline-size) {
.product-grid-wrapper {
container-type: inline-size;
container-name: product-grid;
}
@container product-grid (min-width: 500px) {
.product-card {
flex-direction: row;
}
}
}
对于 :has(),同样用 @supports selector(:has(*)) 包裹需要用到它的样式,在不支持的环境里这些样式直接跳过,不会造成布局损坏。我们项目的统计数据显示不支持容器查询的流量已经低于 2%,但稳妥起见还是留了底。
开发体验和遗留问题的改善
整套重构做完,感受最深的是样式代码的“内聚性”上了一个大台阶。以前做响应式,需要同时维护好几个地方的媒体查询,组件一多,哪个断点控制哪个区域经常弄混。现在每个组件的容器查询逻辑跟着组件走,改一个卡片的适应规则,只需要在它自己的样式块里调整,不用担心影响其他模块。
还有一点是减少了 JS 干预。之前那些需要根据内容元素有无来增删类名的逻辑,现在全部用 :has() 解决,JS 只负责渲染数据和基本的交互,样式完全由 CSS 自己管好。这对长期维护来说,意味着少了一个经常出 bug 的中间层。
当然也不是所有场景都建议立刻切容器查询。如果组件的布局变化确实和视口强相关,比如全屏导航菜单,那媒体查询依然合适。但只要是组件级别的尺寸适应,尤其是需要跨不同区域复用的卡片、面板、表单区块,容器查询带来的好处是实打实的。
写在最后
CSS 容器查询和 :has() 给我的感觉,有点像当年 flexbox 刚普及那会儿——它们不是在原有的布局体系上修修补补,而是直接让一些过去必须靠 JS 或者奇技淫巧才能实现的效果,变成了几行声明式样式。现代浏览器的原生能力已经足够撑起一套不依赖 JS 框架的响应式组件体系,关键是敢不敢在上线项目里用起来。
如果你也在寻找一种更干净的响应式写法,不妨从一两个小组件开始试验。把容器查询的维度从“页面”切换到“组件”,一旦试过,真的很难再回去写那些满天飞的媒体查询了。

