CSS现代技巧
:is() 接受一个选择器列表,匹配其中任意一个选择器的元素。
:is&:where&has
is&where
:is(selector1, selector2, selector3):is() 接受一个选择器列表,匹配其中任意一个选择器的元素。
让我们以 summary:is(details:hover > summary, details[open] > summary) 为例,拆解它的全路径解析过程:
在括号内部,浏览器为了性能,采取的是从右往左的匹配策略。它不是先找 details,而是先在页面上抓取所有的 summary。
当浏览器遇到 summary:is(...) 时,它的心理活动是这样的:
- 第一步(过滤主体): “先在 DOM 树里找出所有的
<summary>标签。剩下的元素(比如div,p)我都不看了。” - 第二步(进入
:is()内部验证): “现在,我要看看这些summary有没有资格穿上这件名为:is()的衣服。我要拿括号里的两个‘模板’去套。”- 模板 A (
details:hover > summary): “我看看这个summary的亲爹是不是一个正在被悬停的details?” - 模板 B (
details[open] > summary): “如果模板 A 不行,那我再看看它的亲爹是不是一个处于打开状态的details?”
- 模板 A (
- 第三步(逻辑合并): “只要上面两个模板有一个套中了,那这个
summary就正式被我‘锁定’了。”
接下来如果是:summary:is(details:hover > div, details[open] > span)
外面(主体):你告诉浏览器,我们要找的是一个 <summary>。
里面(过滤器):你告诉浏览器,这个东西必须匹配 details:hover > div。
解析结果:浏览器会发现,一个元素不可能既是 <summary> 又是 <div>。
:is() 括号里的内容,必须能和括号外的元素“合体”成功,这个选择器才会有意义。
summary:is(.container) $\approx$ summary:is(&.container)
在 CSS 中,单独写 :is(.container) 实际上等同于 *:is(.container),这里的 * 是被隐式补全的。
:is()的优先级不是由它自己决定的,而是由它括号里最重的那个选择器决定的。
:where()是为了解决“样式覆盖战”而生的。无论你往括号里塞什么(哪怕是 ID),它的权重永远是 0。希望用户能轻松覆盖你的默认样式,你应该用
:where()使用
:is()简化逻辑并保持强度
has
:has() 选择器被誉为 CSS 的**“救世主”**,因为它解决了 CSS 诞生 30 多年来最大的遗憾:没有“父级选择器”。
在 :has() 出现之前,CSS 的选择器是“单向”的(只能从父到子)。而 :has() 让 CSS 具备了**“向后看”和“向上看”**的能力。
也就是父亲的状态取决于儿子的状态
/* 只有当 details 内部包含一个类名为 .content 的元素时,才给 details 加背景 */
details:has(.content) {
background: #fff;
}解析步骤:
- 定位主体:浏览器首先扫描所有的
details元素。 - 触发探测:对于每一个
details,浏览器会进入其内部(子树)寻找是否存在.content。 - 匹配结果:
- 如果找到了:给这个特定的
details应用样式。 - 如果没找到:跳过,继续看下一个。
- 如果找到了:给这个特定的
如果 .card 后面紧跟着一个 .banner,就给 .card 增加底边距:
.card:has(+ .banner) {
margin-bottom: 50px;
}简单来说::has() 括号里的内容,默认就带了一个“后代选择器”(即那个看不见的空格)。
在 CSS 规范中,如果你在 :has() 的括号内不写任何连接符,它会默认认为你在寻找后代(Descendants)。
为了让你彻底理解,我们对比一下这几种写法:
| 写法 | 逻辑含义 | 对应非 :has() 路径 |
|---|---|---|
form:has(input) | 后代:表单里有 input(不论多深)。 | form input |
form:has(> input) | 子代:表单的第一层级有 input。 | form > input |
form:has(~ div) | 通用兄弟:表单后面跟着个 div。 | form ~ div |
form:has(+ div) | 相邻兄弟:表单后面紧挨着个 div。 | form + div |
那可不可以有“去除”那种空格的方式?就比如.container.wrap
可以这么写:.container:has(&.wrap)
/* 只有当 .container 自身匹配 .wrap 时(这通常在 JS 动态添加类名时很有用) */
.container:has(&.wrap) {
/* 这里的逻辑比较绕,现代浏览器通常支持嵌套引用来检测自身 */
}& 是 CSS 嵌套模块(CSS Nesting) 的专属符号,它必须写在一个大括号内部才有意义。
.container:has(&.wrap)。此时 & 处于顶层,它没有“父亲”可以引用,浏览器解析到这里会认为这是一个非法选择器,直接丢弃整条规则。
在原生 CSS 中,& 必须作为嵌套选择器的“开头”或者“一部分”来引用父级,且目前它无法在 :has() 的括号内实现你想象中的“自我检测”功能。
:not()
现在的 :not() 已经支持多个参数了,不再需要套娃写好几个 :not()。
/* 选中不是 active 也不是 disabled 的所有按钮 */
button:not(.active, .disabled) {
cursor: pointer;
}其它超好用的现代选择器
:focus-within:只要子元素里有任何一个获得了焦点,父元素就变样(常用于表单高亮)。
:empty 的进化版 :blank:不仅检测有没有子元素,还检测里面是不是只有空格。
:nth-child(An+B of S):这是超强的组合技!在特定类名里筛选第几个。
你肯定用过 :nth-child(2)(选第 2 个子元素)。但如果 HTML 结构很乱,你想选“第 2 个加了 .active 类的子元素”,以前是做不到的。
<div>
<p>干扰项</p>
<p class="item">目标 1</p>
<p>干扰项</p>
<p class="item">目标 2</p>
</div>如果你写 .item:nth-child(2),它选中的是第 2 个子元素(也就是第一个 .item),因为浏览器是先数位置,再看类名。现代解法: 使用 of 关键字。
/* 翻译:在所有的 .item 里面数数,选第 2 个 */
/* 完美选中 "目标 2",直接忽略干扰项 */
:nth-child(2 of .item) {
color: red;
}底蕴价值: 这是逻辑过滤。它解决了 CSS 选择器长期以来“数数不准”的顽疾,让你可以精准打击 DOM 树深处的特定元素。
CSS嵌套取代Sass&Less
原生 CSS 嵌套(CSS Nesting)已经在 2023 年全面进入了所有主流浏览器(Chrome 112+, Firefox 117+, Safari 16.5+)。
这意味着你以前为了使用 & 嵌套而不得不配置 Sass/Less 编译环境的日子,正式成为历史了。
现在的原生语法怎么写?
现在的原生 CSS 几乎完美复刻了 Sass 的嵌套逻辑,甚至更宽容:
/* 纯 CSS 文件,无需编译 */
.card {
background: white;
padding: 20px;
/* 1. 嵌套子元素 */
.title {
font-size: 1.5rem;
}
/* 2. 使用 & 引用父级(状态切换) */
&:hover {
border-color: blue;
}
/* 3. BEM 风格连接(注意:这是原生与 Sass 的唯一大区别) */
/* 原生 CSS 不支持像 Sass 那样直接拼接字符串:&__title 是无效的 */
}原生嵌套 vs Sass 的关键区别
虽然看起来一模一样,但有几个细节你一定要知道:
关于“字符串拼接” (BEM)
- Sass: 支持
&__header,编译后变成.card__header。 - 原生 CSS: 不支持。
&在原生 CSS 中代表的是一个完整的选择器(或选择器列表),它不能被当作字符串前缀。 - 建议:在原生时代,大家更倾向于直接嵌套
.title或者使用:has()这种更高级的选择器。
动态视口单位
动态视口单位 (dvh, svh, lvh)
在移动端(尤其是 iOS Safari),浏览器的地址栏会伸缩,导致 100vh 经常出问题(内容被底部栏遮挡)。
svh(Small Viewport Height): 地址栏展开时的可见高度(最短)。lvh(Large Viewport Height): 地址栏收起时的可见高度(最长)。dvh(Dynamic Viewport Height): 推荐使用。它会自动随地址栏伸缩而变化,完美解决移动端全屏问题。
滚动驱动动画:animation-timeline
这是目前的最前沿黑科技(Scroll-driven Animations)。
痛点: 以前要做“页面顶部阅读进度条”或者“随着滚动,图片慢慢变大”的效果,必须写 JS 监听 scroll 事件,计算 scrollTop,性能很差。
现代解法: CSS 直接绑定滚动条作为“时间轴”。
/* 定义一个进度条动画 */
@keyframes grow-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.progress-bar {
position: fixed;
top: 0; left: 0;
width: 100%; height: 5px;
background: red;
transform-origin: 0 50%;
/* 核心:将动画进度与页面滚动进度绑定 */
animation: grow-progress linear;
animation-timeline: scroll();
}第一阶段:石器时代 (jQuery & window.onscroll)
大约 2010 - 2015 年
这个时候,如果你想做一个“随着滚动,导航栏变透明”的效果,你必须写 JavaScript。
做法: 监听 window.onscroll 事件。
// 这种代码简直是性能杀手
window.addEventListener('scroll', () => {
const scrolled = window.scrollY;
const header = document.querySelector('.header');
// 每次滚动一像素,浏览器就要重新计算并渲染,CPU 都在尖叫
header.style.opacity = 1 - (scrolled / 200);
});问题: 滚动事件每秒触发几十甚至上百次。如果你的 JS 稍微复杂一点,页面就会掉帧、卡顿(Jank)。为了解决这个问题,开发者发明了“防抖(Debounce)”和“节流(Throttle)”,但这只是止痛药,治标不治本。
第二阶段:工业革命 (Intersection Observer API)
大约 2016 - 2020 年
浏览器厂商终于看不下去了,推出了 Intersection Observer(交叉观察器)。这是一个高效的 JS API。
- 做法: 浏览器帮你盯着元素,告诉 JS:“嘿,这个图片进入屏幕了!”然后 JS 给它加个类名
.animate。 - 进步: 性能大大提升,因为不再需要实时监听滚动位置。
- 局限: 它只能做开关(触发动画开始/结束),很难做连续映射(比如:滚动条走 10%,动画就走 10%;滚动条倒回去,动画也倒回去)。
第三阶段:现代文明 (CSS Scroll-driven Animations)
2023 年至今 (Chrome 115+ 开始支持)
这就是让你惊叹的 animation-timeline。
- 核心理念: 时间不再是以“秒”为单位,而是以“像素”为单位。
- 革命性: 以前 CSS 动画是
0s -> 3s;现在 CSS 动画是Scroll Top (0px) -> Scroll Bottom (1000px)。
实战演示:两种模式
CSS 滚动驱动动画主要分为两种玩法,非常直观:
1. scroll() - 宏观视角 (阅读进度条)
盯着整个滚动容器(通常是页面本身)。
场景: 页面顶部的红色进度条,随着你读完全文,它从左走到右。
/* 定义动画:从宽 0 到宽 100% */
@keyframes grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.progress-bar {
/* 绑定动画 */
animation: grow linear;
/* 魔法所在:动画的时间轴 = 最近的滚动容器 */
animation-timeline: scroll();
}2. view() - 微观视角 (元素显现)
盯着元素自己。它关注的是“我自己有没有进入屏幕”。
场景: 图片默认是透明的,当它从屏幕底部露头时慢慢变实,滚出屏幕顶部时又变透明。
@keyframes fade-in-out {
0% { opacity: 0; transform: translateY(50px); }
50% { opacity: 1; transform: translateY(0); } /* 在屏幕中间时完全显示 */
100% { opacity: 0; transform: translateY(-50px); }
}
.image {
animation: fade-in-out linear;
/* 魔法所在:当这个元素进入视口(view)时开始,离开时结束 */
animation-timeline: view();
/* 精确控制:从进入屏幕 10% 处开始,到离开屏幕 10% 处结束 */
animation-range: entry 10% exit 10%;
}“F8” 大法
这是 Chrome 官方提供的快捷键,专门用来暂停脚本执行。
- 适用场景:你想暂停界面,但不想改代码。
- 操作步骤:
- 打开开发者工具(F12)。
- 切换到 Sources (源代码) 面板。
- 把鼠标移到你想触发的界面上(比如悬停让菜单显示)。
- 按下键盘上的
F8(或者Ctrl + \)。 - 效果:浏览器会立刻进入“暂停”状态(Paused in debugger),此时你可以随意去 Elements 面板检查那个原本会消失的元素了。
4. 逻辑控制的新星:Style Queries (样式查询)
第一阶段:洪荒时代 —— 只有“全局”没有“局部”
(Before 2010s)
在这个阶段,CSS 是**“一视同仁”**的。
- Media Queries (
@media):只能查询浏览器窗口(视口)的宽度。 - 痛点: 你写了一个组件(比如卡片),你想让它在“变宽”时改变布局。但你只能问浏览器:“窗口有没有变宽?”而不能问组件:“爸爸(父容器)有没有变宽?”
第二阶段:觉醒年代 —— 容器查询 (Container Queries)
(2020 - 2022)
这是大家最熟悉的历史。开发者苦苦哀求了 10 年,浏览器终于推出了 @container。
- 核心能力: 尺寸查询 (Size Queries)。
- 语法:
@container (min-width: 500px) { ... } - 意义: 组件终于可以根据父容器的宽度来改变样式了。这解决了响应式开发 80% 的痛点。
第三阶段:逻辑革命 —— 样式查询 (Style Queries)
(2023 - 至今)
就在大家为“尺寸查询”欢呼时,规范制定者(CSS WG)突然意识到一个问题:
“既然我们可以查询父容器的宽度,为什么不能顺便查询父容器的样式值呢?”
于是,Style Queries 诞生了。
它不再关心“父容器有多宽”,而是关心“父容器的变量是多少”。这实际上就是在 CSS 里写 if (var == value)。
这是 Container Queries (容器查询) 的进阶版。
现状: 我们知道 @container (min-width: 300px) 可以根据宽度变样式。
未来(Style Queries): 我们可以根据 CSS 变量的值 来变样式。这是 CSS 里的 if/else。
/* 容器定义 */
.card {
container-name: my-card;
}
/* 这是一个逻辑开关 */
.card.featured {
--is-featured: true;
}
/* 样式查询:如果 --is-featured 变量等于 true,则执行下面的 CSS */
@container my-card style(--is-featured: true) {
.title {
color: gold;
font-size: 2rem;
}
.bg {
background: linear-gradient(to right, red, gold);
}
}底蕴价值: 这实现了 样式与结构的分离。React/Vue 组件只需要控制父容器的一个变量开关,CSS 内部就会自动处理所有子元素的复杂变化逻辑。
巨大的挑战
虽然理念很先进,但它目前面临两个巨大的挑战:
1. 循环依赖的死结 (The Circular Dependency Loop)
这是规范制定者最头疼的问题。
设想: 我想查询 background-color。
@container style(background-color: red) {
div { background-color: blue; }
}BUG: 如果子元素变蓝会导致父元素变红,父元素变红又触发查询让子元素变蓝……这就死循环了。
现状: 为了避免这个问题,目前的浏览器(主要是 Chrome)只支持查询 CSS 变量(Custom Properties),比如 style(--foo: bar)。不支持查询常规属性,比如 style(width: 100px) 或 style(color: red)。
2. 兼容性 (Browser Support)
截至目前(2024年),只有 Chrome / Edge (Blink内核) 完全支持。Safari 和 Firefox 还在观望或开发中。
评论
评论加载中……