原生 CSS 嵌套和 @scope:写了这么多年 Sass 之后的几点真实感受

我维护的一个营销活动站,样式层一直是 Sass。最近做技术债清理时翻到一个 .scss 文件,里面 import 链套了四层,编译出来的 CSS 比源码膨胀了一倍,而真正用到 Sass 特性的地方掰着手指能数出来——无非是几处嵌套、几个变量、一个不到十行的 mixin。我盯着 node-sass(后来换 dart-sass)这个一直拖慢构建的环节想:现在原生 CSS 都支持嵌套和 @scope 了,这玩意儿还有多少非留不可的理由?

于是我花了大概两周,在这个站点上做了一次"能去掉多少 Sass"的实验。结论是去掉了一部分,但没全去掉,过程中踩的坑和留下来的取舍,我觉得比结论本身更有用。

原生嵌套不是 Sass 嵌套的复制粘贴

第一直觉是把 Sass 嵌套原样搬过来。比如这段 Sass:

1.card {
2  padding: 16px;
3  .title {
4    font-weight: 600;
5  }
6  &:hover {
7    box-shadow: 0 2px 8px rgba(0,0,0,.1);
8  }
9}

原生 CSS 现在也能写嵌套,上面这段几乎可以直接用:

1.card {
2  padding: 16px;
3  .title {
4    font-weight: 600;
5  }
6  &:hover {
7    box-shadow: 0 2px 8px rgba(0,0,0,.1);
8  }
9}

看起来一模一样。但底下有几处差异,第一次迁移时我全踩了一遍。

先说 & 在原生里的含义更严格。在 Sass 里 & 是字符串拼接,它就是把父选择器的文本接上去。所以 &:hover 编译成 .card:hover&.active 编译成 .card.active。原生 CSS 的 & 不是字符串拼接,它是一个真正的选择器引用,等价于 :is(父选择器)。日常用法里两者结果一致,但一旦父选择器是个选择器列表,区别就出来了。

举个能真栽跟头的例子。父级是 .a, .b 这样一个列表,里面嵌一条 & .item

1.a, .b {
2  & .item { color: red; }
3}

原生里 & 等价于 :is(.a, .b),所以这条实际是 :is(.a, .b) .item。这不只是写法差异——:is() 的优先级取它括号里参数中最高的那一个,如果哪天列表里混进一个 #main,整条规则的 specificity 会被 #main 抬到 id 级别,波及到 .a .item 这种本该很低优先级的匹配。Sass 那边编译出来的是 .a .item, .b .item 两条独立规则,各算各的优先级,没有这个连带效应。平时不写选择器列表感觉不到,一旦父级是列表、又嵌了东西,这个 :is() 语义就得盯着,别被莫名其妙拔高的优先级绕进去。

再看嵌套里的裸元素选择器解析不同。早期规范要求嵌套的选择器不能以元素名(标签)开头,要加 & 或包一层。后来规范放宽了,现在下面这样是合法的:

1.card {
2  span {
3    color: gray;
4  }
5}

它等价于 .card span。这点现在和 Sass 已经基本对齐了,但如果你的目标浏览器还覆盖比较老的版本,写 & span 更稳妥。我自己的习惯是后代选择器一律显式写 & span,可读性也更好,一眼能看出这是后代而不是别的关系。

最关键也最容易栽的一条,是原生嵌套不能拼接类名。

&__item 在原生里做不到

写 BEM 的人对这种 Sass 写法太熟了:

1.card {
2  &__title { font-weight: 600; }
3  &__body  { padding: 12px; }
4  &--featured { border: 2px solid gold; }
5}

Sass 把它们拼成 .card__title.card__body.card--featured。这是字符串拼接的威力。

原生 CSS 做不到这件事,因为前面说了,原生的 & 是选择器引用不是文本拼接。&__title 在原生里会被解析成 & 紧跟一个类型选择器 __title 之类的东西,根本不是你想要的 .card__title,要么不匹配要么直接是语法错误。

