改革的 css

最近 JavaScript 的Promise.withResolvers正式纳入规范了,不知道多少人有关注,但相信大部分人和我一样并不会特别关注,实在是因为最近几年 js 基本没有重要新标准。而以此相对的,css 这边却是一直推出很很多重要的新特征,正好刚刚看到 Google 关于 2023 的 css 的总结文章,就像它开头说的2023 年对 CSS 来说是重要的一年。当然不止今年,近些年都有很最重要的新特征推出,所以我也介绍下近些年比我认为比较重要的 css 特性。

如果想了解这些规则的支持情况,可以在caniuse看到。

layer

相信各位都遇到到过覆盖组件样式或者样式污染的问题,比如下面这段

css
/* 组件内部的样式 */
.nav .button {
  color: red;
}
/* 我们的样式 */
.button {
  color: blue;
}
html
<nav class="nav">
  <button class="button">按钮</button>
</nav>

了解css 优先级应该都能知道按钮会渲染成红色,这个规则在大部分场景下都是非常实用的,比如展示一些必填或错误样式。

但有些情况也可能有负面效果,打个比方,如果.nav .button是外部组件的样式,没办法直接修改,但我们又希望用.button样式进行覆盖,你会怎么做嫩。在过去,处理方法无非是使用!important、增加层级或者通过编译处理。

这上面的处理方法都有一定缺陷,而使用layer会更优雅写。

css
@layer main component;
@layer component {
  /* 组件内部的样式 */
  .nav .button {
    color: red;
  }
}
@layer main {
  /* 我们的样式 */
  .button {
    color: blue;
  }
}

这里按钮会渲染成蓝色。

你可以理解 layer 给包裹的类名增加一层优先级,优先级高于 id 选择器,但低于 style。但 layer 之间可以手动指定优先级,就像第一行@layer main component;,先声明的如果和后声明的有冲突,那么先声明的优先级更高。这个例子的main的优先级就高于component的。还有一点要在使用中特别注意,未声明 layer 的样式比声明 layer 的优先级高。

实际情况下 layer 通常会配合@import一起使用,在 css 文件头部声明,比如

css
@import url(https://cdn.com/component.css) layer(component);
.button {
  color: blue;
}

这里按钮也会渲染成蓝色。

我个人是比较喜欢 layer 这个属性的,不过它也有带来了很多复杂的规则,比如开始的例子就算不加@layer main component;,按钮会被蓝色的覆盖,除此还有嵌套 layer、important 优先级、不支持link 标签等等,这里就不具体解释了。

我觉得这个特征重要的一点是近些年原子化 css 流行,原子化 css 之间包括其他组件库之间样式冲突的问题会越来越多。

选择器

这里说的选择器指的:is, :where, :has,这个几个其实不是特别新的规范,但:has是近年才被主流浏览器支持。

is

:is一个很实用的场景是进行分组,例如我们需要给为一个元素的 hover 和 focus 状态设置同样的样式,我们可以这样写

css
/* 传统写法 */
a:hover,
a:focus {
  color: red;
}
/* is 写法 */
a:is(:hover, :focus) {
  color: red;
}

这个例子写法可能并没有方便多少,但语法上更清晰,继续扩充也不容易出现难于维护的情况,而且下面的写法也可以和其他选择器进行配合。如果你看MDN 的例子,可以发现里面很多篇幅都是介绍:is简化书写的例子。

where

:where:is的用法一致,但优先级不同,:where的优先级总是为 0,可以很容易被覆盖,而:is是由它的选择器列表中优先级最高的选择器决定的。例如我们layer覆盖样式的例子,下面写法也是可以覆盖按钮成蓝色的,也算覆盖样式的一个小技巧。

css
.nav .button {
  color: red;
}
:is(#id, .button) {
  color: blue;
}

而 where 低优先级的特点很适合在组件库中使用,例如 tailwindcss 的typography,只需要加上一个prose类名就可以实现 mrakdown 的排版,如果打开控制台看下样式,会发现很多:where的使用,比如下面这条。

image

我们拆开来看,首先是.prose :where(p),很好理解,是给.prose下的p标签设置样式。而指的:not()排除掉指定的元素,也是一个选中器。而:where([class~="not-prose"],[class~="not-prose"] *)匹配当前有not-prose类或者父元素有not-prose的元素。组合在一起的最终效果是给 p 元素设置样式,前提是类名没有not-prose或者父元素没有not-prose类名。

has

:has优先级计算与:is一样,会根据子兄弟元素的状态改变自身,比如下面的例子

css
.nav:has(a) {
  color: red;
}
html
<nav class="nav"><a>nav1</a></nav>
<nav class="nav">nav2</nav>

这里 nav1 会渲染成红色,而 nav2 不会。

看到上面这个例子你也许能联想到:has其他的场景,这里篇幅有限就不细说了,可以看看MDN 的例子,也可以到 codepen 看看一些更炫酷的例子。例如前段时间看到一个鼠标悬浮展示的例子,效果很炫酷。 Glide To Reveal Secret Code

容器查询

这些年响应式开发已经非常普及了,大部分闻名的网站移动端都有不错的体验,Material UI 这类移动优先的 ui 组件库流行也可见一斑。但如果你看 ui 组件库,可以说没有什么组件库能说很完美支持响应式开发,很重要的原因是组件大部分只能使用弹性布局处理不同尺寸,不像直接开发一样使用媒体查询处理响应式。

这里就要介绍我们的主角容器查询了,以一个页码组件为例,看看这个特征能给组件库带了什么变化。

先看下 Material UI 的pagination组件,这是一个移动端优先的而且国外知名度很大的组件库,按理来说小尺寸的支持应该很完善。

image

嗯,好像展示的并不理想。那如果用用容器查询的方式重新设计一个,效果又是什么样的呢。

container-query

这里在小尺寸窗口下明显要好很多,而且代码也非常简单。

html
<div class="container">
  <nav class="pagination">
    <div><</div>
    <div>1</div>
    <div>2</div>
    <div>3</div>
    <div>4</div>
    <div>5</div>
    <div>></div>
  </nav>
</div>
<input type="range" id="container-len" min="0" max="500" value="500" />
css
.container {
  margin-top: 100px;
  /* 需要在元素上声明一个局限上下 */
  container-type: inline-size;
  box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
}
.pagination {
  display: flex;
  gap: 10px;
  justify-content: center;
}
.pagination div {
  display: grid;
  place-items: center;
  width: 50px;
  height: 50px;
  border-radius: 50%;
  box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
}
/* 定义容器查询 */
@container (width < 400px) {
  .pagination :not(:last-child, :first-child) {
    display: none;
  }
  .pagination {
    justify-content: space-around;
  }
}
#container-len {
  margin-top: 20px;
  width: 500px;
}
js
// 用于修改容器宽度,方便查看效果,不影响容器查询
const container = document.querySelector(".container");
const containerLen = document.querySelector("#container-len");
containerLen.addEventListener("input", () => {
  container.style.width = `${containerLen.value}px`;
});

