CSS 悄悄补齐的那些短板
- 作者:Bougie
- 创建于:2026-09-15
前阵子回去改一个 2021 年的老项目,翻到一段让我停下来的代码。
它是一个卡片列表的「响应式」实现。需求很普通:卡片放在主内容区的时候横向排列,侧边栏收起变宽时就紧凑一点,塞进右侧栏的时候就竖过来。当年写这段代码的人(大概率是我)用了一个 ResizeObserver 监听容器,然后手动往卡片上加 data-compact 属性,再配三条 CSS 规则。
四十多行 JS,加一堆属性,加一个防抖。我盯着它看了半天,心情有点复杂。
现在这件事长这样:
.card-list {
container-type: inline-size;
}
@container (min-width: 480px) {
.card {
display: grid;
grid-template-columns: 120px 1fr;
}
}
四行。没有 JS,没有观察器,没有防抖,不需要记得在组件卸载时 disconnect()。
代码变得这么短不是重点。重点是:我写那段 ResizeObserver 的时候,完全没觉得哪里不对。它甚至算得上当时社区推荐的做法。我们这一代前端对 CSS 的默认期待被压得很低,低到「样式语言不该知道自己的容器有多大」这种事,我们很自然地接受了,然后自己用 JS 补上。
而 CSS 在过去几年里,把这一类「我们自己补上的东西」一件件收了回去。

# 媒体查询问错了问题
媒体查询查的是视口。这在 2010 年是够用的,因为那时候页面基本就是一整列从上往下流,视口宽度几乎是布局的唯一变量。
后来组件化来了,同一个卡片可能出现在页面主区、右侧栏、弹窗、手机端的抽屉里。它该长什么样,跟视口一点关系都没有,只跟它自己被塞进的那个格子有多大有关。媒体查询回答不了一个它没被问的问题。
容器查询把这个关系摆正了:
.sidebar {
container-type: inline-size;
container-name: sidebar;
}
@container sidebar (max-width: 320px) {
.card__meta {
display: none;
}
}
container-type: inline-size 这句有个副作用容易被忽略——它给元素加了一层「尺寸限制」,元素的行内尺寸不再由内容撑开,而是由父级决定。第一次用的人经常在这里卡住:加了容器查询之后布局塌了。这不是 bug,是必须付的代价,浏览器得先知道容器多大,才能拿它去查。
container-name 是可选的,但在一个页面里有好几层容器的时候,我建议写上。不写的话查询会去找最近的祖先容器,嵌套一深,读代码的人就得在 DOM 树里来回跳。
还有 cqi、cqw 这些单位,可以做随容器缩放的字号:
.card__title {
font-size: clamp(1rem, 4cqi, 1.5rem);
}
一行代码,标题跟着容器长大。放在以前这是 JS 的活儿,或者是一个妥协掉的固定字号。

