CSS 悄悄补齐的那些短板

前阵子回去改一个 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 树里来回跳。

还有 cqicqw 这些单位,可以做随容器缩放的字号:

.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 的人。