12k
All articles

使用 @scope 实现原生 CSS 作用域

原生 CSS @scope 将选择器限制在组件或 donut 边界内,并通过接近度级联规则工作,无需构建步骤。

OpenReplay Team
OpenReplay Team
使用 @scope 实现原生 CSS 作用域

@scope 是一条 CSS at-rule,用于限制一组选择器的匹配范围——从某个根元素开始,向下延伸到一个可选的下边界——无需构建步骤,也不会带来额外的特异性。MDN 将其标记为 Baseline “newly available”(新近可用),日期为 2026 年 3 月。

如果你在维护一套组件代码库,那么你其实已经在某个地方为样式隔离付出了代价:可能是一套需要在 code review 中强制执行的命名约定,可能是一个对类名做哈希处理的打包器插件,也可能是一个在运行时注入 style 标签的运行时库。这些方案之所以存在,是因为普通的后代选择器作用范围太广,而链式的子选择器又会把 CSS 死死绑定在某一种特定的 DOM 结构上。

核心要点

  • @scope 块内部,一个裸选择器只保留自身的特异性,因为隐含的前缀是 :where(:scope),而 :where() 的权重为零;如果显式写出 :scope,则会增加 0-1-0 的特异性。
  • @scope (.card) to (.card__content) 匹配位于 card 与其内容插槽之间的元素:根元素被包含在内,而 limit 元素及其下的所有内容都被排除。
  • 当两条带作用域的声明在特异性上打平时,作用域根元素与目标元素之间 DOM 层级跳数更少的那一条胜出,与源码顺序无关。
  • 作用域邻近性(scoping proximity)的比较发生在重要性、级联层和特异性之后、源码顺序之前,因此一个特异性更高的无作用域选择器仍然会覆盖带作用域的规则。
  • @scope 限制的是选择器的匹配范围;它并不能阻止 color 等可继承属性越过作用域下边界,流入被排除的区域。

选择器为什么会”泄漏”?BEM、CSS Modules 和 CSS-in-JS 是怎么解决的?

每个 CSS 选择器都是针对整个文档进行匹配的,所以当你想定位”这个卡片里的主图”时,只能在”过于依赖结构的选择器”和”范围过于宽泛的选择器”之间二选一。

.card > .card__body > img { }  /* 0-2-1, breaks when the markup moves */
img { }                        /* 0-0-1, matches every image on the page */
.card__img { }                 /* 0-1-0, BEM: a unique name per component */

BEM 通过命名纪律来解决作用范围问题:一个唯一的 block 名称,内部每个元素都带上 block__element 类,这样就永远不会出现一个裸的 .titleCSS Modules 把同样的思路自动化了——在构建阶段把每个类名重写为带哈希的、文件内局部唯一的标识符。CSS-in-JS 库则在运行时或编译时从你的组件代码生成这些标识符;关于这一生态的现状,我们另有专文讨论。CSS Cascade Level 6 草案详细说明了这些工具在底层是如何工作的:它们给组件中的每个元素打上一个标记属性或类,然后把该标记加到文件中的每一个选择器上。

方案构建步骤下边界(甜甜圈)参与级联的邻近性额外特异性
BEM仅靠命名约定每个名称一个类级权重
CSS Modules按文件哈希的类名每个名称一个类级权重
CSS-in-JS运行时或编译时每个组件生成的类每个名称一个类级权重
@scope原生 to (limit) 子句根元素不带来任何额外权重

基本的 CSS 作用域块:@scope (.card)

@scope (.card) { img { } } 只匹配作为 .card 的包含性后代(inclusive descendant)的 <img> 元素,且 .card 根元素对 img 的特异性没有任何贡献。

<article class="card">
  <img src="hero.jpg" alt="">   <!-- matched -->
</article>
<img src="logo.svg" alt="">     <!-- not matched -->
@scope (.card) {
  img { border-radius: 8px; }        /* specificity 0-0-1 */
  :scope { padding: 1rem; }          /* specificity 0-1-0, the .card itself */
}

MDN 对作用域内特异性的说明解释了其中的机制。块内的裸选择器在匹配时相当于前面加了 :where(:scope) 前缀,而由于 :where() 本身不带任何权重,根元素对总特异性毫无贡献。这与属性打标(attribute-stamping)类工具的做法恰好相反:生成的 [data-v-abc123] 钩子会给它触及的每个选择器都加上属性级权重。而如果显式写出 :scope,它就是一个普通的伪类,会带来 0-1-0 的权重,所以 :scope img 的特异性是 0-1-1。