# :has() 把 CSS 的单向箭头掰了回来
CSS 的选择器一直是单向的:你能选中后代,不能因为后代去选中祖先。这是有性能上的理由的,反向匹配代价太高,多年来说起父选择器,社区的标准回答是「不可能」。
然后它就这么来了:
.card:has(img) {
padding-top: 0;
}
.card:not(:has(img)) {
padding-top: 1.25rem;
}
看起来只是省了一个 has-cover 的 class。但真正的变化是,判断依据从「模板里写没写这个 class」变成了「渲染出来到底有没有这个元素」。这个区别在一个由数据驱动、字段可能为空的列表里很实在——以前图片字段为空时,卡片顶部会留一块空白,因为模板里的 class 是写死的。
表单大概是 :has() 最舒服的用武之地:
.field:has(input:invalid:not(:placeholder-shown)) {
border-color: #d64545;
}
.field:has(input:valid) {
border-color: #2f9e6f;
}
:invalid 早就有了,麻烦在于它一上来就是 invalid——用户还没输入,输入框就红了。:placeholder-shown 是个老技巧,用来判断「还没填过」。以前这个判断只能落在 input 上,样式也只能改 input 自己;现在可以往上抬到整个 .field,label 的提示文字、边框、图标一起变。
我还见过一个很妙的用法,是拿它做全局状态:
body:has(.modal[open]) {
overflow: hidden;
}
不用再在打开弹窗的 JS 里记得加个 body.classList.add('no-scroll'),也就不会再出现「关掉弹窗忘记移除、页面滚不动了」这种经典 bug。
# 嵌套终于不用装插件了
我用 Sass 很多年,嵌套是当初离不开它的头号理由。为了这个语法,我们接受了一整套构建流程、一个编译步骤、以及「改完保存等两秒」。
现在它是原生的:
.card {
padding: 1rem;
& h3 {
font-size: 1.1rem;
}
&:hover {
border-color: oklch(70% 0.15 250);
}
@container (min-width: 480px) {
display: grid;
}
}
有个坑值得单独说,因为它坑过我一次:在原生嵌套里,& 不是可选的。
.card {
.title { } /* 不会报错,但这个选择器是 .card .title 吗?不是 */
}
上面那行在 Sass 里是后代选择器,在原生 CSS 里,& 会被隐式加在整个选择器前面 —— 但只有对 & 的隐式插入有明确定义的写法才是安全的。写 .title 这种裸类选择器,浏览器会把它解析成 is(.card) .title 吗?实际上规范的行为是隐式 & 只在能明确插入时生效,而裸元素选择器 h3 会产生歧义(h3 到底是后代还是什么),所以元素选择器必须写成 & h3 或者 :is(h3)。
所以规矩很简单:嵌套里写标签选择器,前面一定要带 &。类选择器可以省,但带上是更省心的习惯。
另一点是原生嵌套没有 Sass 的 &__title 拼接。& 是一个完整的选择器,不是字符串。想在原生 CSS 里拼 BEM 式的类名,得老实写全。这个限制我一开始嫌烦,后来觉得是好事——&__title 这种写法搜不到、跳转不了、重构的时候最容易漏。

# @layer 治的是一种慢性病
每个项目到了一定规模都会得同一种病:某个组件的样式不生效,于是有人加了 !important,再后来有人加了更具体的选择器,最后大家都不敢删任何一行 CSS。
@layer 给的不是一个新技巧,是一个可以提前声明的秩序:
@layer reset, base, components, utilities;
@layer reset {
* { margin: 0; box-sizing: border-box; }
}
@layer utilities {
.hidden { display: none; }
}
关键点在于:层的优先级高于选择器特异性。utilities 里的 .hidden 会赢过 components 里写得再具体的 .modal .header .close .hidden,不需要 !important,不需要比谁的选择器更长。
这意味着「后写的能覆盖先写的」重新变成了一条可以依赖的规则。对一个接手别人代码的人来说,这条规则值很多钱。
我们团队现在做新项目,第一件事就是把这几层声明写在入口文件最上面。它不解决所有问题,但它把一类问题的发生概率从「迟早会」降到了「一般不会」。
# subgrid 补的是最后一块拼图
Grid 出来之后有个很尴尬的场景:一排卡片高度不一样,你想让它们的标题、正文、按钮各自水平对齐。用 Grid 排外层的卡片没问题,但每张卡片内部是独立的 Grid 上下文,它们之间对不上。
以前的办法是给卡片固定高度,或者用 flex 加 margin-top: auto 把按钮推到底部——能看,但标题还是不齐。
.list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
grid-template-rows: auto auto auto;
}
.card {
display: grid;
grid-row: span 3;
grid-template-rows: subgrid;
}
卡片声明 grid-template-rows: subgrid,意思是不自己另起一套行,而是直接用父级的那三行。于是同一横排的卡片,标题对齐标题,按钮对齐按钮。
Firefox 2019 年就支持了,Chrome 拖到 2023 年 9 月才跟上,中间这四年 subgrid 基本处于「知道有,但线上不敢用」的状态。现在它可以放心用。

