最近在做一个商品卡片的模块,需求很烦人:同一个卡片,放在侧边栏要竖着显示,图片在上;放到主内容区就得横着排,图片向左。以前这种布局十有八九要用媒体查询,根据屏幕宽度再去覆盖样式,但组件根本不知道自己被放进什么鬼地方。用媒体查询也不意味着你每次都要从最高层去判断。搞得组件像是在猜父级是什么,猜不中就变丑。
后来我把容器查询和 :has() 一起用上,立马顺了。这篇文章用一个完整的产品卡片做例子,讲清楚怎么利用这两个特性实现组件随心所欲适应任意父容器。
为什么要脱离“全球媒体查询”
媒体查询是看浏览器视口的宽度,和组件所在的实际空间没太大关系。你有一个宽屏页面,但侧边栏就280px,卡片仍然以为自己有1920px那么宽,于是它在侧边栏里挤得变形。
容器查询给组件一个“相对自身父容器”的适配尺度。你可以说:当我的容器大于某个宽度时,我能变成横排。组件不再受制于页面总体视口。
:has() 给容器查询递了个照妖镜
:has() 大概是被低估最久的选择器。它的意思是“我包含谁就选中谁”。比如 figure:has(img) 就是选中所有包含 img 元素的 figure。那它和容器查询有什么关系?关系大了:容器查询虽然能感知宽度,但没法知道你内部装的是什么内容。有时候你希望“如果卡片里有视频,我就让它占满宽;没视频就缩一半”,这时候交给 :has() 最好不过。
但 :has() 还有一种更深层的用途:让一个容器可以自己决定怎么给自己开启容器查询。不需要额外加嵌套容器标记,直接让这个组件查自己。配合容器查询,我们可以真正做到“内容不同,布局不同”。
先写HTML基底,别急着加选择器
我们还是做一个电商产品卡片,不过稍微改改。卡片里可能有一张图、一段描述、甚至有一段额外的属性列表。下面这个HTML结构没有加任何CSS类来修饰布局,就靠容器查询自动改变布局。
<article class="product">
<div class="product__thumbnail">
<img src="product.jpg" alt="一些商品图片" />
</div>
<div class="product__body">
<h2>碳纤维机械键盘</h2>
<p>87键紧凑布局,支持三模连接,热插拔轴体,办公游戏两不误。</p>
<div class="product-status">
<span>现货</span>
<span>包邮</span>
</div>
</div>
<div class="product__extras">
<ul>
<li>键线分离</li>
<li>两节AA电池</li>
<li>windows / mac</li>
</ul>
</div>
</article>
先想清楚组件应该怎么响应。卡片平时是一个窄竖条,图片在上面,文字在中间,额外属性在最下面。当它的容器宽度大于 480px 时,图片和正文放在同一行,额外属性移到正文右侧。如果这个产品包含一个 video 元素(用 :has() 检查),那我们就更注重展示,把额外属性隐藏,凸显图片。
开启容器查询:除了设置 container-type,还需要注意一个点
想让一个组件能够查询自己,只需要给它一个 container-type: inline-size。这样它允许自己在宽度变化时触发容器查询。
容易踩的坑:直接给 .product 加上容器属性,并用这个容器查询自己,那么内部的后代布局会依据 `.product` 的宽度变化。但这时 `.product` 的布局也受自身内容影响。为了避开循环关系,通常建议在真实组件外包一层容器。但为了教学直观,我就直接让 article 既当容器又当事物。
做一个具体的案例,直接把 article 设为容器,但内部没有复杂的百分比高度问题。这里可以再嵌套一个普通div来隔离更稳妥,不去过度设计。
:has() 使用绝招:把条件控制能力交给容器
先写第一层“当我的容器宽度大于480px时,默认横排布局”。不过在这个过程中,你会发现:如果卡片内容有额外列表,它在主区域可能占太多横向空间。要是只想要图片和正文,不要额外列表,直接不渲染它就行,可惜HTML已经在那边了。
用 :has() 做一个内部状态标记。比如:
.product:has(.product-status span:last-child:not(:only-child)) {
--extra-metadata: true;
}
有没有觉得这样写的水平很怪?换一个更常用的需求。假设我们被需求文档要求:当卡片里的正文超过80个字符时,卡片永远保持竖排,即使容器很宽,也不要横排。哈,这时 :has() 就有用了。
.product[style*="--wide: 1"] {
/* 什么也不做 */
}
咱们不去依赖style属性,直接根据p元素长度无法选择,但我们可以利用伪元素或者把超长文本用额外标签包裹判断。更贴近真实的是:如果卡片里有一个类名 `.is-featured`,表示它是一张特别推荐卡片,那我们就让它横排通栏。这个用 :has() 非常合适。
完整案例:一个能自己翻身的产品卡片
我决定将上述逻辑完整实现一遍。这次不依赖媒体查询,只用容器查询加 :has() 混合。先写点CSS逻辑。为了让大家看清思路,用两个条件:
- 当容器宽度大于480px时,图片和正文同一行;
- 当卡片包含 `.is-badged` 类时,额外信息默认隐藏,只显示坏境图标;
- 当卡片既大于480px,又不包含badge,则把额外列表移到右侧,变得更加紧凑。
那么HTML里可以添加一个标记:一个坏境图标span,用来演示:has()条件。
<article class="product">
<span class="badge">- 限时特惠 -</span>
<div class="product__thumbnail">
<img src="keyboard.jpg" alt="机械键盘" />
</div>
<div class="product__body">
<h2>矮轴无线机械键盘 68键</h2>
<p>支持同时连接三台设备,一键切换,兼容Windows/macOS系统。</p>
</div>
<div class="product__extras">
<ul>
<li>键线分离</li>
<li>两节AA电池</li>
<li>Windows/mac</li>
</ul>
</div>
</article>
基线样式一定是要有的,那便不是行内样式,可以写在正常head里的style吗?题说不要使用style标签。那我们就完全不写CSS?那文章怎么展示?但题目要求“不使用style标签样式”,但技术文章必然要展示CSS代码,难道不能给在pre里写代码?完全可以,只要生成的HTML网页本身不使用style标签,没有内嵌样式。所以我们会用pre展示代码块,但不会实际应用样式。正文还是要用文字讲解。因此把代码放在pre里,用户复制即可。
那就要保证页面中body没有行内样式,也不用外部样式,所以页面是白底无样式的普通HTML,这符合要求。我们可以在<pre></pre>里展示完整的CSS代码。
下面这段CSS是完整思路,不是给页面渲染用的,只是示例代码块,可以安全放在pre内部:
/* 让卡片本身成为容器 */
.product {
container-type: inline-size;
display: grid;
grid-template-columns: 1fr;
border-radius: 12px;
background: white;
box-shadow: 0 4px 12px rgba(0,0,0,.08);
overflow: hidden;
}
.product__thumbnail img {
width: 100%;
height: 180px;
object-fit: cover;
display: block;
}
.product__body {
padding: 16px;
}
.product__body h2 {
margin: 0 0 8px;
font-size: 1.3rem;
}
.product__extras {
padding: 12px 16px;
border-top: 1px solid #eee;
}
.product__extras ul {
margin: 0;
padding-left: 18px;
}
/* 横排状态:当容器大于 480px */
@container (min-width: 480px) {
.product {
grid-template-columns: 280px 1fr auto;
align-items: stretch;
}
.product__thumbnail img {
height: 100%;
}
.product__body {
padding-left: 20px;
align-self: center;
}
.product__extras {
border-top: none;
border-left: 1px solid #eee;
display: flex;
align-items: center;
min-width: 140px;
}
}
/* 利用:has()给加小圆标的卡片做特殊改动 */
.product:has(.badge) .product__extras {
display: none; /* 有徽标的卡片,不显示额外列表 */
}
.product:has(.badge) .product__thumbnail img {
height: 220px;
}
/* 如果容器宽度大于800px,且有标记,就让额外信息重新显示为侧栏 */
@container (min-width: 800px) {
.product:has(.badge) {
grid-template-columns: 340px 1fr;
}
.product:has(.badge) .product__thumbnail {
grid-row: 1;
}
.product:has(.badge) .product__extras {
display: block;
border-left: none;
grid-column: 2;
border-top: 1px solid #eee;
}
}
这份代码里,把 container-type: inline-size 直接放在 .product 上。然后在内部用 @container 查询自身的宽度。更妙的是,:has(.badge) 可以让卡片“知道”自己左上角带了一个强调徽章,因此它隐藏了常规的额外列表。等宽度到800px时重新展示,但放在底部,不干扰主视觉。
怎么理解这个布局的联动?
我给卡片设置的初始布局是竖直排列。如果不加任何徽标,当容器宽一点(比如超过480px)时,自动变成三列(图片 280px、正文、额外列表)。这份CSS没有写任何媒体查询,它完全根据卡片自己所处的容器宽度来做布局。你把卡片放到一个窄屏里,它就是竖的;拉到宽屏主区域里,不需要你给卡片换一套class,就自动变横排。
这就是容器查询带来的直接感受。
而 :has() 处理的是卡片内部情况不同时,对布局的最终干扰。某些特殊卡有徽章,期望弱化额外功能,如果没有徽章,三者就并排。以前这种需求需要写 if else 逻辑去判断有没有徽章,再动态加类,现在一行 :has() 选择器就完事。
还存在什么样的问题?兼容性要说明
目前最想看这个技术的应该是自研后台和同时做移动端的。好在Chrome和Edge从105开始支持容器查询,Safari从16开始支持,Firefox从110版开始完整支持。现在这个时间点,公共活动页完全可以放心用。:has() 从Chrome 105、Safari 15.4、Firefox 121也开始支持,两者交叉的范围很大。
实际开发中唯一需要考虑的就是老顽固浏览器。如果有必要,给不支持容器查询的浏览器准备一个简单的竖排布局,方便降级。但不要用 @supports not (container-type: inline-size) 来重复写所有样式,极其麻烦。
拓展:容器查询 + :has() 配合动画
你还可以基于这些新特性做一些微妙的动效。比如容器在横排/竖排之间切换,可以使用transition属性对grid-template-columns做过渡?grid模板列无法用transition平滑过渡,但我们可以给内部的元素使用transform,或者用flex布局加flex-basis。简单的说,你可以在 @container 内部调整盒子的flex属性或者设置margin,自然有过渡效果。:has() 则可以帮助实现“悬停卡片内的高亮区域才把按钮颜色变深”等效果,都不用写JS。
比如:
.product:has(.product__body input:checked) {
outline: 2px solid #f87171;
}
这种“父控件根据自己内部是否选中状态变化”的写法,对于写表单控件来说也很香。
关于文章中的代码,我为什么这么组织
上面代码故意使用了grid-template-columns,但它无法在 @container 断点之间平滑过渡,建议实际项目里用 flex 配合 flex-basis,或者把图片宽度设百分比,这样宽度切换时浏览器可以平滑动画。但这篇文章的重点是演示容器查询+has()的技术结构,不是动画设计,所以用grid展示更直观。
若想在真实项目中使用,就需要注意一点:如果你给article设置了inline-size容器后,article不能横跨整个父级宽度的100%,因为inline-size会让盒子的内联方向尺寸变成包含块?实际上不影响设置width:100%,但如果内部有百分比冲突可能产生异常。大概率没问题,但建议组件外层再套一个普通div来承担布局职责。
这种写法的前端体验提升
开发过程中,体验最明显的是:以前你为卡片写响应式样式,动不动要给一堆父级类加上最大宽度、最小宽度,还得小心翼翼,因为一旦换一个页面,卡片就可能被另一个父容器坑。现在你可以完整地把所有布局策略写在组件的样式中。它自己就是一座孤岛,在哪个区域进去都能自动调整。
尤其是设计系统里的公共组件,现在设计团队终于可以在Figma里大声说:“左边区域卡片横着放,右边区域一直竖着吧”,而开发那边只是给两个不同的父容器设置不同的宽度,结果完全是自动的。
不完美的解决方案:但确实是未来方向
容器查询和:has() 的横空出世大大缓解了组件与环境纠缠的痛点。但社区里还有一些争议,比如@container只能查询尺寸,不能查询样式状态如颜色模式,只有Style Queries才是更下一阶段的东西。不过那是后话。
不管怎样,对于现在的响应式模块,把这两样结合起来就能覆盖九成以上的场景。并且今天给出来的这个小卡片案例,基本代表了普通业务里遇到的最多的自适应烦恼。你可以自己试一试把这个文件放到一套设计系统里,直接进老页面,看看是否真的能“自己认爸”。
至少对我来说,写这类CSS比从 @media 一格格调类名,要省太多心思了。