这一条直接把我对 BEM + 原生嵌套的幻想打碎了。如果项目重度依赖 BEM 的 &__ 拼接,原生嵌套帮不上忙——你要么保留 Sass,要么把所有 BEM 类名平铺展开手写:

1.card__title { font-weight: 600; }
2.card__body  { padding: 12px; }
3.card--featured { border: 2px solid gold; }

平铺写其实也没那么糟。BEM 的类名本来就设计成扁平、低耦合的,展开后反而少了一层"父子嵌套"的视觉错觉。我后来在这个项目里就是这么做的:BEM 组件的类名全部平铺,原生嵌套只用在那些天然有层级关系的地方(伪类、状态、媒体查询)。换句话说,迁移逼着我重新想清楚"我到底为什么要嵌套"——很多时候只是为了少写几个字符,那种嵌套去掉反而更清楚。

平铺还顺带解了一个搜代码的老痛点。以前 &__title 那种拼接,你在编辑器里全局搜 card__title 是搜不到定义的,因为源码里根本没有这个完整字符串,它是 Sass 编译时才拼出来的。团队新人接手一个组件,想找某个类名在哪定义的,经常对着搜索框一脸茫然。平铺展开后,card__title 在源码里就是一个可被搜索的完整字符串,定义在哪一眼就跳过去了。这点小小的可检索性,长期看比省那几个字符值多了。

嵌套里塞 @media@container,这块原生反而给力

有一个原生嵌套让我用得很顺、几乎无痛替换 Sass 的地方,是把媒体查询和容器查询直接嵌进选择器里。Sass 时代我早就习惯这么写:

1.card {
2  padding: 16px;
3  @media (min-width: 768px) {
4    padding: 24px;
5  }
6}

原生嵌套一模一样能写,语义还更干净——它不是文本拼接,而是浏览器原生理解"这条 @media 属于 .card 这个作用域":

1.card {
2  padding: 16px;
3  @media (min-width: 768px) {
4    padding: 24px;
5  }
6}

这在做响应式时特别舒服:一个组件的所有断点行为都收在它自己的规则块里,不用像早年那样把 .card 的手机样式写这、平板样式写那、断点散落一整个文件。容器查询同理,@container 也能这么嵌,配合现在这套原生能力,"组件自己响应自己容器的宽度"这件事,从声明到断点全在一个块里闭环。这一块是我这次迁移里少数"原生不但顶得上、还比 Sass 更顺手"的地方——因为它本来就该是浏览器的职责,Sass 当年只是替浏览器先垫了一手。

@scope 和它的 donut 下边界

真正让我觉得"这下 Sass 可以少用一大块"的,是 @scope

老问题:后代选择器会泄漏。.card p 会匹配 .card 里所有层级的 <p>,包括嵌进去的子组件内部的 <p>。Sass 时代我们靠 BEM 命名约定硬扛这个泄漏,靠纪律而不是机制。

@scope 给了机制。它能划定一个样式生效的范围,而且能同时设上边界和下边界:

1@scope (.card) to (.content) {
2  p {
3    margin: 0 0 8px;
4  }
5}

读法是:从 .card(上边界,scope root)开始,到 .content(下边界,scope limit)为止。在这个范围内的 <p> 才会被匹配,一旦进入 .content 内部就不再生效。这个"中间有效、内核挖空"的形状,规范里管它叫 donut scope——甜甜圈,中间那个洞就是被排除的下层区域。

这正好解决子组件泄漏。比如卡片里嵌了一个富文本区域 .content,富文本里的 <p> 不该被卡片的段落样式污染,过去只能靠 :not() 或者重置,现在一个 to (.content) 就划清了。

