说到写CSS,过去最烦的就是一条条写选择器,尤其是组件样式里,父元素、子元素、伪类、伪元素,动不动就一大串。以前只能借助Sass、Less这些预处理器,现在好消息来了——CSS原生嵌套规范已经正式在Chrome 120、Edge 120、Safari 17.2、Firefox 117里默认支持了。这意味着不需要任何构建工具,直接在浏览器里写嵌套代码。上个星期我把自己维护的组件库样式用原生嵌套重写了一遍,删掉了上千行重复选择器,今天来聊聊具体怎么做,以及有哪些坑。
一、原生嵌套到底长什么样?
先看一个最直接的对比。以前写卡片组件样式像这样:
.card {
background: white;
border-radius: 8px;
}
.card .card-title {
font-size: 18px;
}
.card .card-title:hover {
color: #333;
}
.card .card-footer {
border-top: 1px solid #eee;
}
用上原生嵌套之后,可以这样写:
.card {
background: white;
border-radius: 8px;
.card-title {
font-size: 18px;
&:hover {
color: #333;
}
}
.card-footer {
border-top: 1px solid #eee;
}
}
是不是跟Sass几乎一样?区别在于这个语法现在被浏览器直接支持。注意那个&符号依然有效,表示父选择器的引用。在嵌套里表示当前匹配的元素。
二、嵌套的选择器类型
1. 直接子代
以前写 .menu > .menu-item,嵌套后可以这样:
.menu {
> .menu-item {
display: inline-block;
}
}
2. 连接伪类
伪类是最常用的。原先写 .button:hover,现在在嵌套里用&连接:
.button {
background: blue;
&:hover {
background: darkblue;
}
}
3. 群组选择器
如果想让多个元素共享样式,可以这样:
.list,
.grid {
display: flex;
gap: 10px;
}
但在嵌套里,我想给.list和.grid同时设置子元素的样式,还不想破坏当前嵌套层级,可以这么写:
.list {
+ .grid {
margin-top: 20px;
}
}
不过这是相邻兄弟,跟群组没关系。记住:嵌套只是语法糖,最后解析出的选择器依然是有效的CSS。
三、最常用的用法:修饰符和状态
我重构最多的就是“按钮”组件。以前写多态按钮,各种依赖基础类再追加状态类。用嵌套以后,把所有状态都收进一个作用域里。
.btn {
padding: 8px 16px;
border: 1px solid transparent;
&.btn-primary {
background: blue;
border-color: darkblue;
}
&.btn-outline {
background: transparent;
border-color: gray;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.icon {
margin-right: 4px;
}
}
这样写的最大好处是,一眼就能看出所有跟 .btn 相关的样式都在一块,不用上下翻文件。
四、不得不提的@nest规则
虽然&能解决大部分场景,但有些需求必须用@nest。比如你想让一个子元素在父元素处于某个状态时出现特殊样式,但这种状态不能直接写在子元素的选择器里。举个例子,当父容器有 .is-active 类时,改变内部图标的颜色。
.card {
.icon {
color: gray;
@nest .card.is-active & {
color: blue;
}
}
}
这里&代表被嵌套的当前元素(.icon),前面加上 .card.is-active,最终产生选择器 .card.is-active .icon。如果不使用@nest,这种“父元素状态影响子元素”的关系很难在纯嵌套里写出来。不过事实上,用&的另一种写法也能实现:在 .card 里写 &.is-active .icon。但 @nest 更灵活,可以让你把这个逻辑放在更局部的位置。
五、重写一个“列表组件”的例子
为了让你们看到实际重构的潜力,这里展示一个列表组件。以前的CSS是这样的:
.list {
list-style: none;
margin: 0;
padding: 0;
}
.list .list-item {
padding: 12px 8px;
border-bottom: 1px solid #f0f0f0;
}
.list .list-item:hover {
background: #fafafa;
}
.list .list-item:last-child {
border-bottom: none;
}
.list .item-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
}
.list .item-content {
flex: 1;
}
.list .item-content .title {
font-weight: 600;
}
.list .item-content .desc {
color: #999;
}
重构后:
.list {
list-style: none;
margin: 0;
padding: 0;
.list-item {
padding: 12px 8px;
border-bottom: 1px solid #f0f0f0;
&:hover {
background: #fafafa;
}
&:last-child {
border-bottom: none;
}
}
.item-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
}
.item-content {
flex: 1;
.title {
font-weight: 600;
}
.desc {
color: #999;
}
}
}
感觉就像在写Sass,但是编译后依然是普通CSS,没有额外负担。每次改动样式,只需要找到对应的父级块,思路非常顺。
六、注意事项和坑
坑1:嵌套不能无限深
虽然规范没有明确限制深度,但浏览器解析会有一个最大深度(比如Chrome是256)。大多数组件写个五六层完全没问题,但深度太多除了性能,可读性也会下降。所以还是建议最多嵌套4-5层。
坑2:不支持循环引用
像 .a {} 里面再写 .a .b 是可以的,但不能写 .a { & .a {}} 这种把父选择器全部重复一遍的,虽然技术上能解析,但容易让选择器变得冗余。而且浏览器可能会把它解析成 .a .a 导致匹配不到预期元素。我一般只在描述子状态时才用 &。
坑3:媒体查询不能在嵌套里冒泡
在Sass里我们可以在嵌套内写 @media,它会自动把媒体查询提到外层。但CSS原生嵌套目前不支持这种“冒泡”行为。@media 可以直接写在嵌套规则里,但是它只能作用于那个规则块,不会像Sass那样把父选择器带出来。也就是说,你不能这样写:
.card {
@media (max-width: 600px) {
display: block;
}
}
现实是,这个 @media 会被解析成 @media (max-width: 600px) { .card { ... } }吗?其实规范里面有定义,它等价于:
@media (max-width: 600px) {
.card {
display: block;
}
}
嗯,实际上还是能用的,但需要注意其作用域是嵌套内的规则,不是整个父子上下文。测试下来这种“媒体查询跟随当前选择器”的行为跟预处理器一样。之前看规范说支持,用起来还正常,不过最好还是避免嵌套内直接用 @media,毕竟实际浏览器解析可能有些雷。
坑4:和CSS预处理器混用要小心
如果你项目本来就用Sass,那么在Sass代码里写CSS原生嵌套可能会导致Sass处理冲突。建议两种方式不要混合,要么全用Sass,要么纯CSS。我现在新项目直接用原生嵌套,就不再引入Less或Sass了,减少依赖。
七、既然浏览器支持,那还需要构建工具吗?
如果你不考虑老浏览器,现在核心浏览器都已经支持,完全可以直接写原生CSS。当然,如果还要兼容旧版本,建议还是用PostCSS插件去编译嵌套语法。不过今天主要聊的是原生特性,目的是让你知道有这么个东西,并且很快会普及。我在Vue、React项目里都试过,直接在 .vue 或 .jsx 里写嵌套CSS,浏览器控制台完全无报错。
八、总结
CSS原生嵌套让我这种写惯了Sass的人感到极度舒适,代码结构清晰了,而且省去了预处理器的编译步骤。虽然还有个别限制,但整体上完全够用。如果你最近也在重构样式,不妨把组件里的CSS改成嵌套试试,这种“返璞归真”的体验真的挺爽。特别是现在浏览器更新越来越快,CSS新特性一个接一个,前端终于变得更简单了。

