先说一件我最近才意识到的事。CSS 的全局性其实一直没被真正解决。
我们手上有 BEM、有 CSS Modules、有各种前缀约定、有 Shadow DOM。但这些方案做的事不是”让 CSS 有作用域”,而是”让你在命名上模拟作用域”。BEM 靠前缀类名,CSS Modules 靠编译时改名,前缀前缀永远是约定的、可被破坏的。写代码的人忘了起前缀,样式就漏出去了。
真正的 CSS 原生作用域,直到 @scope 出现才算有了。
这篇文章从一个我踩过的实际场景讲起——一个卡片网格,卡片里又能再放卡片,也就是常见的”评论里能引用评论”这种嵌套结构。以前要写一堆 BEM 前缀来防样式互相干扰,现在用 @scope 可以直接圈出作用域。
先看老问题:嵌套组件的样式泄漏
场景很简单。卡片有一个标题,标题字号 20px 加粗。卡片里再放一张卡片的时候,内层卡片的标题不该用外层卡片的样式——它应该更小、更轻。
用传统类名写法,代码得长这样:
<div class="card">
<h3 class="card__title">外层标题</h3>
<p class="card__text">...</p>
<div class="card card--nested">
<h3 class="card__title">内层标题</h3>
<p class="card__text">...</p>
</div>
</div>
CSS 里得用后代选择器小心处理:
.card__title {
font-size: 20px;
font-weight: 700;
}
.card--nested .card__title {
font-size: 15px;
font-weight: 500;
}
/* 三层嵌套怎么办?再来一遍 */
.card--nested .card--nested .card__title {
font-size: 13px;
}
问题是每一层嵌套都要新加一条规则,嵌套层数不定的时候要一直写到 N 层。用 :not() 也麻烦——它只能排除一个具体的选择器,不能表达”别进入内层卡片”。
还有一个更隐蔽的问题:.card__title 这个规则在外层卡片、内层卡片、四层嵌套卡片里都会匹配到。你只是在写”标题该怎么显示”,但实际会作用到所有后代。这就是”作用域”缺失带来的直接后果。
@scope 是什么
一句话:它给 CSS 规则加了一个可选的”作用域边界”,只有落在作用域内的元素才会被匹配。
最小例子:
@scope (.card) {
.title {
font-size: 20px;
font-weight: 700;
}
}
读法是”.card 内部的 .title,样式只在 .card 这个范围内有效”。
如果你觉得这段和 .card .title 的后代选择器长得差不多——确实很像,但行为上差了两点。
第一,作用域的根是动态确定的。 @scope (.card) 里,”根”对应的是最近的那个 .card 祖先。这是层级查找的结果,不是写死的选择器。所以里面用 :scope 可以引用到这个最近的根,不管嵌套了多少层 .card。
第二,它的作用域是有下界的。 后面会讲到的”, to (.card)” 语法,可以让作用域在内层卡片那里停住。这是普通后代选择器做不到的。
起止选择器:把作用域变成”甜甜圈”
回到嵌套卡片。真正想要的其实是:样式对自己这一层的卡片有效,但不要往下渗到内层卡片里去。
这就是 @scope ... to ... 的用武之地:
@scope (.card) to (.card) {
.title {
font-size: 20px;
font-weight: 700;
}
.text {
color: #4a5056;
line-height: 1.6;
}
}
读法是”从 .card 开始,到内层 .card 之前结束”。作用是:外层卡片的 .title 会应用这套规则,内层卡片的 .title 不会——因为内层卡片就是那个”结束点”,样式在这一层停下。
但因为内层卡片本身也是一个 .card,它自己会再次成为作用域的起点,所以它自己的 .title 依然会命中同一条规则,只是不会再往外层继续扩散。这种效果俗称 “donut scope“,甜甜圈形状——中心是空的,但圆环本身套了好几层。
多加一步:如果想让内层卡片的样式不一样,就在内层卡片上写另一个 scope 或者用类名区分:
@scope (.card) to (.card) {
.title { font-size: 20px; }
}
@scope (.card .card) to (.card) {
.title { font-size: 15px; }
}
注意第二个 scope 的起止选择器都写成 .card 加 .card、to (.card)。它匹配的是”内层卡片”,但样式不会继续往里渗。三层嵌套的话,第三个 .card .card .card 会再次命中第一个 scope 的规则,效果是”奇偶层用不同样式”,正好是”评论里引用评论”常需求的那种效果。
:scope 和 & 的区别
在 @scope 里写样式的时候,有两个特殊的引用方式。
:scope 指的是”当前作用域的根”。如果写 @scope (.card) { :scope { ... } },等于直接选中了 .card 本身。
@scope (.card) {
:scope {
border-radius: 12px;
padding: 20px;
background: #fff;
}
}
这段等价于写 .card { ... },但表达上更清楚——它是”这个作用域的根元素该长什么样”。
更有用的是 :scope > .title 这种写法。它表示”直接作为作用域根子元素的 .title“,也就是排除了更深层级的匹配。
@scope (.card) to (.card) {
/* 只要直接子元素的 title */
:scope > .title {
font-size: 20px;
}
/* 卡片里任意深度的 title */
.title {
color: #1f2328;
}
}
两种写法可以混着用,各自表达不同的意图。
那么 & 呢?& 是 CSS 嵌套(CSS Nesting)的引用,指的是”当前规则的选择器”。在 @scope 里写嵌套的时候,& 依然有效,它和 :scope 是两回事:
@scope (.card) {
.title {
font-size: 20px;
/* & 指的是 .title */
&:hover { color: #3b82f6; }
}
}
&:hover 展开后等价于 .title:hover,是在卡片作用域里的 .title 上悬停。这里 & 和 :scope 各管各的,没有冲突。
proximity:@scope 改写了优先级规则
这部分是 @scope 里最反直觉、但也最有用的地方。
普通 CSS 的优先级看选择器的 specificity 和书写顺序。@scope 引入了一个新的维度:proximity(邻近度)。当两个规则作用于同一个元素、且都来自 @scope 内部时,proximity 高的那个赢——proximity 指的是”这个元素距离作用的根有多近”。
看一段代码:
@scope (.card) {
.title { color: red; }
}
@scope (.card .card) {
.title { color: blue; }
}
假设 HTML 是这样:
<div class="card">
<h3 class="title">外层</h3>
<div class="card">
<h3 class="title">内层</h3>
</div>
</div>
内层<h3> 同时会匹配两个 scope。第一个 scope 的根是外层 .card(距离 1 层),第二个 scope 的根是内层 .card(距离 0 层)。proximity 比较下来,第二个更近,所以颜色是蓝的。
好处是——不需要写 !important 也不需要靠书写顺序去打架。哪一层离得近,哪一层的样式生效。这比”最后写的赢”符合直觉太多了。
但要注意 proximity 只在”规则来自不同的 @scope 块”或者”同一块里不同层级”时起作用。同一作用域内,普通 specificity 规则照旧。所以下面这条和上面那条比还是白的那个输:
@scope (.card) {
div { color: white; } /* specificity 0,0,1 */
.title { color: red; } /* specificity 0,1,0 —— 赢 */
}
两者的 proximity 一样(都在同一个 scope 里),回到普通的 specificity 比较。
和 BEM / CSS Modules 的对比
这个问题在被问到不止一次,我把自己半年用下来的判断摆出来。
BEM 的优势是零依赖、任何场景都能用、静态检查工具认识。问题也很明显:命名爆炸、重构代价大、真正的样式隔离没有——它只是让你自己保证不冲突。
@scope 的优势是原生、能表达”在哪里停”这种 BEM 表达不了的东西。短板是浏览器支持还在完善,Firefox 到最近才跟上。
CSS Modules 的优势是编译后类名不冲突,团队不需要约定。它跟 @scope 不是一回事——前者解决”类名冲突”,后者解决”样式作用范围”。这俩其实可以并存:用 CSS Modules 生成唯一类名,用 @scope 划出作用域。
我自己现在的做法是:新项目上 @scope,老项目保持 BEM 但把嵌套组件那些特别扭的地方换成 @scope。因为老项目里的 BEM 命名是既成的资产,贸然全换会有一堆工具链问题。但同一个组件里的嵌套冲突,用 @scope 改掉反而是收益最大也最不痛的一种改法。
把旧组件改一遍
拿一个实际在做的东西举例。这是一个通知列表,每个通知是一条卡片,通知里可以嵌入引用它的另一条通知。原来的写法是 BEM 加层级选择器,很难看,改成 @scope 之后简洁了不少。
<ul class="notices">
<li class="notice">
<div class="title">系统维护提醒</div>
<div class="body">今晚 22:00 到 23:00 会短暂停服。</div>
<div class="notice">
<div class="title">补充说明</div>
<div class="body">实际维护时间以系统公告为准。</div>
</div>
</li>
<li class="notice">
<div class="title">账号安全提醒</div>
<div class="body">检测到有异地登录,请及时修改密码。</div>
</li>
</ul>
CSS 部分:
@scope (.notice) to (.notice) {
:scope {
padding: 16px 20px;
border: 1px solid #e4e6eb;
border-radius: 12px;
background: #fff;
margin-bottom: 12px;
}
:scope > .title {
font-size: 16px;
font-weight: 600;
color: #1f2328;
margin-bottom: 6px;
}
:scope > .body {
color: #4a5056;
font-size: 14px;
line-height: 1.65;
}
}
/* 内层嵌套的通知,整体视觉上收一点 */
@scope (.notice .notice) to (.notice) {
:scope {
padding: 12px 16px;
border-left: 3px solid #3b82f6;
background: #f7f8fa;
margin-bottom: 8px;
}
:scope > .title {
font-size: 14px;
font-weight: 600;
color: #3b82f6;
margin-bottom: 4px;
}
:scope > .body {
font-size: 13px;
color: #6b7076;
line-height: 1.6;
}
}
现在读一下这段 CSS 的逻辑。
第一段处理所有通知卡片的基础样式。它的作用域从 .notice 开始,到里面的第一个 .notice 之前结束。:scope 是外层那张卡片,:scope > .title 是它的直接子标题——因为有 >,不会误伤更深层的标题。
第二段处理嵌套的通知。.notice .notice 匹配所有内层卡片,样式以蓝色边框和浅灰底做视觉区分。同样用 :scope > 精确控制到直接子级。
如果出现三层嵌套(通知里引用通知,通知里又引用通知),第二段的规则会再次命中——因为 .notice .notice 对第三层也成立。这样每层嵌套都会用内层的样式,视觉上始终是”缩进的浅灰卡片”,符合直觉。
整个样式表没有任何类名前缀,不需要 .notice .notice .notice .title 这样的层级选择器。这就是 @scope 最直接的价值。
几个实际会遇到的坑
起止选择器可以更复杂但不能省略括号
@scope (.a .b) to (.c) 是合法的,起止都是完整的选择器。但不能写成 @scope .a .b to .c——必须有括号。这个语法细节容易漏。
匿名作用域
如果你写的是:
@scope {
.title { ... }
}
没有起止选择器,这就是”匿名作用域”。作用域的根是最近的一个”有样式的”祖先,语义上非常模糊,几乎不会用。正常写代码总是带起止选择器。
:scope 在 @scope 外的行为
:scope 这个伪类在 @scope 之外也是存在的,它就等于 html(根元素)。所以以前写 :scope .title { } 就等于 html .title { }。现在在 @scope 里它才真正发挥”作用域根”的作用。
和 Shadow DOM 不用二选一
有同事问我”是不是有了 @scope 就不用 Shadow DOM 了”。两者解决的问题其实不完全一样。@scope 控制的是样式扩散范围,Shadow DOM 控制的是节点树的隔离。Web Components 用 Shadow DOM 可以做到 JS 层和 CSS 层的双重隔离,@scope 只解决后一半。要用 Shadow DOM 的场景还是用 Shadow DOM,@scope 更适合传统标签结构的项目。
浏览器兼容性
Chrome、Edge、Safari 都已经发布正式支持。Firefox 稍晚,但最近也稳定了。如果不支持 @scope,整块规则会被直接丢弃——页面不会报错,样式直接没生效。
稳妥的兜底做法是先写一份 BEM 版本的样式,再用 @supports 判断包一层 @scope 版本把优先级推上去:
/* 兜底:所有浏览器都认的写法 */
.card .title { font-size: 16px; }
@supports (selector(&:has(*))) {
/* 支持 @scope 的时候用作用域覆盖 */
@scope (.card) to (.card) {
:scope > .title { font-size: 16px; }
}
}
判断条件用 :has() 是因为这两个特性的浏览器版本线大致重叠,实际用哪个特性检测都可以,只要它在目标版本的支持时间上足够接近。
完整可运行示例
把前面那个通知列表的片段拼成可以直接打开的单文件。保存成 HTML 用 Chrome 或 Safari 打开。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>通知列表示例</title>
<style>
body {
margin: 0;
padding: 40px 24px;
font: 15px/1.6 -apple-system, "Segoe UI", "PingFang SC", sans-serif;
color: #1f2328;
background: #f7f8fa;
}
.notices {
max-width: 520px;
margin: 0 auto;
padding: 0;
list-style: none;
}
@scope (.notice) to (.notice) {
:scope {
padding: 16px 20px;
border: 1px solid #e4e6eb;
border-radius: 12px;
background: #fff;
margin-bottom: 12px;
}
:scope > .title {
font-size: 16px;
font-weight: 600;
color: #1f2328;
margin-bottom: 6px;
}
:scope > .body {
color: #4a5056;
font-size: 14px;
line-height: 1.65;
}
}
@scope (.notice .notice) to (.notice) {
:scope {
padding: 12px 16px;
border: 1px solid #d6ddf7;
border-left: 3px solid #3b82f6;
border-radius: 8px;
background: #f4f7ff;
margin-top: 12px;
margin-bottom: 0;
}
:scope > .title {
font-size: 14px;
font-weight: 600;
color: #3b82f6;
margin-bottom: 4px;
}
:scope > .body {
font-size: 13px;
color: #5a6a8a;
line-height: 1.6;
}
}
</style>
</head>
<body>
<ul class="notices">
<li class="notice">
<div class="title">系统维护提醒</div>
<div class="body">今晚 22:00 到 23:00 会短暂停服,请提前保存工作内容。</div>
<div class="notice">
<div class="title">补充说明</div>
<div class="body">实际维护时间以系统公告为准,可能前后浮动 15 分钟。</div>
</div>
</li>
<li class="notice">
<div class="title">账号安全提醒</div>
<div class="body">检测到有异地登录,请及时修改密码并检查绑定邮箱。</div>
</li>
</ul>
</body>
</html>
打开之后能明显看到:外层的两个通知卡片是白底黑字,内层那张缩小了一圈、带蓝色左边框、浅蓝底。所有样式都没用到前缀类名,.title 和 .body 这两个通用名在作用域内各管各的。
最后一点想法
CSS 这几年的新特性里,有一些是纯效率提升(比如 :is() 少写几个选择器),有一些是新的能力(比如容器查询、滚动驱动动画)。@scope 属于后者,它真正做到了一件以前 CSS 做不到的事情:明确地表达”样式在哪停”。
以前我们只能表达”样式从哪开始生效”,至于它往下面渗到多深,完全靠选择器精确度和命名约定去猜。@scope 第一次让”不要越过这个元素”成了一个可写的表达式。
配合 :scope > 限定直接子级、配合 proximity 让近处的规则自然覆盖远处的规则,一个中等复杂度的组件从 BEM 迁移过来,能省掉大约 30% 到 40% 的类名字符。这带来的是更短的 CSS 和更好的可读性。
如果你手头正有一个嵌套层级很深的组件、因为样式互相渗透改了很久,这可以成为一个很好的切入点。它不涉及 JS 逻辑,风险低,收益直观。

