最近 JavaScript 的Promise.withResolvers正式纳入规范了,不知道多少人有关注,但相信大部分人和我一样并不会特别关注,实在是因为最近几年 js 基本没有重要新标准。而以此相对的,css 这边却是一直推出很很多重要的新特征,正好刚刚看到 Google 关于 2023 的 css 的总结文章,就像它开头说的2023 年对 CSS 来说是重要的一年。当然不止今年,近些年都有很最重要的新特征推出,所以我也介绍下近些年比我认为比较重要的 css 特性。
如果想了解这些规则的支持情况,可以在caniuse看到。
layer
相信各位都遇到到过覆盖组件样式或者样式污染的问题,比如下面这段
/* 组件内部的样式 */
.nav .button {
color: red;
}
/* 我们的样式 */
.button {
color: blue;
}
<nav class="nav">
<button class="button">按钮</button>
</nav>
了解css 优先级应该都能知道按钮会渲染成红色,这个规则在大部分场景下都是非常实用的,比如展示一些必填或错误样式。
但有些情况也可能有负面效果,打个比方,如果.nav .button
是外部组件的样式,没办法直接修改,但我们又希望用.button
样式进行覆盖,你会怎么做嫩。在过去,处理方法无非是使用!important、增加层级或者通过编译处理。
这上面的处理方法都有一定缺陷,而使用layer会更优雅写。
@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 文件头部声明,比如
@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 状态设置同样的样式,我们可以这样写
/* 传统写法 */
a:hover,
a:focus {
color: red;
}
/* is 写法 */
a:is(:hover, :focus) {
color: red;
}
这个例子写法可能并没有方便多少,但语法上更清晰,继续扩充也不容易出现难于维护的情况,而且下面的写法也可以和其他选择器进行配合。如果你看MDN 的例子,可以发现里面很多篇幅都是介绍:is
简化书写的例子。
where
:where
和:is
的用法一致,但优先级不同,:where
的优先级总是为 0,可以很容易被覆盖,而:is
是由它的选择器列表中优先级最高的选择器决定的。例如我们layer
覆盖样式的例子,下面写法也是可以覆盖按钮成蓝色的,也算覆盖样式的一个小技巧。
.nav .button {
color: red;
}
:is(#id, .button) {
color: blue;
}
而 where 低优先级的特点很适合在组件库中使用,例如 tailwindcss 的typography,只需要加上一个prose
类名就可以实现 mrakdown 的排版,如果打开控制台看下样式,会发现很多:where
的使用,比如下面这条。
我们拆开来看,首先是.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
一样,会根据子兄弟元素的状态改变自身,比如下面的例子
.nav:has(a) {
color: red;
}
<nav class="nav"><a>nav1</a></nav>
<nav class="nav">nav2</nav>
这里 nav1 会渲染成红色,而 nav2 不会。
看到上面这个例子你也许能联想到:has
其他的场景,这里篇幅有限就不细说了,可以看看MDN 的例子,也可以到 codepen 看看一些更炫酷的例子。例如前段时间看到一个鼠标悬浮展示的例子,效果很炫酷。
容器查询
这些年响应式开发已经非常普及了,大部分闻名的网站移动端都有不错的体验,Material UI 这类移动优先的 ui 组件库流行也可见一斑。但如果你看 ui 组件库,可以说没有什么组件库能说很完美支持响应式开发,很重要的原因是组件大部分只能使用弹性布局处理不同尺寸,不像直接开发一样使用媒体查询处理响应式。
这里就要介绍我们的主角容器查询了,以一个页码组件为例,看看这个特征能给组件库带了什么变化。
先看下 Material UI 的pagination组件,这是一个移动端优先的而且国外知名度很大的组件库,按理来说小尺寸的支持应该很完善。
嗯,好像展示的并不理想。那如果用用容器查询的方式重新设计一个,效果又是什么样的呢。
这里在小尺寸窗口下明显要好很多,而且代码也非常简单。
<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" />
.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;
}
// 用于修改容器宽度,方便查看效果,不影响容器查询
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的文章。
用一个简单的例子介绍下
<!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>
效果如图
简单理解 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 确实增加了非常多的新特征,尤其是动画方面的,也许过几年,这些特征会成为我们日常开发的标配,就像现在的flex
、grid
一样。