先说一个我遇到的问题。
项目里有个用户卡片组件,结构大致是头像、昵称、一段简介。样式写得很朴素:
.card .title {
font-size: 20px;
font-weight: 600;
}
.card .avatar {
width: 48px;
height: 48px;
border-radius: 50%;
}
一开始没问题。后来产品要在卡片里加一个”最近互动”区域,这个区域本身又是一张卡片——同样的类名,同样的结构。结果内层卡片的昵称也变成了 20px,头像是 48 像素,跟外边那个完全一样大。
这其实是意料之中的,.card .title 这个选择器命中了两层里的所有 title。当时的处理是加一层直接子代:
.card > .title { }
修完管了三个月。后来设计改版,标题外面包了一层 div 做对齐,> 直接失效,样式一夜之间全没了。
这类问题本质上是同一个:CSS 缺少”作用域”这个概念。选择器只能表达”在某元素里”,表达不了”在这一片区域里,但到此为止”。
一、这些年我们用过的四种隔离方案
在讲 @scope 之前,先把已有的选择摆一遍。知道它们各自卡在哪,才能判断什么时候该换。
BEM 命名
.user-card { }
.user-card__title { }
.user-card__avatar { }
靠命名前缀制造”伪作用域”。好处是零工具依赖、输出直白。代价是全凭自觉——只要有人写了一次 .title,隔离就出现了裂缝,而且这种裂缝在代码评审里不容易被发现,因为它不会报错。
CSS Modules / Vue scoped / styled-components
构建期给类名加哈希,物理上不可能撞车。这是目前工程化项目里的主流方案。
但它带来两个新麻烦。一是动态类名让调试变得困难,DevTools 里看到的是一串 _title_a3f2b,搜不到源码。二是”从外部覆盖组件样式”变得很别扭,你只能加 :deep()、::v-deep、《:global》这类穿透语法,每个框架的写法还都不一样。
Shadow DOM
浏览器原生隔离,最彻底。但代价也最大:外部样式默认进不去,需要注入 adoptedStyleSheets 或 CSS 自定义属性做通信;表单控件的部分行为在 shadow 里和外面不一致;和 React 的合成事件绑定时偶尔会出岔子;服务端渲染时还要处理样式收集。
用 Shadow DOM 去做一个卡片组件的隔离,属于用高射炮打蚊子。
@scope
这是第四种,也是这篇要讲的主角。它做的事情是:在同一个样式文档里,划出一片区域,区域内的规则不会越界。
它不改变类名,不依赖构建工具,不需要额外的 DOM 层级。
二、基本语法:三十秒上手
@scope (.card) {
.title { font-size: 20px; }
img { border-radius: 8px; }
}
读法是:在 .card 里,.title 长这样,img 长这样。规则里的选择器会自动带上”必须在 .card 子树内”这个前提。
写成传统选择器就是 .card .title 和 .card img。那区别在哪?三个地方。
差别一:特异性不累加
.card .title 的特异性是 (0,2,0)。@scope (.card) { .title { } } 里的 .title 特异性仍然是 (0,1,0)。
作用域根选择器不计入特异性,它只影响后面要讲的作用域邻近性。这意味着组件内部的样式不会因为”被包了一层作用域”而变得难以覆盖。
差别二:可以用 :scope 指代根
@scope (.card) {
:scope {
border: 1px solid #e5e5e5;
padding: 16px;
border-radius: 12px;
}
}
:scope 在块内指的是那个匹配了 .card 的元素本身。所以组件的”外壳样式”和”内部样式”可以写在同一个块里,不用把类名再抄一遍。
差别三:可以有边界
这是 @scope 真正独有的能力,也是它值得被引入生产的原因。
三、甜甜圈作用域:to 边界
@scope (.card) to (.card__body) {
img { border-radius: 50%; }
}
这个 to (.card__body) 是一个上限。匹配从 .card 开始往下走,走到 .card__body 就停住,不再往里进。
所以效果是:.card 自己的图片会变圆,但 .card__body 里面的图片不受影响。形状上就是一个甜甜圈——外面一圈是作用域,中间挖了个洞。
这里有个细节需要注意:to 排除的是边界元素以下的子树,边界元素本身仍然算在作用域里。也就是说 .card__body 这个元素自己是受规则影响的,只有它的后代被划出去了。这一点和直觉有点出入,我第一次写的时候把边界当成”包括边界在内全部排除”,结果样式范围比预期小了一圈。写的时候建议随手搭个小 demo 确认一下。
以前的 CSS 里没有等价写法。要实现类似效果,只能靠 :not() 一层层往外剥,或者给中间那层元素加个特殊类名然后显式重置。两种写法都不干净,而且结构一改就得重来。
四、案例一:嵌套卡片各管各的
回到开头那个问题。用 @scope 重写:
@scope (.card) to (.card__body) {
:scope {
border: 1px solid #e8e8e8;
border-radius: 12px;
padding: 16px;
}
.title {
font-size: 20px;
font-weight: 600;
margin: 0;
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
}
}
配上结构:
<div class="card">
<img class="avatar" src="a.jpg">
<h3 class="title">张三</h3>
<div class="card__body">
<div class="card">
<img class="avatar" src="b.jpg">
<h3 class="title">李四</h3>
</div>
</div>
</div>
外层卡片应用了整套样式,内层卡片不受影响——因为它整个子树都在 .card__body 的界限之外。
那内层卡片就完全没有样式了吗?不是。内层那个 .card 自己也匹配根选择器,所以规则会再应用一次,只是各管各的子树的。这正好是我们想要的效果。
如果产品要求内层是缩小版,再加一条:
@scope (.card .card) {
.avatar { width: 32px; height: 32px; }
.title { font-size: 15px; }
}
两条规则同时命中内层的 .avatar,谁赢?答案是这条新的。原因不是特异性更高——两条规则里的 .avatar 特异性都是 (0,1,0)——而是作用域邻近性。下一节展开。
五、作用域邻近性:@scope 带来的排序新维度
这是 @scope 里最反直觉、但一旦理解就非常好用的机制。
@scope (.card) {
p { color: navy; }
}
@scope (.card .comment) {
p { color: olive; }
}
<div class="card">
<div class="comment">
<p>这段文字是什么颜色?</p>
</div>
</div>
两条规则都命中了这个 p。它们的特异性相同,都是 (0,0,1)——因为作用域根选择器不计入特异性。
决定胜负的是”哪个作用域根离元素更近”。.comment 是 p 的父级,.card 是祖父级,前者更近。所以结果是 olive。
这个机制叫 scope proximity,它被插入在层叠排序的中间位置,比较顺序大致是:
来源与重要性
→ 层叠层级(@layer)
→ 作用域邻近性(@scope)
→ 特异性
→ 书写顺序
注意邻近性排在特异性之前。也就是说,就算你把外层的规则写成 #main .card p 这种高特异性选择器,只要邻近性输了,还是内层赢。
这个顺序和我们习惯的”特异性定生死”很不一样,但它其实解决了 CSS 里一个长期的痛点:组件的局部样式应该天然压过全局样式,而不应该靠堆选择器权重去赢。
和 @layer 一起用
既然邻近性排在层级之后,那就意味着 @layer 的优先级仍然高于作用域邻近性。这个组合很有用:
@layer base, components, overrides;
@layer components {
@scope (.card) {
.title { font-size: 20px; }
}
}
@layer overrides {
@scope (.card) {
.title { font-size: 24px; }
}
}
即使两条规则的邻近性完全一样,overrides 层里的那条也会赢,因为 layer 的排序更靠前。这种写法让”设计系统默认值”和”业务覆盖值”之间的关系变得非常明确,不再需要靠 !important 或者更长的选择器去打架。
六、案例二:富文本正文的样式边界
后台系统里,富文本是最容易出样式污染的地方。编辑器产出的 HTML 结构不可控,用户还可能粘贴带内联样式的片段进来。
@scope (.article-body) to (.article-body .embed-block) {
:scope {
line-height: 1.85;
color: #222;
}
:where(h2) {
font-size: 1.5rem;
margin: 2em 0 .6em;
}
:where(p) {
margin: 0 0 1.1em;
}
:where(a) {
color: #0a67c2;
text-underline-offset: 3px;
}
:where(img) {
max-width: 100%;
height: auto;
border-radius: 6px;
}
:where(blockquote) {
margin: 1.5em 0;
padding-left: 1em;
border-left: 3px solid #ddd;
color: #555;
}
}
有几处刻意的写法。
为什么要用 :where()
富文本内容是外部数据,你不知道哪天运营会粘一段带样式的 HTML 进来。:where() 把这段规则的特异性全部归零,等于告诉浏览器”这些是底线样式,任何其他规则都能覆盖它们”。
如果不加 :where(),@scope (.article-body) { h2 { } } 的特异性是 (0,0,1)。看着很低,但文章页里往往还有一条 .article-body h2 { } 的老规则,特异性 (0,1,1),两者一撞就出问题。归零之后这类冲突基本消失。
为什么要有 embed-block 边界
嵌入的代码块、第三方视频容器、图表组件,它们内部有自己的样式体系。to (.embed-block) 把这些子树从正文样式里摘出去,避免正文的 img { max-width: 100% } 把图表里的 canvas 也一起改了。
:scope 的用处
:scope { line-height: 1.85 } 只作用于正文容器本身,里面的 line-height 会继承下去。这比写 .article-body { line-height: 1.85 } 好在两点:一是类名不用重复写;二是如果这个容器同时也是另一个组件的作用域根,两条 :scope 规则会按邻近性自然排序。
七、案例三:可移植的评论组件
这个案例想说明的是 @scope 在”组件移植”上的价值。
假设你写了一个评论列表组件,希望它在任何地方使用都不变形,同时允许宿主页面通过自定义属性调整尺寸。
@scope (.comment-list) {
:scope {
container-type: inline-size;
--avatar-size: 36px;
display: flex;
flex-direction: column;
gap: 18px;
}
.comment {
display: grid;
grid-template-columns: var(--avatar-size) 1fr;
gap: 12px;
}
.comment__avatar {
width: var(--avatar-size);
height: var(--avatar-size);
border-radius: 50%;
object-fit: cover;
}
.comment__body { min-width: 0; }
.comment__meta {
font-size: 13px;
color: #888;
}
}
@container (max-width: 420px) {
.comment {
grid-template-columns: 1fr;
}
.comment__avatar { display: none; }
}
注意 @container 放在了 @scope 外面。这样写是为了让容器查询的规则不参与作用域邻近性比较——如果放进去,它和内层其他规则的排序会变得不好推理。
宿主页面想调尺寸,只需要:
.sidebar .comment-list {
--avatar-size: 28px;
}
CSS 自定义属性不受作用域限制,这是组件暴露”受控的配置点”最干净的方式。比用 :deep() 去改内部类名要好得多,因为内部结构怎么改都不会影响宿主。
八、六个实际会踩到的坑
1. to 边界排除的是子树,不是边界元素
这一点前面提过了,但值得再强调一次,因为它是最容易判断错误的地方。如果你想表达”连边界元素一起排除”,to 做不到,得改成把边界选择器写成边界元素的父级,或者用 :not() 显式排除。
2. @scope 不降低特异性
很多人以为”作用域内的样式更好覆盖”,这个理解只对了一半。作用域根不贡献特异性,但规则里的选择器该多少还是多少。@scope (.card) { .card .title { } } 的特异性仍然是 (0,2,0),一点没降。
想要真正压低优先级,得靠 :where() 包裹。我在组件库里养成了一个习惯:所有”默认外观”类规则一律写 :where(),所有”必须生效”类规则不写。这样外部使用者不用查文档也知道哪些能覆盖。
3. 隐式前缀不等于后代选择器
@scope (.card) { .title { } } 和 .card .title { } 在匹配结果上确实一样,但它们在层叠排序里完全不是一回事。前者走邻近性,后者走特异性。
这个差别在调试时特别隐蔽。你看到两条规则都写了同样的选择器,纳闷为什么内层赢了,很可能就是因为其中一条在 @scope 里。
4. 作用域不会穿透 Shadow DOM
@scope (.wrapper) { button { } } 无法选中 wrapper 内部某个自定义元素(带 shadow root)里的 button。这不是 @scope 的缺陷,而是层叠上下文本身的边界。
换句话说,@scope 和 Shadow DOM 不是竞争关系。你如果真需要强封装(比如做嵌入到客户页面的 widget),继续用 Shadow DOM;@scope 用来解决同一文档内的样式越界,这就够了。
5. 别把所有 CSS 都塞进 @scope
我见过有人把整个项目的样式都包进了 @scope (:root)——那样做就等于没做,还白白增加了一层邻近性比较,调试的时候 DevTools 里全是嵌套的层级,很难受。
现实的做法是分三类处理:全局基础样式(reset、字体、颜色变量)用普通写法;组件级样式用 @scope 划边界;工具类(.sr-only、.flex-1)保持全局,因为它们本来就是跨作用域使用的。
6. 嵌套里的 & 和 :scope 是两回事
@scope (.card) {
.title {
&:hover { color: red; }
}
}
这里的 & 指的是 .title,不是 .card。想引用作用域根,必须写 :scope。这个混淆我见过好几次,特别是在同时用 SCSS 的项目里,两套嵌套机制叠在一起的时候更容易晕。
@scope (.card) {
.title {
/* 想突出整张卡片,应该这样写 */
:scope:hover & { box-shadow: 0 4px 12px rgba(0,0,0,.08); }
}
}
关于浏览器支持与降级
目前主流浏览器都已经陆续支持 @scope,但在写这篇文章的时候,各引擎的版本进度还是不太一样,上线前用 caniuse 确认一下目标用户群最稳。
降级的处理比较麻烦,因为 to 边界基本没有等价的自动转换方式。可行的做法有两种:一是在构建阶段用 lightningcss 或 postcss 插件把不带边界的 @scope 展开成后代选择器(能覆盖大部分简单用例);二是只把 @scope 用在”锦上添花”的地方,让它在不支持的浏览器里退化成无样式,而不是错样式。
第二点很重要。如果你的核心布局依赖 @scope,那必须做完整的降级方案;如果只是用它来隔离一些装饰性样式,那不做降级也能接受。
九、怎么判断该不该用它
给出几个判断标准,按我自己项目的实际取舍整理的。
该用 @scope 的场景:组件库内部样式;富文本、Markdown 渲染结果;第三方注入的内容区域;会有嵌套的同类组件(卡片套卡片、评论套评论);需要从外部覆盖但不想暴露类名的场景。
继续用 BEM 或 CSS Modules 的场景:项目很小、样式文件就几十行;团队对构建产物的可预测性要求很高;需要对老浏览器做完整支持而无法引入构建期的转换插件。
继续用 Shadow DOM 的场景:样式需要隔离到”外部完全无法影响”的程度;组件要嵌入到不受你控制的第三方页面里;需要同时隔离 DOM 结构而不只是样式。
十、写在最后
回过头看,CSS 这些年补上的能力里,@scope 算是定位比较特殊的一个。它没有引入新语法去替代旧写法,也没有改变渲染模型,解决的只是一件早就该解决的事:让选择器能表达”边界”。
这件事看起来小,但边界缺失导致的问题一直在悄悄消耗前端团队的时间。BEM 的命名规范、构建工具的重写、:deep() 的穿透语法、那些因为结构变化而失效的 > 选择器——它们本质上都是在用别的机制去模拟一个本该由语言提供的能力。
如果你的项目里有一两个组件,每次改结构都要重新检查样式有没有外泄,那从它开始试 @scope 是个不错的切入点。改动量很小:把一段规则的类名前缀删掉,外面套一层 @scope,复杂的地方加上 to 边界,然后挨个页面确认一遍效果。
确认无误之后,你会发现那层看得见又摸不着的边界,终于被写进代码里了。

