过去十多年里,我们做响应式设计几乎只有一件武器:媒体查询。它盯着视口宽度,把整张页面切成几档。可当页面被拆成一个个可复用的组件之后,问题就来了——同一个卡片组件,放在侧边栏里只有 280px,放在主内容区有 700px,它凭什么要用同一套样式?媒体查询看不到“组件自己有多宽”,它只关心“浏览器窗口有多宽”。容器查询(Container Queries)彻底改变了这件事:组件第一次可以感知自己的容器尺寸,并据此调整内部布局。这不是媒体查询的替代品,而是响应式设计从“页面级”走向“组件级”的分水岭。这篇 45 分钟长文,从语法、容器类型、容器单位,一路讲到卡片、侧边栏、表格三大实战,再到 style()、scroll-state()、命名嵌套、性能陷阱与兼容策略,并配有多个可实时调节的交互实验台,帮你把容器查询真正用进项目里。
为什么媒体查询不够用:组件复用的困境
先看一个真实场景。你写了一个漂亮的 .product-card,包含缩略图、标题、描述和标签。在首页的主内容区它占据 700px 宽,横向排列很好看;可同一个组件被放进侧边栏,只有 280px 宽,横向排列立刻挤成一团,文字被压成竖条。
面对这个问题,传统的解法只有三种,而它们都不够好:
- 解法一:给组件加一个修饰类。例如
.product-card--compact。问题在于,谁来决定用哪个类?只能是使用它的页面,于是组件的“自适应”变成了“被安排”。 - 解法二:写一堆上下文选择器。例如
.sidebar .product-card { ... }。这会让组件与页面结构强耦合,一旦侧边栏换个位置,样式就失效。 - 解法三:用媒体查询模拟。例如
@media (max-width: 768px) { ... }。可这依然在盯视口:桌面端侧边栏只有 280px,但视口有 1440px,媒体查询完全不会触发。
这三种解法的共同病根是:媒体查询无法回答“我这个组件现在有多宽”这个问题。而容器查询正是为了回答它而诞生的。
.product-card { /* 默认样式 */ }
.sidebar .product-card {
flex-direction: column;
}
/* 组件被绑死在 .sidebar 上,换个地方就废了 */
/* ❌ 媒体查询做法:只看视口,不看容器 */
@media (max-width: 768px) {
.product-card { flex-direction: column; }
}
/* 桌面端侧边栏只有 280px,这里不会触发 */
/* ✅ 容器查询做法:组件自己说了算 */
.card-wrap {
container-type: inline-size;
}
@container (min-width: 400px) {
.product-card {
flex-direction: row;
}
}
一句话记住区别
媒体查询问的是“屏幕有多大”,容器查询问的是“我住在多大的房子里”。一个组件被放进任何容器,都能自己决定该怎么排布——这才是真正意义上的组件化响应式。
核心语法:三行代码建立第一个容器
容器查询的语法出奇地简单,核心只有两步:先声明谁是容器,再写条件。
.card-wrap {
container-type: inline-size;
container-name: card; /* 可选,但强烈建议命名 */
}
/* 也可以写成简写 */
.card-wrap {
container: card / inline-size;
}
/* 第二步:用 @container 写条件 */
@container card (min-width: 400px) {
.product-card {
flex-direction: row;
gap: 16px;
}
}
/* 不写名字也可以,就近匹配最近的容器 */
@container (min-width: 400px) {
.product-card { flex-direction: row; }
}
这里有两个容易踩坑的细节:
- 容器不能查询自己。
@container只对“容器的后代元素”生效。如果你给.product-card自己加了container-type,再写@container去改它自己的样式,是不会生效的。 - 命名不是可有可无的装饰。当页面里存在多个嵌套容器时,不命名会让浏览器采用“最近祖先”策略,结果往往出人意料。命名能让规则精确落位。
媒体查询与容器查询的语法对照
| 维度 | 媒体查询 | 容器查询 |
|---|---|---|
| 规则前缀 | @media |
@container |
| 作用对象 | 整个视口 / 设备 | 最近(或指定名字)的容器 |
| 需要声明 | 不需要 | 父元素需设置 container-type |
| 可用单位 | px / em / rem 等 |
同左,另加 cqw / cqi 等容器单位 |
| 典型场景 | 页面级栅格、导航折叠 | 组件级布局切换、卡片自适应 |
两者并不是非此即彼的关系。一个成熟的项目通常是这样分工的:页面骨架用媒体查询,组件内部用容器查询。前者决定“页面有几种宏观布局”,后者决定“组件在不同空间里怎么表现”。
container-type 三种取值:你在建立什么样的容器
container-type 决定了容器能响应哪种查询。它的选择不仅影响能不能查,还会影响元素的布局行为,这一点经常被忽略。
.a { container-type: normal; }
/* 元素行为完全不变,但可以用于 style() 查询 */
/* inline-size:只查询水平方向(最常用) */
.b { container-type: inline-size; }
/* 等价于同时应用: */
/* contain: layout inline-size style; */
/* 元素的宽度由外部决定,不受内部内容影响 */
/* size:水平和垂直方向都可查询 */
.c {
container-type: size;
width: 100%;
height: 300px; /* 必须有明确高度 */
}
/* 等价于 contain: size layout style paint; */
/* 尺寸完全独立,不依赖内容撑开 */
/* style:只查询自定义属性,不查询尺寸 */
.d { container-type: style; }
inline-size 的副作用:宽度不再被内容撑开
这是最容易让人困惑的一点。给一个元素设置 container-type: inline-size 后,它会应用 contain: inline-size,这意味着元素的宽度不再受内容影响。
举个例子:一个 display: inline-block 的标签,原本宽度由文字长度决定。一旦加上 container-type: inline-size,它的宽度就会变成由外部约束(通常是撑满父容器或收缩为 0)。如果你只是想让一个行内元素变成容器,记得额外给它一个明确的宽度或 display: block。
.badge {
display: inline-block;
container-type: inline-size;
/* 宽度不再是文字宽度,可能塌成 0 */
}
/* ✅ 修正:明确宽度,或改用块级布局 */
.badge {
display: inline-block;
container-type: inline-size;
width: fit-content; /* 显式指定 */
min-width: 4em;
}
/* ✅ 更好的做法:容器放在外层,组件放在内层 */
.badge-wrap {
container-type: inline-size;
display: inline-block;
}
.badge { display: inline-block; }
size 类型的适用场景
container-type: size 需要容器在两个方向上都有明确尺寸,因此它适合那些尺寸已经由外部完全确定的场景,例如:
- 固定高度的图表容器,内部图例要根据高度决定横排还是竖排;
- 画布类组件(地图、可视化面板),需要同时感知宽高;
- 全屏转场中的媒体容器,需要按宽高比决定裁切方式。
对于普通的响应式卡片,inline-size 几乎总是更安全、更合适的选择。因为它的约束更弱,不会让元素意外塌陷。
交互实验室:亲手拖一拖容器宽度
下面这个实验台把容器宽度暴露成一个滑块。拖动滑块,你会看到同一份 HTML 结构在窄容器里是纵向布局,宽过 340px 后切换成横向,再宽过 520px 后缩略图放大、标题字号提升。这就是容器查询的全部魔力——它不关心你的屏幕有多大,只关心自己住在多大的盒子里。
观察要点:
- 容器宽度在 0~339px 时,卡片是纵向布局,缩略图占满整行;
- 跨过 340px,卡片变成横向,缩略图固定为 80×80;
- 跨过 520px,缩略图放大到 112×112,标题与正文同步放大;
- 整个过程没有任何 JS 参与布局计算——JS 只负责改变容器宽度,其余全部由 CSS 完成。
.cq-stage {
container-type: inline-size;
container-name: demo;
}
.cq-card {
display: flex;
flex-direction: column;
gap: 12px;
}
@container demo (min-width: 340px) {
.cq-card {
flex-direction: row;
align-items: center;
}
.cq-card-thumb {
width: 80px;
height: 80px;
}
}
@container demo (min-width: 520px) {
.cq-card-thumb {
width: 112px;
height: 112px;
}
.cq-card-body h4 { font-size: 1.2rem; }
}
容器查询单位:让尺寸随容器缩放
除了条件判断,容器查询还带来了一整套新的长度单位。它们以容器尺寸为基准,而不是视口尺寸——这意味着一个组件内部的字号、间距、圆角都可以随容器等比缩放。
| 单位 | 含义 | 典型用途 |
|---|---|---|
cqw |
容器宽度的 1% | 与容器宽度联动的尺寸 |
cqh |
容器高度的 1% | 需要容器高度的场景(size 类型) |
cqi |
容器内联轴尺寸的 1% | 横向书写模式下等价于 cqw |
cqb |
容器块轴尺寸的 1% | 纵向书写模式下等价于 cqh |
cqmin |
cqi 与 cqb 中较小者 |
需要保证不溢出的场景 |
cqmax |
cqi 与 cqb 中较大者 |
需要保证足够醒目的场景 |
拖动滑块,你会看到标题字号、进度条宽度、说明文字同步缩放。这种效果如果用 vw 实现,就会跟着视口变化——而视口没变的时候,组件就不会有任何响应。容器单位让“缩放”第一次成为组件自己的行为。
.stat-wrap {
container-type: inline-size;
}
.stat-card {
padding: 4cqi;
border-radius: 2cqi;
}
.stat-value {
/* 数字随容器放大,永远占据视觉重心 */
font-size: clamp(1.5rem, 12cqi, 3.5rem);
line-height: 1.1;
}
.stat-label {
font-size: clamp(0.7rem, 3.5cqi, 1rem);
letter-spacing: 0.04em;
}
/* ⚠️ 不要滥用:容器单位也会放大你的失误 */
/* 容器很窄时 12cqi 可能小于可读下限 */
/* 因此永远配合 clamp() 使用 */
容器单位 vs 视口单位:一次直观对照
.hero-title { font-size: 5vw; }
/* 侧边栏里的同一组件,字号依然巨大,撑破布局 */
/* 容器单位:跟着组件所在的盒子走 */
.hero-title { font-size: 7cqi; }
/* 放进侧边栏自动变小,放进主区域自动变大 */
/* 现代写法:两者结合,兼顾全局节奏与局部适配 */
.hero-title {
font-size: clamp(1.25rem, 4vw + 3cqi, 3rem);
}
双容器对比:同一组件,两种命运
下面这份对比图把完全相同的 HTML 结构分别放进 280px 和 600px 的容器里。请横向滚动查看:窄容器里它是纵向卡片,宽容器里它自动变成横向布局。整个过程没有任何额外类名、没有任何媒体查询。
<div class="dual-container" style="width:280px">
<div class="mini-card"> ... </div>
</div>
<div class="dual-container" style="width:600px">
<div class="mini-card"> ... </div>
</div>
/* CSS 只写一次 */
.dual-container {
container-type: inline-size;
container-name: duo;
}
@container duo (min-width: 360px) {
.mini-card { flex-direction: row; }
.mini-thumb { width: 76px; height: 76px; }
}
@container duo (min-width: 540px) {
.mini-thumb { width: 104px; height: 104px; }
}
这个能力为什么重要
在组件库、设计系统、微前端的语境下,组件会被放进无数种容器里。如果组件只能靠外部传类名来适配,那它就不是真正可复用的组件,只是一个“半成品”。容器查询把适配逻辑收回到组件内部,让组件真正具备了自包含的响应能力。
实战一:打造一个自包含的卡片组件
让我们把前面的知识整合成一个完整的、可以直接放进项目的卡片组件。它需要满足:在任何容器里都能自适应、层级清晰、字号可读、动效自然。
/* 外部容器负责建立查询上下文,组件本身不假设任何宽度 */
<div class="card-host">
<article class="card">
<div class="card__media">...</div>
<div class="card__body">
<h3 class="card__title">...</h3>
<p class="card__desc">...</p>
<div class="card__meta">...</div>
</div>
</article>
</div>
/* ========== 容器声明 ========== */
.card-host {
container-type: inline-size;
container-name: card;
}
/* ========== 基础样式(移动优先) ========== */
.card {
display: flex;
flex-direction: column;
gap: 12px;
background: #fff;
border-radius: 12px;
padding: clamp(12px, 4cqi, 20px);
box-shadow: 0 1px 4px rgba(15,23,42,.08);
}
.card__media {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 8px;
object-fit: cover;
flex-shrink: 0;
}
.card__title {
font-size: clamp(1rem, 5cqi, 1.35rem);
line-height: 1.35;
}
.card__desc {
font-size: clamp(0.82rem, 3.4cqi, 0.95rem);
color: #64748b;
line-height: 1.65;
}
/* ========== 断点一:够宽了,改横排 ========== */
@container card (min-width: 360px) {
.card {
flex-direction: row;
align-items: center;
gap: 16px;
}
.card__media {
width: 96px;
aspect-ratio: 1 / 1;
}
}
/* ========== 断点二:很宽了,展示更多信息 ========== */
@container card (min-width: 560px) {
.card { gap: 22px; }
.card__media {
width: 140px;
}
.card__meta {
display: flex; /* 窄容器里隐藏的元信息,宽容器里显示 */
gap: 8px;
}
}
/* ========== 容器极窄时的兜底 ========== */
@container card (max-width: 220px) {
.card__desc { display: none; }
.card__media { aspect-ratio: 4 / 3; }
}
三个设计要点
- 基础样式面向最窄场景。默认就是纵向、紧凑、字号偏小;容器变宽时才逐步“解锁”更多布局可能。这与移动优先的思路一致,只是判断依据从视口变成了容器。
- 用
clamp()兜住字号。容器单位负责缩放,clamp()负责保证可读性。只写5cqi在极窄容器里会小到看不清,在极宽容器里又可能大得离谱。 - 条件性显示信息。容器窄的时候隐藏次要描述,宽的时候再展示——这是容器查询相对于媒体查询的独特优势,因为同一个页面里不同位置的组件状态可以完全不同。
container-type,然后写 @container 改卡片自己的布局。容器不能查询自身,这条规则永远不会生效。
.card-host)承担容器角色,卡片本身保持纯粹的组件样式。
实战二:自适应侧边栏与主内容布局
页面级的双栏布局也可以从容器查询中获益。设想一个仪表盘:左侧是筛选面板,右侧是数据区。当筛选面板本身被折叠得很窄时,它内部的表单应该自动变成图标模式或纵向堆叠——而这取决于它自己的宽度,不是视口。
.dashboard {
display: grid;
grid-template-columns: auto 1fr;
gap: 20px;
}
/* 侧边栏本身成为查询容器 */
.filters {
container-type: inline-size;
container-name: filters;
width: 260px;
transition: width .3s ease;
}
.filters.collapsed { width: 72px; }
/* ========== 极窄:只显示图标 ========== */
@container filters (max-width: 100px) {
.filter-group__label {
/* 视觉隐藏但保留给屏幕阅读器 */
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
.filter-group {
display: flex;
flex-direction: column;
align-items: center;
}
}
/* ========== 中等宽度:标签在上,控件在下 ========== */
@container filters (min-width: 101px) and (max-width: 220px) {
.filter-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.filter-group__label {
font-size: 0.78rem;
}
}
/* ========== 宽敞:标签与控件同一行 ========== */
@container filters (min-width: 221px) {
.filter-group {
display: grid;
grid-template-columns: 80px 1fr;
align-items: center;
gap: 10px;
}
}
.main-panel {
container-type: inline-size;
container-name: main;
}
/* 数据网格:按主区域宽度决定列数 */
.metric-grid {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
@container main (min-width: 420px) {
.metric-grid { grid-template-columns: repeat(2, 1fr); }
}
@container main (min-width: 720px) {
.metric-grid { grid-template-columns: repeat(4, 1fr); }
}
@container main (min-width: 1080px) {
.metric-grid { grid-template-columns: repeat(6, 1fr); }
}
/* 传统做法:媒体查询只能按视口猜 */
@media (min-width: 1200px) {
.metric-grid { grid-template-columns: repeat(4, 1fr); }
}
/* 侧边栏展开/折叠时,媒体查询完全无感 */
为什么这在仪表盘里特别有价值
仪表盘的特点是布局状态多变:用户可以折叠侧边栏、可以拖拽分栏、可以全屏某个面板。这些变化都不改变视口宽度,但都会改变组件的可用空间。用媒体查询处理这些场景,你几乎必然要写一堆状态类;用容器查询,组件自己就能感知空间变化,代码量反而更少。
实战三:从数据表到卡片列表的无缝切换
数据表格是响应式设计里最头疼的组件之一。传统做法是:视口小于 768px 时把表格转成卡片。但如果这个表格被放在一个窄的面板里呢?视口很宽,表格依然会被挤爆。
容器查询让表格可以真正按“自己有多宽”来决定形态。
<div class="table-host">
<table class="data-table">
<thead><tr><th>名称</th><th>状态</th><th>金额</th></tr></thead>
<tbody>
<tr>
<td data-label="名称">订单 A</td>
<td data-label="状态">已支付</td>
<td data-label="金额">¥ 1,280</td>
</tr>
</tbody>
</table>
</div>
/* ========== 容器声明 ========== */
.table-host {
container-type: inline-size;
container-name: table;
}
/* ========== 表格态(宽容器) ========== */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
}
/* ========== 卡片态(窄容器) ========== */
@container table (max-width: 520px) {
.data-table thead {
/* 表头视觉隐藏,但保留给屏幕阅读器 */
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
.data-table tbody tr {
display: block;
background: #fff;
border-radius: 10px;
padding: 12px;
margin-bottom: 10px;
box-shadow: 0 1px 4px rgba(15,23,42,.08);
}
.data-table td {
display: flex;
justify-content: space-between;
padding: 6px 0;
border: none;
}
.data-table td::before {
content: attr(data-label);
font-weight: 600;
color: #64748b;
font-size: 0.78rem;
}
}
/* ========== 极窄:进一步精简 ========== */
@container table (max-width: 300px) {
.data-table td {
flex-direction: column;
gap: 2px;
}
}
这个方案的三个优势
- 一份 HTML,两种形态。不需要为移动端单独渲染一套 DOM,也就不存在“两套结构不同步”的维护噩梦。
- 无障碍友好。表头用视觉隐藏而非
display: none,屏幕阅读器依然能正确读出表格语义;data-label让卡片态的每个单元格都有明确的字段名。 - 位置无关。表格放进主内容区就显示为表,放进窄侧栏就变成卡片列表——不需要任何外部干预。
命名容器与嵌套:当页面里有多个容器
真实项目里,容器往往不止一层。一个卡片放在列表里,列表放在侧边栏里,侧边栏又放在页面栅格里——每一层都可能成为容器。这时候命名就成了精确控制的关键。
.page-grid {
container-type: inline-size;
container-name: page;
}
.sidebar {
container-type: inline-size;
container-name: sidebar;
}
.card-host {
container-type: inline-size;
container-name: card;
}
/* ========== 精确指定查询哪一层 ========== */
/* 只看最近的 card 容器 */
@container card (min-width: 400px) {
.card { flex-direction: row; }
}
/* 只看 sidebar 容器(跳过中间的 card) */
@container sidebar (min-width: 600px) {
.card { box-shadow: 0 4px 12px rgba(15,23,42,.12); }
}
/* ========== 一个元素可以同时有多个名字 ========== */
.card-host {
container-name: card card-host-host;
}
/* ========== 也可以用简写一次性声明 ========== */
.card-host {
container: card / inline-size;
}
/* ========== 没有名字时:就近匹配最近的可查询容器 ========== */
@container (min-width: 400px) {
/* 谁最近就查谁,行为可能随 DOM 变化而改变 */
/* 因此生产环境建议始终命名 */
.card { flex-direction: row; }
}
容器名称的命名规范建议
- 按语义命名,不按尺寸:用
card、sidebar、main,而不是narrow、wide。因为容器的宽度是变化的,语义是不变的。 - 避免与元素类名冲突:容器名有独立的命名空间,与类名互不干扰,但为了避免阅读混乱,建议加一个统一前缀,如
cq-card。 - 在组件根节点声明容器:让组件的“容器身份”一目了然,而不是藏在某个中间层。
- 不要给所有元素都加容器类型:每个
container-type都会引入 containment 约束,可能改变布局行为,也会带来额外开销。
/* 统一前缀,语义清晰,避免歧义 */
.cq-card-host {
container: cq-card / inline-size;
}
.cq-sidebar-host {
container: cq-sidebar / inline-size;
}
.cq-main-host {
container: cq-main / inline-size;
}
/* 使用时语义明确,一眼可知查询的是哪一层 */
@container cq-card (min-width: 400px) { ... }
@container cq-sidebar (min-width: 280px) { ... }
style() 查询:不止尺寸,还能查询样式状态
容器查询的能力不止于尺寸。通过 @container style(),你可以查询容器上的自定义属性值,从而实现主题切换、状态驱动的样式变化。这是容器查询里最被低估的能力。
.style-widget {
container-type: style; /* 注意:不是 inline-size */
--theme: light;
}
/* ========== 按自定义属性值匹配 ========== */
@container style(--theme: dark) {
.style-widget {
background: #0f172a;
border-color: #1e293b;
}
.style-widget .sw-title { color: #f8fafc; }
.style-widget .sw-text { color: #94a3b8; }
}
@container style(--theme: brand) {
.style-widget {
background: linear-gradient(135deg, #2563eb, #1d4ed8);
color: white;
}
}
/* ========== 也可以做布尔式判断 ========== */
/* 只要 --dense 有值(非 initial)就命中 */
@container style(--dense) {
.list-row { padding: 4px 8px; }
}
/* ========== 结合尺寸查询一起使用 ========== */
.panel {
container: panel / inline-size style;
--mode: compact;
}
@container panel (min-width: 600px) and style(--mode: compact) {
/* 同时满足尺寸与样式条件 */
.panel-body { columns: 2; }
}
style() 查询的典型用途
- 主题注入:外层容器设置
--theme: dark,内部所有组件自动切换暗色样式,无需给每个组件传参。 - 密度控制:表格容器设置
--dense,内部的单元格、按钮自动收紧内边距。 - 状态驱动:容器设置
--state: loading,内部自动显示骨架屏、禁用交互。 - 布局变体:同一个组件在
--layout: compact的容器里显示精简版,在默认容器里显示完整版。
需要注意:style() 查询目前主要用于自定义属性,不能查询任意 CSS 属性值(例如查询 display 或 color 是不行的)。它的设计意图就是让开发者通过自定义属性传递语义状态,因此使用时也要遵循这个约定。
scroll-state() 查询:感知滚动与吸附状态
容器查询家族还在持续扩展。@container scroll-state() 让元素可以感知自己是否被吸附(stuck)、是否可滚动、是否滚动到了边缘——这些过去只能靠 JS 监听 scroll 事件才能实现的能力,现在直接写进 CSS。
/* 让一个 sticky 表头在被吸附时改变样式 */
.scroll-area {
container-type: scroll-state;
overflow-y: auto;
height: 400px;
}
.sticky-header {
position: sticky;
top: 0;
background: #fff;
transition: box-shadow .2s ease;
}
@container scroll-state(stuck: top) {
.sticky-header {
box-shadow: 0 4px 12px rgba(15,23,42,.12);
border-bottom: 1px solid #e2e8f0;
}
}
/* ========== 感知滚动方向与位置 ========== */
/* 滚动到顶部时隐藏“回到顶部”按钮 */
@container scroll-state(scrollable: top) {
.back-to-top { opacity: 1; pointer-events: auto; }
}
@container scroll-state(scrollable: none) {
.back-to-top { opacity: 0; pointer-events: none; }
}
/* ========== 感知是否溢出 ========== */
@container scroll-state(overflowing: bottom) {
.fade-mask { opacity: 1; }
}
/* ========== 传统 JS 方案的对比 ========== */
/* 需要在 scroll 事件里读取 scrollTop、比较阈值 */
/* 再手动加/删 class —— 容易掉帧,也容易写漏 */
area.addEventListener('scroll', () => {
const stuck = area.scrollTop > header.offsetTop;
header.classList.toggle('is-stuck', stuck);
}, { passive: true });
能力边界与使用建议
- 目前支持度较低:
scroll-state()在 Chrome 133+ 才逐步可用,生产环境务必配合@supports与 JS 退化方案。 - 适合纯视觉反馈:吸附阴影、渐隐遮罩、回到顶部按钮显隐——这些“有更好,没有也能用”的效果最适合用它。
- 不适合承载关键逻辑:不要用它来控制业务数据的加载或权限判断,那不是 CSS 的职责。
与 Grid、Flex 的配合:让布局引擎先干活
容器查询不是万能的,它擅长的是“在给定空间里做取舍”。而很多自适应问题,其实布局引擎自己就能解决——用容器查询之前,先确认是不是在重复造轮子。
/* ✅ 用 auto-fit + minmax,一行代码搞定 */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
}
/* 不需要任何查询,浏览器自动决定每行几个 */
/* ========== 场景二:侧边栏宽度由内容决定 ========== */
/* ✅ 用 Grid 的 auto 轨道 */
.layout {
display: grid;
grid-template-columns: minmax(200px, 280px) 1fr;
gap: 20px;
}
/* ========== 场景三:真正需要容器查询的时刻 ========== */
/* 当布局已定,但组件内部需要“重新排版”时 */
.card-host {
container: card / inline-size;
}
/* 这才是容器查询的主场:内部结构切换 */
@container card (min-width: 420px) {
.card { flex-direction: row; }
.card__media { width: 120px; aspect-ratio: 1; }
.card__meta { display: flex; }
}
决策流程:什么时候该用容器查询
一个实用的经验:先让布局引擎做它能做的事,再用容器查询处理它做不到的事。很多开发者一上来就写一堆 @container,结果发现代码量大、断点难维护,反而不如 repeat(auto-fit, minmax()) 来得干净。
性能考量与常见陷阱
容器查询本身不会让页面变慢——它读取的是浏览器已有的布局信息,判断成本极低。但使用不当仍然会踩坑,尤其是对 containment 约束理解不足的时候。
.everything {
container-type: inline-size;
}
/* 每个元素都变成容器,宽度不再被内容撑开,布局崩塌 */
/* ❌ 陷阱二:size 类型但没有固定高度 */
.panel {
container-type: size;
/* 没有 height,容器高度为 0,内部全部塌陷 */
}
/* ❌ 陷阱三:容器查询自身 */
.box {
container-type: inline-size;
}
@container (min-width: 400px) {
.box { padding: 20px; } /* 不会生效 */
}
/* ❌ 陷阱四:断点套用设备尺寸 */
@container (min-width: 768px) { ... }
/* 768 是视口断点,容器宽度通常远小于它 */
/* 结果:规则永远不触发,或触发得莫名其妙 */
/* ✅ 正解:按组件自身的内容临界点定断点 */
/* 把卡片从 500px 慢慢收窄,观察: */
/* 大约 360px 时横排开始拥挤 → 断点定在 360px */
@container card (min-width: 360px) { ... }
关于性能的三个事实
- 容器查询的判断本身几乎不花钱。浏览器在布局阶段就已经知道每个容器的尺寸,查询只是读取并比较,不产生额外的重排。
- 代价在于 containment 约束。当元素声明了
container-type,它内部的变化不会再影响外部布局,这通常减少计算量;但如果因此需要额外的显式尺寸,可能引入新的布局约束。 - 真正需要警惕的是过渡动画。容器宽度变化时,如果内部有大量基于容器查询的样式切换,并且都带
transition,可能会同时触发多组布局计算。建议只给必要属性加过渡,并且时长控制在 300ms 以内。
调试容器查询的实用技巧
.card-host {
container: card / inline-size;
}
/* 调试期临时打开,用 outline 标出容器边界 */
.debug-containers .card-host {
outline: 2px dashed #ef4444;
outline-offset: -2px;
}
/* ========== 技巧二:用伪元素显示容器当前尺寸 ========== */
.debug-containers .card-host::after {
content: 'card container';
position: absolute;
top: 0;
left: 0;
background: #ef4444;
color: white;
font-size: 10px;
padding: 2px 6px;
border-radius: 0 0 4px 0;
}
/* ========== 技巧三:DevTools 中的容器标记 ========== */
/* Elements 面板中,容器元素会显示 container 徽章 */
/* Styles 面板中,@container 规则会标注命中状态 */
/* 未命中的规则会灰显,方便快速定位 */
兼容性与渐进增强策略
容器查询的浏览器支持已经相当不错。尺寸查询(inline-size)在主流浏览器中都可用,而较新的 style()、scroll-state() 则还需要时间。
/* 默认:窄容器布局,所有浏览器都能正确显示 */
.card {
display: flex;
flex-direction: column;
gap: 12px;
}
/* 用 @supports 检测容器查询能力 */
@supports (container-type: inline-size) {
.card-host {
container: card / inline-size;
}
@container card (min-width: 400px) {
.card { flex-direction: row; }
}
}
/* ========== JS 侧的特性检测 ========== */
const hasCQ = CSS.supports('container-type', 'inline-size');
const hasStyleCQ = CSS.supports('container-type', 'style');
if (!hasCQ) {
/* 退化:用 ResizeObserver 手动计算并加类 */
const ro = new ResizeObserver((entries) => {
entries.forEach((e) => {
e.target.classList.toggle('is-wide', e.contentRect.width >= 400);
});
});
document.querySelectorAll('.card-host').forEach((el) => ro.observe(el));
}
/* 对应的退化 CSS */
.card-host.is-wide .card {
flex-direction: row;
}
退化策略的选择
- 纯视觉增强:例如吸附阴影、渐隐遮罩。不支持时效果消失,但功能完全正常——最省事的退化方式。
- 布局切换:例如卡片横排。退化时保持纵向布局,虽然不够好看,但依然可读可用。
- 关键信息隐藏:如果某个信息只在宽容器里显示,请确保它在窄容器里不是必需的,否则退化后用户会丢失信息。
- 需要精确控制时:用
ResizeObserver做 JS 退化,代价是要维护两套逻辑,仅在必要时使用。
容器查询最佳实践清单
把全文的要点浓缩成一份可以直接贴在工位上的清单。
- 媒体查询管页面,容器查询管组件:两者分工明确,不要用其中一个替代另一个。
- 容器类型优先选
inline-size:只有在确实需要感知高度时才用size。 - 始终给容器命名:匿名查询依赖 DOM 就近匹配,脆弱且难以维护。
- 容器不能查询自己:把
container-type放在外层宿主元素上,组件本身保持干净。 - 断点由内容决定:慢慢收窄容器,找到它开始“难看”的那个宽度,那就是断点。
- 优先用布局引擎的能力:
auto-fit、minmax()、flex-wrap能解决的,别写查询。 - 用
clamp()限制容器单位:clamp(0.8rem, 3cqi, 1.2rem)比裸写3cqi安全得多。 - 注意
inline-size的宽度约束:元素宽度不再被内容撑开,必要时显式指定尺寸。 - 不要给所有元素加容器类型:容器数量爆炸会带来不必要的 containment 开销。
- 过渡动画只加在必要属性上:容器宽度变化时,避免同时触发大量布局计算。
- 用
@supports包裹新特性:style()与scroll-state()需要退化路径。 - 调试时给容器加可视化边框:配合 DevTools 的 container 徽章,快速定位命中情况。
- 组件库优先采用容器查询:这是让组件真正自包含、可复用的关键一步。
- 在真实内容下测试:占位文字和真实文案的换行行为差异很大,断点必须用真实内容验证。
/* 1. 组件宿主:统一声明容器 */
.cq-host {
container-type: inline-size;
container-name: cq;
}
/* 2. 组件基础样式:面向最窄场景 */
.cq-item {
display: flex;
flex-direction: column;
gap: 12px;
padding: clamp(12px, 4cqi, 20px);
}
/* 3. 内容尺寸连续缩放 */
.cq-item__title {
font-size: clamp(1rem, 5cqi, 1.35rem);
}
.cq-item__desc {
font-size: clamp(0.82rem, 3.4cqi, 0.95rem);
}
/* 4. 结构切换断点 */
@container cq (min-width: 360px) {
.cq-item {
flex-direction: row;
align-items: center;
}
}
/* 5. 更宽时的信息增强 */
@container cq (min-width: 560px) {
.cq-item__meta { display: flex; }
}
/* 6. 极窄时精简内容 */
@container cq (max-width: 220px) {
.cq-item__desc { display: none; }
}
/* 7. 渐进增强包裹 */
@supports (container-type: inline-size) {
/* 容器查询规则集中放在这里 */
}
容器查询真正改变的,不是某一行 CSS 的写法,而是我们思考响应式的起点。过去我们习惯从“屏幕有多大”出发,把页面切成几档;现在我们可以从“组件住在哪里”出发,让每个组件自己决定该如何呈现。当组件库里的每一个成员都具备这种能力时,页面级的响应式设计就会变得前所未有的轻松——因为你不再需要为每一种使用场景准备一个变体。
建议你在下一个项目里,挑一个最常被复用的组件(卡片、列表项、表单项都行),把它改成容器查询驱动。改完之后,试着把它放进最窄和最宽的两个位置,看看它是否都能自然成立。那一刻你大概会意识到:原来响应式设计可以这么省心。