什么是甜甜圈作用域(donut scope)?

甜甜圈作用域写作 @scope (.card) to (.card__content),它为从 card 开始、一直到(但不包括).card__content 及其子树的所有元素设置样式——这正是嵌套组件所带来的插槽内容(slotted content)难题。

<article class="card">
  <img src="hero.jpg" alt="">          <!-- in scope -->
  <div class="card__content">
    <img src="inline.jpg" alt="">      <!-- excluded: below the limit -->
  </div>
</article>
@scope (.card) to (.card__content) {
  img { border: 4px solid goldenrod; }
}

默认情况下,根元素本身算在作用域内,而 limit 元素不算。在任一选择器后追加 > * 都可以翻转这个边界。@scope (.card) to (.card__content > *) 会把 .card__content 元素本身纳入作用域,同时仍然排除它的子元素——当插槽包装器需要 padding、但插入的内容必须保持原样时,这一点非常有用。按照草案中的定义,一个元素若位于根元素处或其下方,且既不是 limit 元素本身、也不在任何 limit 元素之下,它就符合作用域条件。若不给每个元素加上边界类,或不使用会重新引入特异性的 :not() 链,没有任何选择器组合能表达”X 的后代,但不在 Y 内部”这个含义。

作用域邻近性优先于源码顺序

当两条带作用域的规则在特异性上打平时,作用域根元素离目标元素最近的那条声明胜出——这修复了普通后代选择器处理不好的嵌套主题 bug。

<div class="theme-light">
  <p>Light</p>
  <div class="theme-dark">
    <p>Dark</p>
    <div class="theme-light">
      <p>Light again?</p>
    </div>
  </div>
</div>

如果使用普通选择器,最内层的段落同时匹配 .theme-light p.theme-dark p,两者特异性都是 0-1-1,因此样式表中靠后的那条规则胜出——尽管该段落位于浅色容器内部,最终却渲染成深色主题的颜色。

