Skip to main content

现代 CSS:容器查询、:has()、@layer

媒体查询看的是视口。一张卡片放进窄侧栏时,视口可能仍是桌面宽度,卡片自己却已经挤变形了。容器查询、:has() 和 @layer 解决的是三件不同的事,不要当成一套「新 CSS 语法」一起上。

1. 容器查询​

媒体查询问的是「视口有多宽」,容器查询问的是「我这个组件被塞进了多宽的地方」。同一个商品卡片放在 320px 的侧栏和 900px 的主区里,视口宽度完全一样,媒体查询给不出答案,容器查询可以。写法上要先给父元素声明 container-type: inline-size(只关心内联方向尺寸时用这个,代价最小),之后子元素才能用 @container (min-width: 480px) 写分支。

.card-shell {
container-type: inline-size;
container-name: card;
}

.card-body {
font-size: 14px;
}

/* 查询最近的名为 card 的容器,而非视口 */
@container card (min-width: 480px) {
.card-body {
font-size: 16px;
display: grid;
grid-template-columns: 1fr 2fr;
}
}

为什么元素不能查询自身:如果允许一个元素既当容器又被自己查询,它的尺寸变化会反过来影响自身查询条件,形成循环依赖,浏览器无法解析。所以容器查询查的是「最近的祖先容器上下文」,这也是为什么要先在父级声明 container-type。

2. 风格查询​

媒体查询:问的是视口@media (min-width: 768px)同一个卡片放在侧栏和主区,判定依据始终是屏幕宽度,和容器实际多宽无关。容器查询:问的是父容器@container (min-width: 400px)同一个组件在窄栏自动降级成单列,宽栏切成两栏 —— 复用时才不会错位。两者是叠加关系:页面级骨架仍用媒体查询,组件内部自适应交给容器查询。
图:媒体查询看视口,容器查询看父容器 —— 复用组件时只有后者靠得住

容器查询默认按尺寸分支,但有时我们想按「容器暴露的某个自定义属性」分支,而不是按它的宽高。这就是风格查询(style query),它让主题、密度这类样式不必依赖 class 层层传递到每一个子节点:

.theme-scope {
container-type: inline-size;
--density: compact;
}

@container style(--density: compact) {
.panel { padding: 8px; }
}
@container style(--density: spacious) {
.panel { padding: 20px; }
}

父容器声明一次 --density,所有后代按风格查询自行取用,组件内部不再需要知道自己处于哪种密度档位。

3. :has() 父级选择器​

:has() 让 CSS 第一次能根据「子孙的状态」反向上样式。过去「父元素根据子元素是否存在、是否聚焦」只能靠 JS 切 class,现在纯 CSS 就能表达。

/* 有图和无图的卡片用不同版式 */
.card:has(.card-cover) {
grid-template-rows: auto 1fr;
}
.card:not(:has(.card-cover)) {
padding-block: 16px;
}

/* 表单:只要组内出现校验失败项,整组标红边 */
.field-group:has(:user-invalid) {
border-color: #dc2626;
}

/* 标签后面的输入框必填时加星号 */
label:has(+ input:required)::after {
content: " *";
color: #dc2626;
}

它本质是一个「关系型伪类」,不是选择器性能杀手。真正的代价来自选择器复杂度:写在 body 上的 body:has(:hover) 会在每次悬停时触发整文档重算,要控住范围、尽量用子代选择器。还要注意 :has() 不能嵌套在另一个 :has() 的参数里(规范明确禁止),写了整条规则会被丢弃且无报错——这是它最容易让人踩的静默失败。

4. @layer 层叠层​

层叠层解决的是一个很具体的老问题:为了盖住第三方库的样式,只能把选择器写长或者上 !important,时间一长谁也改不动。有了 @layer reset, base, components, utilities,优先级由层的声明顺序决定,与选择器权重无关——写在 utilities 层的单类名,天然压过 components 层里再长的选择器。

/* 顺序只在第一次声明时确定 */
@layer reset, base, components, utilities;

@layer reset {
* { margin: 0; box-sizing: border-box; }
}
@layer components {
.btn-primary { background: #2563eb; }
}
@layer utilities {
.bg-red { background: #dc2626; }
}
/* .bg-red 一定压过 .btn-primary,与选择器长短无关 */

层的顺序只在第一次声明时确定,后面重复写同名层不会重新排序,所以声明语句要放在样式表最前面一次性写完。!important 依旧会反转层内与层间的顺序——层的存在不消除 important 的特殊性,遇到需要覆盖 utilities 层的情况仍然要靠 important 或调整层顺序。

5. 同期补齐的另外几项​

这几项截至 2026 年 10 月也已进入 Baseline 广泛可用,可以放心用:

  • @property:给自定义属性注册类型与初值,让渐变角度、颜色这类原本无法插值的值能平滑动画。
  • color-mix():在 oklch / oklab 空间里原生混色,hover 态、深色表面色调不再靠手算色板。
  • oklch() / oklab():感知均匀的色彩空间,调亮度不偏色,比 hsl 更适合生成可访问调色板。
  • subgrid:子网格对齐到父网格的线,复杂表单与卡片栅格的利器。
  • :focus-visible:只在键盘聚焦时显示焦点环,鼠标点击不抢戏。
  • 原生 CSS 嵌套:新项目基本不必再为嵌套单独跑 Sass。
/* @property 让角度可动画 */
@property --angle {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
.spinner {
background: conic-gradient(from var(--angle), #2563eb, #9333ea);
transition: --angle 1s linear;
}

用之前都确认一下目标运行环境,别为了「新」而新。

容器查询能替代媒体查询吗?不能。两者问的对象不同——媒体查询看视口,容器查询看父容器,实际项目里是叠加着用的,页面级布局仍然靠媒体查询。

容器也不能查询自身尺寸,它查的是最近的祖先容器上下文。让元素依赖自己的尺寸来定自己的样式,会形成循环依赖,规范直接把这个口子堵上了。

:has() 常被当成「父选择器」而没人敢用,其实它是关系型伪类,浏览器对匹配做了优化。真正要控制的是选择器本身的复杂度,嵌套过深才有代价。至于 @layer,它并不比 !important 更高——important 依旧会反转层内与层间的顺序,层只是多了一层可管理的优先级,不等于取消了 important 的特殊性。

这三件事的共同点是——把原本要靠 JS 或靠约定的判断交还给 CSS,用之前先确认支持度,用之后别再写两套。

6. 侧栏里的卡片不该看视口​

媒体查询看的是窗口。窗口是 1440 像素,侧栏只有 280。卡片若按 min-width: 900px 排成横的,在侧栏里会挤出容器。

把卡片的容器标成查询容器,查询条件写卡片自己的宽度。侧栏里不足 320 就改成上下排,主栏里超过 320 再横排。同一张卡片放进对话框、放进侧栏、放进主内容,不用为每个位置写一套类名。

:has() 用来根据子元素改变父级,例如卡片里有图时才加上下边距。不要写在 body 上监听任意悬停,那会让整页在鼠标移动时反复计算。@layer 把重置、组件、工具类分成层,后写的工具类不会因为选择器更短而压不住组件样式。三件事解决的问题不同,不要为了「用上新语法」在一个组件里各写一遍。

容器查询的语法细节以 MDN:CSS Container Queries 为准,网上很多示例漏写了 container-type,照抄会完全没有效果。