有一处边界语义我一开始搞反过,值得记牢:下边界那个 .content 元素本身算在作用域外面,从它开始(含它)就不再生效。也就是说 .content 自己身上、以及它内部的所有 <p>,都不吃这条卡片段落样式。我原本以为下边界是"到 .content 的子元素为止、.content 本身还算圈内",写完发现 .content 那层的样式没生效,查规范才知道 scope limit 是"从它起就挖空"。搞清楚这个"含边界"的方向之后,donut 那个洞的大小才算拿捏准了。

只写上边界也行,这时它就是个普通的"作用域包裹":

1@scope (.card) {
2  /* :scope 指向 scope root 本身,也就是 .card */
3  :scope {
4    border: 1px solid #ddd;
5  }
6  .title {
7    font-weight: 600;
8  }
9}

这里的 :scope 伪类指代作用域的根元素,方便你给容器本身写样式,而不用在 @scope 外面再重复一遍选择器。还有个隐式规则值得知道:@scope 块里那些不写 :scope、直接写的选择器(像上面的 .title),会被当作相对于 scope root 的后代来匹配——等于自动带了个隐含的 :scope 前缀。所以 .title 匹配的是 .card 范围内的 .title,而不是全页面的 .title。这也是为什么把一段样式挪进 @scope 后,原本会误伤到别处同名类的规则突然"老实"了:作用域把它的匹配面天然收窄到了根元素以内。

就近优先:@scope 改了一点优先级直觉

@scope 还带来一个和我多年直觉不同的规则。当多个 @scope 块都能匹配同一个元素、且优先级(specificity)相同时,胜出的是作用域根在 DOM 上离目标元素更近的那个。规范叫 scope proximity。

举个会真实遇到的场景:浅色主题套着深色主题区块。