要使用容器查询,需要用container-type创建一个容器上下文,这里的inline-size表示只查询行向尺度,@container (width < 400px)是我们定义的容器查询,语法上与媒体查询类似,同样可以定义多个查询。这里当容器宽度小于 400px 时,我们隐藏非头尾元素以适应小尺寸宽度,当然你可以做更多处理做些优化,例如添加动画、修改字体大小等等。

容器查询还有一些查询长度单位,可以用于调整字体大小,例如cqw,表示容器宽度的 1%,这在过去只能通过 js 计算。

View Transition

这可能是我最喜欢新特征了,也是我觉得会很快广泛使用的功能之一了。这个功能可以很轻松的创建炫酷动画过渡效果,如果你不要求所有的浏览器都有统一的过度效果,完全可以立刻在项目上使用,很轻松兼容其他浏览器,这也是我喜欢的原因之一。

其实 Googe 的文章已经介绍的非常详情了,打不开的也可看看Mdn的文章。

用一个简单的例子介绍下

html
<!doctype html>
<html>
  <style>
    /* 覆盖默认 */
    ::view-transition-old(root) {
      animation: none;
    }
    ::view-transition-new(root) {
      animation: none;
    }
  </style>
  <body>
    <div id="bg" style="width: 100vw; height: 100vh"></div>
  </body>
  <script>
    let isBlack = false;
    addEventListener("click", (event) => {
      const x = event.clientX || 0;
      const y = event.clientY || 0;
      // 开始一次视图过渡:
      const transition = document.startViewTransition(() => {
        document.getElementById("bg").style.background = isBlack ? "white" : "black";
        isBlack = !isBlack;
      });
      // 获取到最远角的距离
      const endRadius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
      // 等待伪元素创建完成:
      transition.ready.then(() => {
        // 新视图的根元素动画,覆盖默认动画
        document.documentElement.animate(
          {
            clipPath: [`circle(0 at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`],
          },
          {
            duration: 500,
            easing: "ease-in",
            // 指定要附加动画的伪元素
            pseudoElement: "::view-transition-new(root)",
          }
        );
      });
    });
  </script>
</html>

效果如图 transition

简单理解 View Transition 就是浏览器会记录页面和 name 标记元素的快照,而我们可以定义快照之间的转换动画。如果你看过那种多个图片拼接成的视频,说不定你手机就自带这个功能,肯定很容易理解。

回到代码上,这里的核心是document.startViewTransition,调用这个方法时会记录一次快照,而我们需要在内部的回调函数内进行 1 我们的 dom 更新,在回调完成后也会记录一次快照。这里记录快照,后面就到动画执行阶段了。

你应该注意到::view-transition-new(root)::view-transition-old(root)这里我没有加动画,反而是覆盖成animation: none;。这是因为我们用了自定义动画,其实和在animation写效果一样,但 js 会更灵活。这里transition.ready.then会在过渡动画即将开始前触发。

最后

还是 Google文章的这句画:2023 年对 CSS 来说是重要的一年,今年 css 确实增加了非常多的新特征,尤其是动画方面的,也许过几年,这些特征会成为我们日常开发的标配,就像现在的flexgrid一样。