CSS 容器查询实践:组件响应式不应该只盯着屏幕宽度
很长一段时间里,前端做响应式布局主要依赖媒体查询:
1@media (max-width: 768px) { 2 .card { 3 flex-direction: column; 4 } 5}
这种方式适合页面级布局,但在组件化时代经常不够用。因为组件关心的不是屏幕有多宽,而是“我自己被放进了多宽的容器”——同一个卡片组件,放在首页三列网格里可能很窄,放在详情页主栏里可能很宽,放在侧边栏里又更窄。如果只按视口宽度判断,组件很容易在某些布局里表现不对。
最能说明问题的是后台仪表盘这类场景。同一个数据卡片做了三套媒体查询样式:列表页一套、详情页一套、看板拖拽布局又一套。后来产品说要支持自由拖拽分栏,卡片可能出现在任意宽度的格子里,这套媒体查询就彻底失控了——视口宽度根本无法描述卡片到底有多少可用空间。用满屏的 @media 断点凑出来的东西,在 1440 屏幕下把一个被拖到 300px 窄格里的卡片渲染成横排,文字全挤成一坨。
CSS Container Queries 解决的正是这个问题。
媒体查询的问题
假设有一个文章卡片:
1<article class="post-card"> 2 <img class="post-card__cover" src="/cover.jpg" alt="" /> 3 <div> 4 <h2>React Server Components 实践</h2> 5 <p>一篇关于服务端组件渲染的文章。</p> 6 </div> 7</article>
我们希望宽的时候图片在左,窄的时候图片在上:
1.post-card { 2 display: flex; 3 gap: 16px; 4} 5 6@media (max-width: 768px) { 7 .post-card { 8 flex-direction: column; 9 } 10}
问题是:当屏幕宽度大于 768px,但卡片被放在一个 320px 的侧边栏里时,它仍然会保持横向布局。卡片自己已经很窄了,但媒体查询看的是整个视口,所以判断错了。
过去我们绕这个问题的办法很丑陋。常见的是给组件加 modifier,比如 .post-card--narrow,让外层布局根据自己的上下文去挂这个 class——组件不再“自洽”,正确渲染依赖调用方记得传对修饰符,一旦有人复制粘贴新页面忘了加,卡片就崩了。再激进一点的团队会上 ResizeObserver,在 JS 里测量元素宽度然后切 class,能用,但首屏闪烁、SSR 拿不到尺寸、滚动时高频触发回调这些破事,维护成本不低。
这就是组件响应式和页面响应式的差异。容器查询的意义在于,它把“我有多宽”这个判断权还给了组件本身,不用调用方配合,也不用 JS 兜底。
容器查询的基本写法
使用容器查询,第一步是声明一个容器:
1.post-card-wrapper { 2 container-type: inline-size; 3}
然后组件可以根据容器宽度调整:
1.post-card { 2 display: flex; 3 gap: 16px; 4} 5 6@container (max-width: 420px) { 7 .post-card { 8 flex-direction: column; 9 } 10 11 .post-card__cover { 12 width: 100%; 13 } 14}
完整结构:
1<div class="post-card-wrapper"> 2 <article class="post-card"> 3 <img class="post-card__cover" src="/cover.jpg" alt="" /> 4 <div> 5 <h2>React Server Components 实践</h2> 6 <p>一篇关于服务端组件渲染的文章。</p> 7 </div> 8 </article> 9</div>
现在卡片关心的是 wrapper 的宽度,而不是屏幕宽度,它放在哪里都能根据自己的可用空间调整。这里有个我一开始没绕明白的点:@container 查询的是祖先里最近的那个声明了 container-type 的元素,而不是组件自己。所以 wrapper 这层是必须的——你不能在 .post-card 上既声明 container-type 又用 @container 改它自己的布局。我第一次试的时候把容器声明在卡片本身,然后纳闷为什么 @container (max-width: 420px) 一直不生效,调了半天才反应过来:元素查不到自己当容器,它得往上找。这是容器查询和媒体查询心智模型上最大的不同,媒体查询是全局的,容器查询是上下文的。
另外提醒一句,声明了 container-type: inline-size 的元素,它的行内尺寸不再受内容撑开影响——也就是说容器宽度由外部布局决定,子元素再宽也不会把它顶大。这正是我们想要的行为(不然就循环依赖了),但如果你原来靠内容撑宽的某个 div 突然塌了,多半就是这个原因。
给容器命名
如果页面里有多个容器,可以给容器命名:
1.article-layout { 2 container-name: article; 3 container-type: inline-size; 4}
查询时指定容器:
1@container article (min-width: 720px) { 2 .toc { 3 display: block; 4 } 5}
命名容器适合复杂页面,避免组件误匹配最近的匿名容器。但不要滥用命名,大多数组件只需要最近容器,匿名容器已经足够。我真正用到命名的场景,是嵌套容器。比如一个文章布局里,外层 .article-layout 是容器,里面又有一个 .comment-list 也声明成容器,那么评论项里写 @container (max-width: 400px) 默认查的是最近的 .comment-list,而我想根据整篇文章的栏宽去决定要不要显示侧边目录,就只能 @container article (min-width: 720px) 显式跳到外层。没有命名的话,嵌套一深就分不清查的是哪一层了。我的经验是:除非明确遇到嵌套且需要跨层查询,否则不要提前命名,留着匿名反而省心。
还有个细节,container 是简写:
1.article-layout { 2 container: article / inline-size; 3}
斜杠前是名字,后是类型,等价于分别写 container-name 和 container-type。日常我更倾向写全称,review 时更直观,简写在 diff 里容易看走眼。
inline-size 和 size 的区别
常用的是:
1container-type: inline-size;
它只关注行内方向尺寸。对于大多数横向布局,也就是宽度。
还有:
1container-type: size;
它同时关注宽度和高度,但会带来更强的布局约束。实际项目里我很少默认使用 size,除非组件确实要根据高度变化。size 的坑在于:它要求容器在块方向(通常是高度)上也是“确定”的,浏览器需要在不看内容的情况下就知道容器多高。换句话说,你得给它一个明确的高度,否则很容易出现高度塌成 0、内容溢出的情况。我踩过一次,给一个本来高度自适应的卡片加了 container-type: size,结果整个卡片直接塌没了,因为它原本的高度是被内部文字撑开的,而 size 把这条路堵死了。inline-size 只约束行内方向,块方向照旧由内容撑开,所以绝大多数横排/竖排切换的需求用它就够,也安全。
真要用 size 的场景我目前只遇到过一个:一个固定高度的图表容器,需要在矮的时候隐藏图例、高的时候显示,这种容器高度本来就是写死的,加 size 没有副作用。大部分响应式组件只需要 inline-size,少用更强约束可以减少意外布局问题。
为什么 container-type 会改变盒子行为:从 containment 说起
上面提到 inline-size 会让元素的行内尺寸不再被内容撑开,size 还会连块方向一起锁住。这不是 bug,而是规范里写死的语义,理解它得回到 CSS Containment 这套机制。
容器查询本质上是 containment 的一个应用。规范(CSS Containment Module Level 3)规定,container-type: inline-size 等价于给元素隐式打开了 contain: layout inline-size,container-type: size 则是 contain: layout size。contain 的含义是“向浏览器承诺:这个子树的某些影响不会泄漏到外面,外面的某些信息也不需要喂进来”。
为什么必须这样?想象一下如果不加约束会发生什么:.post-card 根据容器宽度从横排切成竖排,竖排之后它自己的内容高度变了;如果容器尺寸又反过来依赖内容,那么“容器尺寸 → 触发查询 → 改变布局 → 改变内容尺寸 → 改变容器尺寸”就形成了一个无限循环。浏览器为了能在一趟布局里收敛,必须要求容器的尺寸在“查询发生前”就是确定的、不依赖内部内容的。size containment 的作用就是斩断这条反馈链——它告诉布局引擎:算这个元素自己的尺寸时,把里面的内容当成空的。
这也解释了为什么 inline-size 安全而 size 危险。inline-size 只切断了行内方向(宽度)的内容依赖,块方向(高度)照旧由内容撑开,绝大多数布局的宽度本来就由外部父容器分配,所以你几乎感觉不到约束。而 size 连高度也切断了,一旦你没显式给高度,它就会按“内容为空”去算,结果就是高度塌成 0。可以用一段最小复现验证:
1<div class="probe inline"><p>这段文字撑开高度,但撑不开宽度</p></div> 2<div class="probe size"><p>这段文字宽高都撑不开 → 整块塌成 0</p></div>
1.probe { 2 border: 2px solid; 3 margin: 12px; 4 /* 注意:故意不给宽高 */ 5} 6.probe.inline { 7 container-type: inline-size; 8 /* 宽度被锁,高度仍由 <p> 撑开 → 能看到内容 */ 9} 10.probe.size { 11 container-type: size; 12 /* 宽高都不依赖内容,没给显式高度 → 边框压成一条线 */ 13}
打开浏览器你会直接看到第二个盒子塌成一条横线。这是规范层面 containment 在起作用,不是某个浏览器的实现缺陷。顺带一个容易忽略的副作用:container-type 还会隐式创建一个新的 containing block(对 size 而言尤其明显,它带 layout containment)。这意味着容器内部的 position: fixed 元素会以这个容器为参照,而不是视口。我有个全屏弹层组件,外面被人套了一层 container-type: size 的卡片后,弹层突然只在卡片范围内“全屏”了——排查了好久才定位到是 containment 改写了 fixed 的定位基准。如果你的 fixed 元素行为诡异,检查一下祖先链上是不是有人开了容器。
声明容器也不是完全无成本的:浏览器需要为这个元素建立一套 containment 上下文。实际使用时要注意几点:不要给所有元素都加 container-type;优先加在组件外层 wrapper(前面提过,容器不能声明在组件自己身上,道理同样适用);避免依赖子元素撑开容器后再反过来查询自身;查询条件不要过于碎片化。关于性能我想说句公道话:网上有些文章把 container-type 说得很可怕,实际我在中等规模页面(几百个卡片的虚拟列表之外的普通页面)里没测出肉眼可见的差异。真正会出问题的是无脑地给列表里每一个元素都套容器,几千个 containment 上下文叠加起来确实会拖慢布局。所以原则不是“不能用”,而是“声明在该声明的那一层”——通常是可复用组件的最外层 wrapper,一个组件一个,别往里铺。
容器查询适合哪些场景
我觉得容器查询最适合下面几类组件:卡片、工具栏、数据面板、文章内容区域——共同点是会出现在两个以上宽度差异明显的位置。
卡片组件最典型,经常出现在列表、侧边栏、推荐区、详情页里:
1.product-card-wrapper { 2 container-type: inline-size; 3} 4 5.product-card { 6 display: grid; 7 grid-template-columns: 120px 1fr; 8 gap: 16px; 9} 10 11@container (max-width: 360px) { 12 .product-card { 13 grid-template-columns: 1fr; 14 } 15}
工具栏也很合适:按钮多的时候,宽容器展开文字,窄容器只显示图标(@container 里把 .toolbar__label 切成 display: none)。数据面板同理——仪表盘组件经常被拖进不同栅格区域,写法和卡片一样,只是断点换成面板自己的宽度。
文章内容区域是我后来才意识到特别合适的一类,尤其是富文本/MDX 渲染的内容。同一篇文章可能全屏阅读、也可能塞进一个右侧抽屉预览,正文容器宽度差很多。我把代码块的“是否换行 vs 横向滚动”、图片的“单列 vs 双列并排”都交给容器查询,编辑端和阅读端复用同一套样式,不用再为预览面板单独维护一份 CSS:
1.prose { 2 container-type: inline-size; 3} 4 5/* 窄容器里成对图片改成纵向堆叠 */ 6@container (max-width: 480px) { 7 .prose .figure-pair { 8 grid-template-columns: 1fr; 9 } 10}
判断一个组件该不该上容器查询,我现在的标准很简单:它会不会出现在两个以上宽度差异明显的位置?会,就值得;只在一个固定位置出现,老老实实写死或者用媒体查询反而更省事。
容器查询不替代媒体查询
容器查询很有用,但它不是媒体查询的替代品。媒体查询仍然适合页面级决策,比如顶部导航在移动端变成抽屉、页面整体从两栏变成一栏、移动端隐藏某些全局区域、根据设备特征调整 hover 行为;容器查询适合组件级决策,比如卡片横排还是竖排、标题显示几行、工具栏显示文字还是只显示图标、面板内部是一列还是多列。我的规则是:页面结构看视口,组件细节看容器。
和 Grid 配合更自然
容器查询和 CSS Grid 很搭:Grid 负责外部排布,Container Query 负责组件内部适配,各司其职。比如一个自适应卡片列表,外层宽度由 grid 决定,卡片内部再根据自己拿到的容器宽度变化:
1.post-grid { 2 display: grid; 3 grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); 4 gap: 20px; 5} 6.post-card-shell { 7 container-type: inline-size; 8} 9.post-card { 10 display: grid; 11 grid-template-columns: 140px 1fr; 12 gap: 16px; 13} 14@container (max-width: 360px) { 15 .post-card { 16 grid-template-columns: 1fr; 17 } 18}
auto-fit 网格和容器查询搭配时,CLS 从哪来
“auto-fit 网格 + 容器查询卡片”这套组合看起来很干净:网格负责外部列数,容器查询负责卡片内部排版,两边都是纯 CSS、同步生效,理论上不该有闪烁。但这套组合在移动端加上 SSR 之后,容易出现一种反直觉的布局偏移——首屏渲染后的一两百毫秒内,整个卡片列表突然从竖排跳成横排。
矛盾点在于:容器查询本身没有任何异步行为,它忠实地反映容器宽度的当前值;问题出在“容器宽度”这个输入本身可能是不稳定的。如果卡片里嵌了一个依赖 ResizeObserver 做尺寸计算的组件(常见于懒加载的图表、编辑器这类重型子组件),它只有在 hydration 完成之后才会测量并写入影响布局的 inline style。这一写,auto-fit 网格的列宽在那一刻才真正稳定下来,容器宽度跟着变化,@container 查询的结果也跟着翻转。也就是说:“正确的查询”叠加“晚到的尺寸”,视觉上就是一次跳变,用 Lighthouse 的帧级 trace 去看,能清楚看到首屏渲染后卡片从竖排到横排的这一帧断层。
修法有两条线。第一条是从源头掐断不稳定输入:把影响布局的 ResizeObserver 逻辑挪走,让宽度完全交给 CSS 决定,服务端吐出来的 HTML 配合 CSS 加载完就是最终布局,不依赖任何 JS 介入。第二条是把前面 auto-fit + minmax 那种“列数完全由内容宽度推断”的写法,换成容器查询显式分档,每一档都是确定的,不再依赖运行时才知道的中间态:
1.post-grid-shell { 2 container-type: inline-size; 3} 4.post-grid { 5 display: grid; 6 grid-template-columns: 1fr; /* 默认单列,最安全 */ 7 gap: 20px; 8} 9@container (min-width: 560px) { 10 .post-grid { grid-template-columns: repeat(2, 1fr); } 11} 12@container (min-width: 900px) { 13 .post-grid { grid-template-columns: repeat(3, 1fr); } 14}
按这个思路改完,CLS 能明显回落,因为列数变化的分界点从”运行时才知道”变成了”CSS 里写死的几个分档”。这里的判断标准可以固定下来:容器查询不会自己制造布局偏移,但它会忠实放大“容器尺寸在运行时才确定”的任何抖动。所以一旦用了容器查询,就要顺手排查这条链路上有没有 JS 在测量并回写影响布局的尺寸,尽量让尺寸从首屏起就由 CSS 一锤定音。容器查询和 SSR 是天生一对——服务端吐出的 HTML 配上 CSS 就是终态,不需要等 hydration,这恰恰是它相对 ResizeObserver 方案最大的优势,没必要自己又把 JS 测量塞回去把这个优势抵消掉。
排查这类问题时,Chrome DevTools 的 Performance 面板里有个专门的 Layout Shift 区域,鼠标悬上去能看到具体是哪个元素在哪一帧发生了偏移,比肉眼盯着页面看靠谱得多。如果怀疑是某个子组件的 JS 在中途改了尺寸,可以先用 Elements 面板临时禁用那段脚本的执行,对比两次的 CLS 曲线,能很快确认问题是不是出在这条链路上。
容器单位
容器查询还带来了一组容器单位,比如 cqw、cqh、cqi、cqb。其中 cqw 表示容器查询宽度的 1%。
例如:
1.hero-title { 2 font-size: clamp(24px, 8cqw, 56px); 3}
这表示标题字号跟随容器宽度,而不是视口宽度。
不过我不会滥用容器单位。字体、间距如果变化太频繁,页面会显得不稳定。更常见的做法是用容器查询切换几个明确状态,而不是让所有尺寸连续变化。
和 ResizeObserver 方案的正面对比
为了让“能不写 JS 就别写 JS”这件事更具体,我把过去那套 ResizeObserver 切 class 的写法和容器查询并排放一下。同样是“窄了切单列”的需求。
旧方案,JS 测量 + 切 class:
1function PostCard({ post }) { 2 const ref = useRef(null); 3 const [narrow, setNarrow] = useState(false); 4 5 useEffect(() => { 6 const el = ref.current; 7 if (!el) return; 8 const ro = new ResizeObserver(([entry]) => { 9 // 注意 contentBoxSize 在不同浏览器结构不一致,得兜底 10 const w = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width; 11 setNarrow(w < 360); 12 }); 13 ro.observe(el); 14 return () => ro.disconnect(); 15 }, []); 16 17 return ( 18 <article ref={ref} className={narrow ? "post-card post-card--narrow" : "post-card"}> 19 {/* ... */} 20 </article> 21 ); 22}
这段代码每一行都在替我处理本不该我管的事:contentBoxSize 的浏览器差异、SSR 首屏 narrow 永远是 false 导致的闪烁、组件卸载时忘了 disconnect 的泄漏、以及 ResizeObserver 回调里再 setState 触发的额外渲染。拖动分栏时这个回调会被高频触发,还得自己上 requestAnimationFrame 节流,否则掉帧。
容器查询版本,JS 一行不用,组件只是老老实实套一层 wrapper:
1.post-card-shell { container-type: inline-size; } 2.post-card { display: grid; grid-template-columns: 1fr; } 3@container (min-width: 360px) { 4 .post-card { grid-template-columns: 140px 1fr; } 5}
行为差异在于:CSS 版本是布局阶段同步求值的,服务端渲染出来就是终态,没有任何“先错后对”的中间帧;JS 版本永远要等组件挂载、等 observer 第一次回调,中间那一帧就是闪烁的来源。我现在的态度很明确——除非要把尺寸读进 JS 去做别的事(比如传给 canvas、上报埋点),否则纯展示层的响应式一律走容器查询。
顺手提一句样式查询(style queries)
容器查询规范里还有一个发展中的分支:style queries,按容器上的自定义属性值来查询,而不是尺寸。它适合“同一个组件在不同主题/语境下换皮肤”这种需求,省掉一堆 modifier class。
1.card-shell { 2 container-type: inline-size; 3} 4 5/* 父级通过自定义属性声明“语境” */ 6.sidebar .card-shell { --tone: muted; } 7.featured .card-shell { --tone: highlight; } 8 9@container style(--tone: highlight) { 10 .card { background: var(--brand-50); border-color: var(--brand-400); } 11}
到 2024 年它的浏览器支持还不如尺寸查询成熟(自定义属性的 style query 在 Chrome 系比较稳,跨浏览器还得谨慎),所以我目前只在内部工具、可控环境里用它替代部分 data-* + 属性选择器的写法。生产环境对外的页面我还是老实用尺寸查询,style query 当作一个值得关注但先别全押的方向。
渐进增强和兼容性
到 2024 年,现代浏览器对容器查询的支持已经比较可用。但如果项目需要兼容很老的浏览器,还是要用 @supports 包一层,先写基础样式再增强,并且基础样式要给最“安全”的那个状态(一般是单列)——顺序别反,如果把横排当默认、指望容器查询把它“降级”成单列,老浏览器拿到的就是撑不开的横排,体验反而更糟。我的习惯是 mobile-first 照搬过来:默认窄,往宽里加。
1.post-card { 2 display: grid; 3 grid-template-columns: 1fr; 4} 5 6@supports (container-type: inline-size) { 7 .post-card-shell { 8 container-type: inline-size; 9 } 10 11 @container (min-width: 420px) { 12 .post-card { 13 grid-template-columns: 140px 1fr; 14 } 15 } 16}
这样不支持容器查询的浏览器至少能看到稳定的一列布局,支持的浏览器获得更好的组件适配。实测下来,到 2024 下半年 Chrome、Edge、Safari、Firefox 桌面和移动端都已经稳定支持,真正还需要兜底的基本只剩一些企业内网里的老 WebView。如果你的用户盘里没有这类设备,@supports 那层其实可以省掉,直接用也没问题。
调试时的几个小技巧
容器查询不生效是新手最容易卡住的地方,分享几条我自己排错的顺序。
先确认容器声明对了位置。Chrome DevTools 的 Elements 面板里,被声明为容器的元素旁边会有一个小标记,鼠标悬上去能看到它的 container-type 和当前尺寸,这是判断“我到底在查哪一层”的最快办法。
如果查询完全没反应,九成是三种情况之一:容器声明在了组件自己身上(前面讲过,这是查不到的)、祖先链上压根没有容器(于是查询匹配到了根,永远 false)、或者写了命名查询但名字拼错了。命名拼错不会报错,只会静默失效,这点很坑。
还有一个反直觉的:@container 的查询条件不能用 em 之外的相对单位指望它跟随容器字号——容器查询长度比较里的 em 解析的是容器自身的字号,不是根字号。我有次用 @container (min-width: 30em) 调不准,就是因为容器局部改过 font-size,30em 算出来的像素跟我脑子里的不一样。拿不准的时候直接写 px 最省事。
CSS 容器查询解决的是组件化时代的响应式问题。媒体查询看视口,容器查询看组件所在空间。前者适合页面级布局,后者适合组件级适配。
我的建议是:不要为了新特性把所有媒体查询改掉,而是在卡片、面板、工具栏、文章内容这些复用组件里逐步引入容器查询。它最有价值的地方,是让组件从“假设自己在某个屏幕尺寸下”变成“根据自己实际拿到的空间工作”。
当一个组件能在主栏、侧栏、弹窗、网格里都自然适配,它才是真正可复用的组件。