1@scope (.theme-light) {
2  a { color: #1a73e8; }
3}
4@scope (.theme-dark) {
5  a { color: #8ab4f8; }
6}
1<div class="theme-light">
2  <a href="#">浅色链接</a>
3  <div class="theme-dark">
4    <a href="#">深色链接</a>  <!-- 这个会用深色,因为 .theme-dark 离它更近 -->
5  </div>
6</div>

深色区块里的链接拿到深色样式,因为 .theme-dark 这个作用域根在 DOM 树里离它更近。两条规则 specificity 完全一样,过去靠源码顺序决胜负,现在 @scope 让"距离"先于"顺序"。这个特性做主题嵌套、做局部覆盖时特别顺手,但你得记住它,否则会被"为什么没按源码顺序来"绕进去。

排查这类问题时我踩过一个坑:光看 CSS 文件推不出结果,因为 proximity 是 DOM 结构决定的,同一份样式挂在不同嵌套深度的元素上,赢家会变。所以调试 @scope 一定得开 DevTools 看具体那个元素的 computed 样式,看它到底命中了哪个作用域块——现在的开发者工具会把生效的 @scope 规则和被 proximity 压下去的规则一并列出,胜负一目了然。凭脑子在源码里推优先级,遇上 proximity 基本会翻车。

@scope 的边界选择器不进 specificity,但它管到的地方要小心

用久一点会撞上一个我一开始想当然搞错的细节:写在 @scope (...) 括号里的那个作用域根选择器,它自身不计入里面规则的优先级。也就是说 @scope (.card) { p { ... } } 里那条 p 规则,它的 specificity 就是一个纯 p(0,0,1),并不会因为外面套了 .card 就变成 .card p(0,1,1)。这跟嵌套的直觉正好相反——嵌套里 .card { p {} } 展开后 p 是要背上 .card 那份 specificity 的。

这个差异有实际后果。我曾经把一段样式从嵌套改写成 @scope 包裹,想着"不就是换个圈法",结果原本能压住的一条全局 p { margin: ... } 突然又冒出来生效了——因为改写后作用域内那条 p 的优先级从 .card p 掉回了裸 p,被全局规则以源码顺序反超。@scope 负责的是"匹配范围",不负责"提升优先级",这两件事得分开记,别指望套一层 @scope 就能顺带赢下优先级战争。

真要在作用域内引用根元素、借它抬一点优先级,用 :scope。前面提过 :scope 指向 scope root,写 :scope p 就相当于把 .card 那份 specificity 显式加回来了,需要时手动加、不需要时保持轻量,比嵌套那种"无脑背上父级权重"可控得多。

@scope 和 Shadow DOM 不是一回事

聊到"样式隔离",团队里资深前端第一反应是问:那这跟 Shadow DOM 有啥区别,不都是把样式圈起来吗?这个问题得掰清楚,否则容易拿错工具。

Shadow DOM 的隔离是强隔离,它建在文档树的边界上:一个自定义元素挂上 shadow root,里面的样式和外面就是两个世界,外部选择器默认穿不进去,内部样式也漏不出来,连全局 reset 都进不去(要靠 ::part、CSS 自定义属性这些显式开的口子才能通信)。它隔离的是 DOM 结构本身。

@scope 是软隔离,它根本没碰 DOM 边界,只是在选择器匹配这一层做文章——限定一条规则"从哪个元素开始、到哪个元素为止"生效。DOM 还是那棵完整的、扁平的树,全局样式照样能选到 @scope 圈内的元素,@scope 只是让它自己这条规则收敛在一个范围里。

所以两者的取舍很清楚:要做真正的组件封装、发一个谁都能用且不会被宿主样式污染的 Web Component,那是 Shadow DOM 的活;要在一个普通页面里给某段区域的样式划个作用域、防止后代选择器泄漏,又不想承担 Shadow DOM 那套"什么都进不来、全局 reset 也进不来"的封闭代价,@scope 才是趁手的。我这个营销站是后者——我要的只是"卡片段落样式别污染富文本子块",根本不需要把卡片做成一个封闭的 shadow 边界,那样反而要为穿透样式额外费一堆功夫。@scope 补的是 Sass 时代靠 BEM 命名硬扛的那类泄漏,不是 Shadow DOM 那种组件级封装,别拿它当 Shadow DOM 的平替。

一个容易忽略的坑:嵌套里声明和规则的先后

原生嵌套还有个 Sass 时代不存在的顺序问题,是我改一处样式时被绕进去才注意到的。原生 CSS 里,同一个规则块里的直接声明(property)和嵌套进来的子规则,是按书写顺序参与层叠的。看这段:

1.btn {
2  color: gray;
3  &.primary {
4    color: blue;
5  }
6  color: black;  /* 写在嵌套规则之后 */
7}

最后那句 color: black 写在 &.primary 之后,在层叠里它的"位置"就靠后。对普通 .btn 来说最终是 black(后面的覆盖前面的 gray);但这种把声明和嵌套规则交错着写的排版,可读性极差,也容易在别人接手时误判优先级。Sass 编译时会把嵌套规则整个提到外面去,声明顺序被重排,反而看不出这个问题。到了原生,浏览器直接按你写的顺序算,所见即所得。我给自己定的规矩是:一个块里,直接声明全写在最前面,嵌套的子规则全部靠后,别交错。这不是语法要求,是防止自己或同事被顺序绕晕的自律。

兼容性:2025 年这道坎已经基本过了

我当初最担心兼容性。实际查下来,到 2025 年原生嵌套在主流浏览器(Chrome、Edge、Safari、Firefox 的近版本)都已经稳定支持,@scope 也在主流浏览器铺开了,Firefox 是较晚跟上的那个。对我这个面向较新浏览器的营销站来说,直接用没问题。

如果你的用户盘子里还有不少老浏览器,有两条路:要么用 PostCSS 的相关插件把原生嵌套和 @scope 降级编译——这等于工具链还在,只是从 Sass 换成了 PostCSS;要么对 @scope 这种没有干净降级方案的特性,用 @supports 兜底再渐进增强。@supports 现在能直接探测选择器和 at-rule 的支持情况,比如判断浏览器认不认 @scope

1@supports at-rule(@scope) {
2  /* 支持 @scope 的浏览器走这里 */
3}
4@supports selector(& > *) {
5  /* 支持原生嵌套选择器的走这里 */
6}

这样能给不支持的浏览器留一份平铺的老样式做保底,支持的再吃上作用域隔离。不过 at-rule() 这种探测语法本身也还比较新,用之前我特意确认了它在我关心的那几个浏览器里靠谱——嵌套和 @scope 都用上了,探测手段本身却不被支持,那就本末倒置了。我的站点因为受众明确,最后干脆没做降级,直接裸用;但这套 @supports 兜底的思路,是我给那些用户盘更杂的项目留的方案。

为什么我没把 Sass 全删掉

实验做完,Sass 在这个项目里确实瘦了一大圈,但没归零。留下来的都是原生还顶不上的,可以分三类看。

变量这一类,其实可以全换成 CSS 自定义属性 --x。CSS 变量比 Sass 的编译期变量还更强:它是运行时的、能被 JS 读写、能跟着主题切换。这部分我全迁了,一个 Sass 变量都没留。

mixin 这一类,原生没有对应物。我那些 @mixin——比如统一的"按钮基础样式"——没法直接翻译成某个 CSS 特性。我把其中一部分改写成了 utility class,用一组原子类拼出原来 mixin 的效果;剩下那些确实需要传参、按参数生成不同样式的,只能还留着 Sass mixin。

函数和循环这一类,缺口最硬。@function@each@for 这些原生统统没有。我有一段用 @for 批量生成间距类的代码(.mt-1.mt-10 那种),原生写不出来,只能原样留着。CSS 自己的 @function(自定义函数)规范在路上、已经有早期实现冒头,但还远不能依赖它做生产,只能算个"在观望的下一步"。

还有一类容易被漏掉的是编译期条件 @if/@else。我有几个 mixin 里带 @if $variant == 'ghost' { ... } @else { ... } 这样的分支,按传入参数生成不同样式。原生这块也没有——@media@supports@container 那些 @ 规则是运行时按环境求值的条件,跟 Sass 那种"编译时根据变量值决定生成哪段 CSS"是两码事,替不了。所以凡是"根据一个编译期已知的值,决定要不要吐出某段声明"的逻辑,仍然只能留在 Sass 里。

另外顺一句 @import 的事。开头那个膨胀一倍的锅,一半得算在老式 @import 头上——它会重复引入、层层嵌套,同一段基础样式被带进来好几遍。这轮清理我把残留的 Sass 也顺手从 @import 换成了 @use/@forward,模块只加载一次、命名空间也清爽了,光这一步就把编译产物又压掉一截。这跟原生没关系,纯是 Sass 内部的现代化,但和"给构建瘦身"是同一件事的两面。

所以我的真实取舍是这样的:嵌套和作用域用原生,变量全面转 CSS 自定义属性,而 mixin、函数、循环、条件这些"编译期生成"的能力暂时还得靠 Sass 兜着。结果是 Sass 文件从四层 import 链瘦成了一个只剩 mixin 和几个生成循环的工具文件,构建里那个慢环节处理的量小了很多。

这次清理没给我"终于干掉预处理器"的爽感,反而让我更清楚地看到分界线在哪:浏览器把选择器组织(嵌套)和作用域隔离@scope)这两件事接管了,做得还比 Sass 干净;但编译期的代码生成——mixin 展开、循环、函数——仍然是预处理器的地盘。等原生 @function 真正可用、再有个像样的 mixin 机制,那一天才轮得到认真讨论"要不要彻底告别 Sass"。在那之前,我的策略是能用原生的就别再过 Sass,把预处理器收缩到它真正不可替代的那一小块上。两周的实验下来,我对这种"渐进退场"反而更踏实——一刀切的迁移最容易在边角处崩盘。