上个月做一个商品详情页的改版。详情页里嵌了一个”猜你喜欢”模块,模块本身是另一个团队维护的组件,通过打包好的 JS + CSS 引入。上线前一晚,测试在群里发了两张截图:同一张商品卡片,在商品列表里长得正常,在”猜你喜欢”里标题字号大了 4px,圆角从 8px 变成了 12px。
排查了两小时。原因是”猜你喜欢”模块的样式里有一个 .card 类选择器,而商品列表也用 .card 作为根节点,后面的样式覆盖了前面的。那位同事辩解:”我们的组件里不是用更具体的类名了吗?” 我看了一眼,两个团队都用了 .card 这个”一看就懂”的名字。就是这一撞。
最后是靠给”猜你喜欢”外面套一层 .section-like,然后把所有选择器都改成 .section-like .card 前缀解决掉的。但我知道这只是把问题推后了——下一个撞名的模块还会重演一遍。
问题不在命名习惯,在 CSS 本身没有作用域的概念。这次我们不聊 BEM 怎么写得更啰嗦,聊一个我用了三个月、决定推广的方案:@scope 原生作用域规则。
先看清已有方案的边界
在讲 @scope 之前,值得把现有的四种主流隔离方案摆出来对比一下,因为它们各自的取舍决定了 @scope 的定位。
BEM 命名约定
/* BEM */
.card__title { font-size: 16px; }
.hero-card__title { font-size: 20px; }
好处是不引入工具链,坏处是把隔离的责任全部压在了人头上。一个人忘了遵守约定、一个组件引入了外部的类名,防护就失效。而且名字会越来越长,长到最后写代码变成了打字比赛。
Vue scoped / Svelte scoped
框架在编译时给每个选择器加上一个 data-v-xxx 属性,隔离效果非常好。但它有两个限制:一是只能用在写了 scoped 的组件里,引入到全局的组件方法(比如通过 innerHTML 插入的内容)拿不到这个属性;二是当你需要样式”穿透”子组件时,得用 :deep(),反而多了一层耦合。
CSS Modules
// button.module.css
.primary { ... }
// 使用
import styles from './button.module.css'
<button className={styles.primary}>...</button>
类名被哈希成 .primary_7x3kD,撞名不可能。但代价是每次都要 import,写内联样式时拿不到,跟第三方库混用时还要处理全局类名。
Shadow DOM
const shadow = host.attachShadow({ mode: 'open' })
shadow.innerHTML = '<style>.card { ... }</style><div class="card">...</div>'
隔离最彻底,连继承属性都挡得住。但代价是——静态样式、全局字体、CSS 变量都得重新考虑;表单元素里的一些原生行为也会变;开发者工具的调试体验一言难尽。而且组件渲染进 Shadow DOM 之后,外部想调样式只能靠 CSS 自定义属性传递。
这四种方案都在解决同一个问题:让样式只作用于某个子树。它们各有所长,也各有代价。@scope 想做的是,在普通 DOM 里直接获得作用域能力,不需要编译,不需要加属性,也不需要影子节点。
@scope 三段式语法
先看最简形式:
@scope (.card) {
.title {
font-size: 16px;
}
.meta {
color: #666;
}
}
这段代码的意思很直观:只对出现在 .card 内部的 .title 和 .meta 生效。
它跟 .card .title { } 的写法看起来像,但有几个关键区别。
第一,作用域内外是真正隔开的。如果页面上别的地方也有 .card,外面那个 .title 不会受影响。用后代选择器的话,两个 .card 都会被命中。这是最本质的差别——后代选择器描述的是”祖先长什么样”,@scope 描述的是”元素落在哪个子树里”。
第二,样式不会溢出作用域边界。写在 @scope (.card) 里的规则,哪怕选择器写成了 body { },也不会命中 card 外面的 body。作用域是一个”上界”,任何选择器只能在这个范围内匹配。这是 @scope 最被低估的价值——你不需要担心写错选择器污染全局,因为规则天然被关在了盒子里。
第三,:scope 指向根元素自己。在 @scope (.card) 里写 :scope,等价于写 .card,但语义更清楚:这是”根”,不是”某个名叫 card 的东西”。
@scope (.card) {
:scope {
border-radius: 12px;
padding: 16px;
}
.title {
font-size: 16px;
}
}
上面代码的效果是:给所有 .card 加圆角和内边距,同时只给 card 内部的 title 设字号。:scope 让样式的意图立刻清晰了——”这一坨是针对根元素的”。
邻接边界:to 关键字
作用域还有一个更精细的写法,用 to 划定”内边界”:
@scope (.card) to (.card__slot) {
.title {
font-size: 16px;
}
}
意思是:只对从 .card 内部开始、到 .card__slot 为止的这段范围生效。一旦遇到 .card__slot 这个元素,往里就不再命中了。
这个特性解决了一个很实际的痛点。.card__slot 里通常放用户自定义内容,比如一个”猜你喜欢”的卡片插槽。用 to 划定边界之后,卡片自己的样式会止步于插槽外,插槽里的内容和卡片本身解耦。以前遇到这种场景只能写 :not(.card__slot) .title 之类的兜底,非常容易漏。
/* 邻接边界的实际效果 */
<div class="card">
<h3 class="title">卡片标题</h3> <!-- 命中 -->
<div class="card__slot">
<h3 class="title">插槽标题</h3> <!-- 不命中 -->
</div>
</div>
注意:to 只会阻止作用域继续向下蔓延,不会影响元素自身的样式。 .card__slot 元素本身如果匹配了作用域内的选择器,它还是会被命中。要想完全排除它自己,得用 :not(.card__slot) 兜一层。
案例一:重写商品卡片组件
把前面的问题用 @scope 重新做一遍。假设商品卡片的 HTML 结构是这样:
<article class="product-card">
<img class="cover" src="..." alt="">
<div class="body">
<h3 class="title">商品名</h3>
<p class="desc">商品描述</p>
<div class="price-bar">
<span class="price">¥199</span>
<button class="buy-btn">立即购买</button>
</div>
</div>
</article>
以前的写法要用一长串前缀:
/* 老写法,前缀蹭得脸都长了 */
.product-card { border-radius: 12px; overflow: hidden; }
.product-card .cover { width: 100%; display: block; }
.product-card .title { font-size: 16px; line-height: 1.4; }
.product-card .desc { color: #666; font-size: 13px; }
.product-card .price { color: #e8590c; font-weight: 600; }
用 @scope 之后:
@scope (.product-card) {
:scope {
border-radius: 12px;
overflow: hidden;
background: #fff;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
}
.cover {
width: 100%;
display: block;
aspect-ratio: 1 / 1;
object-fit: cover;
}
.title {
font-size: 16px;
line-height: 1.4;
/* 两行省略 */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.desc {
color: #666;
font-size: 13px;
}
.price-bar {
display: flex;
align-items: center;
justify-content: space-between;
}
.price {
color: #e8590c;
font-weight: 600;
}
.buy-btn {
padding: 6px 12px;
border-radius: 6px;
background: #e8590c;
color: #fff;
}
}
代码量没有减少,但有两个明显的好处。
一是改起来不会漏。以后新增一个 .tag 元素,只要写在作用域里,自然就只会影响卡片内部的标签,不用记得去加 .product-card 前缀。
二是可读性更好。所有和卡片相关的样式集中在一段 @scope 里,一眼能看出这是同一个组件的样式集合,不像散的规则那样要靠前缀来”猜”它们属于谁。
这里有一个细节要强调::scope 里的属性写全了,是为了让卡片本身有一个完整的外观定义。如果卡片本身的样式也混在页面级 CSS 里,作用域的意义就减半了。
作用域外的样式可以覆盖作用域内吗
能,但它们的比较方式有点讲究。
@scope (.product-card) {
.price { color: #e8590c; }
}
/* 页面级样式 */
.modal .product-card .price {
color: #d9480f;
}
这两条规则的优先级都是 0,2,0(两个类选择器),看谁排在后面谁生效。作用域本身不参与优先级计算,它只改变选择器的匹配范围,不改变它的权重。
这一点和很多人直觉相悖——会以为 @scope 里的规则优先级更高。其实没有,作用域是”谁能命中”的筛选,不是”谁更重要”的裁决。要让作用域里的规则稳稳压过外部,还是得靠 @layer,这个后面会说。
案例二:面包屑导航,:scope 直接当选择器用
面包屑是个很适合演示 :scope 的场景。它的结构通常是:
<nav class="breadcrumb">
<a href="/" rel="external nofollow" >首页</a>
<span class="sep">/</span>
<a href="/list" rel="external nofollow" >商品列表</a>
<span class="sep">/</span>
<span aria-current="page">详情</span>
</nav>
用 @scope 写:
@scope (.breadcrumb) {
:scope {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
padding: 12px 0;
}
/* 作用域内的所有链接 */
a {
color: #666;
text-decoration: none;
}
a:hover {
color: #e8590c;
}
/* 分隔符 */
.sep {
color: #cbd5e0;
user-select: none;
}
/* 当前页,不能用 a 选择 */
[aria-current="page"] {
color: #1f2933;
font-weight: 500;
}
}
这里最值得说的是最后一条。以前为了给”当前页”加样式,得写 .breadcrumb a:last-child 或者加一个 active 类。@scope 里可以直接用属性选择器 [aria-current="page"]——因为作用域已经把匹配范围收窄到了面包屑内部,不用再加前缀来区分。
再看 :scope 那一段。:scope 等价于 .breadcrumb,但语义上表达的”我是这个作用域的根元素”。这不是语法糖,而是告诉你这段规则是针对根元素的,不能移到子元素上。.breadcrumb { } 写在作用域里能工作,但读起来容易让人误以为它在选一个叫 breadcrumb 的后代。
用 to 处理嵌套的面包屑
有的设计里,面包屑下面会挂一个”当前位置的下拉菜单”。菜单里的 a 元素不该享受面包屑的链接样式:
@scope (.breadcrumb) to (.breadcrumb__dropdown) {
a {
color: #666;
text-decoration: none;
}
}
加了 to (.breadcrumb__dropdown) 之后,菜单内部的 a 就不再被面包屑样式污染。菜单自己的样式由它自己的 @scope 负责。这种”一个组件管好自己,不管别人”的边界感,是 @scope 相比 BEM 最大的进步。
案例三:嵌入第三方组件时如何”关上门”
回到开头那个”猜你喜欢”模块的场景。第三方组件通过打包引入,我们不能改它的源码,只能在应用侧做隔离。
以前的思路是加一层外壳,然后用前缀选择器。但第三方组件的内部结构我们未必完全清楚,加前缀时容易漏。
用 @scope 的思路就简单多了:把整个第三方模块包进一个作用域盒子,然后这个盒子里面的东西,用后置规则收敛样式。
/* 给第三方模块套一层作用域,把它所有失控的类名收进来 */
@scope (.external-module) {
:scope {
/* 模块自己的布局 */
margin-top: 24px;
}
/* 覆盖第三方组件里的 .card 样式,不会影响外面的应用 */
.card {
border-radius: 8px;
box-shadow: none;
border: 1px solid #e9ecef;
}
.card .title {
font-size: 15px;
/* 追平应用本身的标题风格 */
font-weight: 500;
color: #1f2933;
}
}
这段代码的效果是:所有 .card 的样式覆盖只作用在 .external-module 内部。应用自己的 .card 完全不受影响。
以前的实现方式是给第三方模块外面套一层 .external-module 然后改所有选择器为 .external-module .card,但问题在于——第三方组件的样式表加载顺序不确定,可能后加载,覆盖我们自己写的规则。这时候必须靠 !important 或者提高选择器特异性来压。
解决这个问题的正解是 @layer:
/* 先声明层级顺序 */
@layer reset, vendor, app, overrides;
/* 第三方样式放进 vendor 层 */
@import url('third-party.css') layer(vendor);
/* 应用自己的样式放 app 层 */
@layer app {
.card { border-radius: 12px; }
}
/* 覆盖意图明确的,单独放最上层 */
@layer overrides {
@scope (.external-module) {
.card {
border-radius: 8px;
}
}
}
@layer 决定谁赢,@scope 决定作用范围,两个解决的是不同维度的问题。把它们组合起来用,才是一套完整的样式架构:layer 管优先级,scope 管作用域。
上面这段代码的意思是:所有第三方样式和 reset 都在最底层,应用样式在中间层,覆盖意图明确的样式在最上层。@layer overrides 里的东西会自动压过 app 层,不需要写 !important。这就是 layer 的价值——把”我必须压过它”这件事从选择器层面转移到了架构层面。
@scope 不会隔离继承属性
这一点必须特别强调,因为它和我一开始的直觉完全相反。
@scope 只限制选择器的匹配范围,不改变 CSS 属性的继承规则。也就是说,作用域根元素上的可继承属性,还会照常向后代传递。
@scope (.card) {
:scope {
color: red;
font-size: 12px;
}
}
<div class="card">
<p>这段文字是红的,字号 12px</p>
</div>
color 和 font-size 都是可继承属性,p 元素没有自己的设置,就会从 card 继承下去。这和 Shadow DOM 不一样——Shadow DOM 会切断继承链,而 @scope 不会。
这个行为既是优点也是缺点。
优点是”主题化”变简单了。你想要”这个卡片里所有文字都变成品牌色”,直接在 :scope 上写 color 就行,不用给每个子元素单独设置。
缺点是——如果你本来想隔离的内容里有可继承属性,靠 @scope 挡不住。比如第三方组件在根元素上设了 font-family,它会往下”渗”进所有子元素。
这时候只能靠显式的重置:
@scope (.external-module) {
:scope {
/* 强制在模块根部重置继承 */
font-family: system-ui, sans-serif;
line-height: 1.5;
color: #1f2933;
}
}
比起 Shadow DOM 的”天然隔离”,@scope 更像”半开半掩的门”——样式规则关得住,属性继承拦不住。理解了这个定位,就知道该在什么场合用它、什么场合还需要 Shadow DOM。
跟 CSS Modules 和 Vue scoped 怎么选
这三个方案我都在用,选型逻辑其实是清晰的。
选 CSS Modules:项目已经用了构建工具,所有组件都从组件库引入。类名哈希之后完全没有撞名可能,工程的确定性最高。
选 Vue scoped:框架是 Vue,绝大部分样式都写在单文件组件里。它跟 BEM 的写法兼容,迁移成本最低。
选 @scope:以下几种情况特别适合。
- 样式不经过构建工具,直接写在 CSS 文件里被
<link>引入的页面 - 需要处理第三方组件的样式隔离,而它的类名无法改
- 需要精细控制”样式到哪一层为止”,也就是
to关键字能派上用场的场景 - 同一套 CSS 文件被多个应用复用的场景(比如设计系统的 CSS)
坦白说,这几个方案不是互斥的。我在最近这个项目里的方案是:Vue scoped 管组件内部的样式,全局 CSS 用 @layer 分好层级,第三方模块的隔离交给 @scope。每个工具解决它最适合的问题。
踩过的坑
1. 空选择器不报错,但什么都不干
@scope () {
.title { color: red; }
}
括号里没写东西(或者写了一个错的选择器),整个 @scope 块会被浏览器忽略,里面的规则一条都不生效。而且不会在控制台给出警告。写错的时候很难排查。
排查方式是打开开发者工具的”已应用样式”面板,看看有没有你写的那条规则。如果没有,八成是 scope 选择器写错了。
2. 作用域根元素自己也要单独写
新手常见的困惑:为什么写了 @scope (.card) { .card { ... } },卡片根元素的样式没生效?
@scope (.card) { .card { } } 里那个 .card 会被理解成”匹配 card 内部的 .card 元素”,而 card 自己不算在自己内部。所以根元素的样式必须用 :scope 来写。
正确写法:
@scope (.card) {
:scope { border-radius: 8px; } /* 根元素 */
.title { font-size: 16px; } /* 内部元素 */
}
3. 嵌套的作用域会叠加
@scope (.outer) {
@scope (.inner) {
.title { color: red; }
}
}
嵌套的 @scope 效果是:匹配所有同时处于 .outer 和 .inner 范围内的 .title。看起来像 .outer .inner .title,但实际匹配范围是”.inner 之内”(因为内层 scope 已经把范围缩小到了 inner 里)。
这种嵌套不常用,真要用的话一定写几个断言测试一下,确认匹配行为符合预期。
4. 和 :has() 一起用时要小心
@scope (.card) {
:scope:has(.badge) {
border: 2px solid gold;
}
}
这段代码的含义是:如果 card 内部有 .badge,就给 card 加边框。这没问题。但如果写成 :has(.badge) 而没有 :scope,它匹配的是”作用域内任何一个内部含有 .badge 的元素”,那是完全不一样的效果。
记住一条规则:在 @scope 里用 :has() 时,:scope 前缀几乎总是需要的。
降级方案
@scope 在各大浏览器里已经基本可用,但项目如果还需要支持较旧的环境,可以用 @supports 做优雅降级。
/* 默认:用后代选择器兜底(等于放弃了作用域隔离) */
.card .title { font-size: 16px; }
.card .price { color: #e8590c; }
/* 支持 @scope 时,换成真正的作用域写法 */
@supports (selector(:scope)) {
@scope (.card) {
.title { font-size: 16px; }
.price { color: #e8590c; }
}
}
这里用的是 @supports (selector(:scope)) 而不是 @supports (at-rule(@scope))。at-rule() 是更新的特性查询语法,兼容性不如前者。用 :scope 做检测也能大致判断环境是否支持作用域——浏览器既然认 :scope 这个伪类,多半也支持 @scope。
更稳妥的做法是两套样式都写一遍,用 @supports 切换。代价是维护成本翻倍,只建议在必须兼容旧环境的项目里这么做。新项目直接上 @scope,遇到问题再补降级也不迟。
写在最后
用了三个月 @scope 之后,我对它的评价是:它不会杀死 BEM,也不会取代 CSS Modules,但它填补了一块长期空缺——在普通的 CSS 文件里,用一种轻量的方式把样式关进盒子。
它最大的价值不在语法,而在心态上的转变。以前写样式,脑子里的默认状态是”这段规则可能会命中任何地方”,所以每个选择器都要想半天会不会撞名;现在可以默认”它只会命中这里”,思考的成本一下子就下来了。
如果项目里正在处理类似的问题——样式表散乱、第三方组件污染、改一处怕动全身——可以先挑一个独立模块试一下。不用一步到位改完所有样式,先给最有隔离需求的那一两个组件套上 @scope,看看感觉。跨出第一步之后,剩下的迁移会顺得多。