# 颜色这部分,是肉眼能看出来的差别
oklch() 值得单独拿出来说,因为它解决的不是写法问题,是效果问题。
:root {
--brand: oklch(62% 0.17 252);
}
.btn:hover {
background: oklch(from var(--brand) calc(l + 0.08) c h);
}
oklch 的三个分量是亮度、彩度、色相,而且它是对人眼感知均匀的。同样把亮度加 10%,在 hsl 里黄色会明显变灰、蓝色几乎没变化,在 oklch 里两者的观感变化是一致的。做设计系统的人应该对这个痛点很熟:定了一组主色,想生成 hover、active、disabled 的深浅变体,用 hsl 调出来的东西总有几个颜色不对劲,得手动微调。
oklch(from ...) 这种相对颜色语法把「基于一个已有颜色派生」变成了语言里的原生能力。以前要么靠 Sass 函数,要么靠 CSS 变量把 L、C、H 拆成三个变量存。现在一行搞定。
color-mix() 也很好用:
.tag {
background: color-mix(in oklch, var(--brand) 15%, white);
}
我上一次做主题切换的时候,靠这两个函数把原来二十多个写死的色值压到了六个基础色。规模小了,出错的机会也就少了。
顺便提一句 light-dark(),配合 color-scheme 可以不写媒体查询就跟随系统主题:
:root { color-scheme: light dark; }
body {
background: light-dark(#ffffff, #14161a);
color: light-dark(#1a1a1a, #e8e8e8);
}
# 还有一批在路上的
有些东西现在还只能算「Chrome 先行」,生产环境要谨慎,但值得知道:
- 视图过渡(View Transitions):
document.startViewTransition()一行就能让页面切换有过渡动画,跨文档版本也支持了。Safari 和 Firefox 的进度不一样,得看目标用户的浏览器分布。 - 滚动驱动动画(scroll-driven animations):
animation-timeline: scroll(),滚动条变成动画的时间轴。以前这类效果基本要上 GSAP 或手写监听。 - 锚点定位(anchor positioning):把浮层定位到某个元素旁边,纯 CSS,不用再算
getBoundingClientRect。
这三个我都只在内部工具里用过,因为它们的降级体验不好做。倒是 text-wrap: balance 可以随便用,它只影响排版,不支持的浏览器原样显示,一行代码让标题换行更均匀:
h1, h2 {
text-wrap: balance;
}
# 我们卡住的其实是肌肉记忆
写这篇文章的时候我一直在想一个问题:这些特性里,容器查询 2023 年初就全平台可用了,:has() 到 2023 年底也齐了,到今天已经过去快三年。可我在代码评审里,还是经常看到用 JS 实现这些效果的新代码。
不是大家不知道,是肌肉记忆还在。我们脑子里有一张「哪些事得用 JS 做」的清单,这张清单是 2018 年前后形成的,之后就没怎么更新过。遇到响应式卡片,手先伸向 ResizeObserver;遇到父元素要变样式,手先伸向 classList。这些动作不需要思考,所以也不会触发「等一下,CSS 现在是不是能做了」这个念头。
另一半原因是 CSS 这十年的名声不太好。它被当成一门「靠试」的语言,写对了不知道为什么对,写错了不知道为什么错。于是大家倾向于用自己能推理的语言去解决布局问题,哪怕绕远路。这个倾向是有道理的,但代价是我们背上了一堆本不该存在的 JS。
我现在的习惯是,动手写布局之前先花两分钟去 MDN 上确认一下「这件事现在 CSS 能不能做」。这个两分钟经常能省掉几十行代码,和它们后续所有的维护成本。
CSS 已经不是我们认识的那个 CSS 了。麻烦的是,我们还是那批写 CSS 的人。