@scope (.theme-light) {
  p { color: #1b1b1b; }
}
@scope (.theme-dark) {
  p { color: #f2f2f2; }   /* declared later, but loses on the inner p */
}

最内层的 <p> 距离它的 .theme-light 根元素只有一跳,距离 .theme-dark 则有两跳,所以浅色规则生效。MDN 也给出了同一个示例的完整推演;按照规范的邻近性规则,没有作用域根的规则永远赢不了这场较量,因为它的跳数被视为无穷大。

邻近性在级联中处于什么位置?

作用域邻近性的比较发生在重要性、级联层和特异性之后、源码顺序之前,因此一个特异性更高的无作用域选择器仍然会覆盖带作用域的规则,无论作用域根离得多近。

Cascade 6 的排序规则按优先级从高到低列出了七条标准:

  1. 来源与重要性(Origin and importance)
  2. 上下文(shadow tree 封装)
  3. style 属性
  4. 级联层(Cascade layers)
  5. 特异性(Specificity)
  6. 作用域邻近性(Scope proximity)
  7. 出现顺序(Order of appearance)

邻近性只是特异性的平局决胜手段,而不是它的替代品:

@scope (aside) {
  p { color: green; }      /* 0-0-1, scoped */
}
aside#sidebar p { color: red; }   /* 1-0-2, unscoped, wins */
<aside id="sidebar"><p>This is red.</p></aside>

把邻近性排在特异性之下是一个深思熟虑的决定,草案的变更日志记录了那个会让邻近性压过特异性的更强版本被移除的过程。相关理由可见于该特性的设计说明文档:如果邻近性优先,特异性就只能在邻近性相同的选择器之间决出胜负,于是那些本来按书写顺序确定覆盖关系的规则,就会变成靠 DOM 结构的形状来决定输赢。如果你期待带作用域的样式能像 Shadow DOM 封装那样工作,那么这里正是这种期待落空的地方。

选择器有作用域,继承没有

@scope 限制的是选择器能在哪里匹配;它不会阻止可继承属性越过作用域下边界,因此在作用域根上设置的 color 仍然会到达被排除区域内的每一个元素。

<article class="card">
  <p>Card text</p>
  <div class="card__content">
    <p>Slotted text: also hotpink, with no border</p>
  </div>
</article>
@scope (.card) to (.card__content) {
  :scope { color: hotpink; }             /* inherited: crosses the limit */
  p { border: 1px solid currentColor; }  /* not inherited, and p in the slot is out of scope */
}

插槽中的段落位于作用域之外,因此没有任何带作用域的选择器能匹配到它,它不会得到边框。但它依然渲染为 hotpink,因为继承是一种属性级机制,它在级联之后运行,对作用域一无所知。@scope 参考文档也表达了同样的观点:作用域圈定的是选择器能触及哪些元素,而不是最终样式会流向何处。任何你希望在插槽边界处被截断的效果,都需要在插槽自身上显式重置——这和以前的做法并无不同。

哪些浏览器支持 @scope?什么时候该用它?

MDN 将 @scope 标记为截至 2026 年 3 月的 Baseline “newly available”,这意味着当前所有主流引擎都已支持它。兼容性数据显示,Chrome 和 Edge 118、Firefox 146 首次提供支持;Safari 17.4 已经支持,Safari 26.0 至 26.3 被标记为部分支持,Safari 26.4 恢复了完整支持。不支持它的浏览器会丢弃整条 at-rule——这正是 CSS 对无法识别的语法结构的要求——因此带作用域的块会退化为”什么都不做”,而不是变成一条出错的规则。

当一个组件掌管着 DOM 树中的某个区域,却不应该给插入其中的内容设置样式时,或者当同一个组件以不同变体自我嵌套时,就该用 @scope。而对于全局重置、排版和品牌 token,就别用它了:这些样式本就应该一路传递下去,而级联机制的设计初衷正是让它们能够如此。如果你需要的是让整份样式表之间相互排序,而不是圈住某个子树,那么级联层仍然是正确的工具。

@scope 把样式隔离从构建流水线搬进了浏览器:甜甜圈边界和邻近性解析如今是级联层面的特性,而不再是约定或生成的哈希。接下来可以做的实事是:挑一个目前依赖 __element 后缀或哈希类名来保护内部插槽不被污染的组件,把它改写成一个 @scope (.component) to (.slot) 块,然后检查那些你原本期望止步于插槽边界的颜色或字体,是否在插槽处被显式重置了。

常见问题

能用 @supports 对 @scope 做特性检测吗?不支持它的浏览器会怎样?

不支持 @scope 的浏览器会丢弃整个 at-rule 块,因此带作用域的规则不会生效,其他部分也不会出问题。CSS Conditional Rules Level 5 定义了 @supports at-rule(@scope),但 at-rule() 比 @scope 更新(Chromium 148 率先支持),所以凡是老到不支持 @scope 的浏览器,同样也不支持这个检测函数。正确做法是在块外编写特异性相等或更低的普通回退规则;支持 @scope 的浏览器会用带作用域的规则覆盖它们。

可以不写根选择器就使用 @scope 吗?

可以。在 HTML 的 style 元素内部,你可以写一个前导部分不带根选择器的 @scope,浏览器会把其中的规则限定到该 style 元素的父元素上。这种内联形式同样接受 limit,写作 @scope to (.card__content)。它适合那些自带样式的服务端渲染片段。而在普通样式表中,请使用带 (root) 前导的形式,让作用域根保持显式。

可以用 JavaScript 读取 @scope 规则吗?

可以,通过 CSSScopeRule 接口,它继承自 CSSGroupingRule。该接口暴露两个只读字符串属性:start 返回序列化后的作用域根选择器,end 返回作用域 limit 选择器;当前导部分中对应部分被省略时,二者返回 null。你可以通过 document.styleSheets 及其 cssRules 列表访问到某条规则,并借助从 CSSGroupingRule 继承的 cssRules 属性访问其中带作用域的样式规则。

在样式封装方面,@scope 与 Shadow DOM 有什么区别?

Shadow DOM 会创建一棵独立的 DOM 树,并带有一条硬性的样式边界:外部选择器除非通过 ::part,否则无法匹配 shadow root 内部,内部规则也无法向外触及。@scope 则完全不改变标记结构;它只限制某个块内的选择器能在哪里匹配,因此其他样式表依然可以命中作用域内的每一个元素。可继承属性会穿越这两种边界。此外,@scope 不需要 JavaScript,也不需要 shadow root。

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.