使用 CSS @scope at-rule 限制选择器的覆盖面

了解如何使用 @scope 仅选择 DOM 的有限子树中的元素。

发布时间:2023 年 10 月 4 日

Browser Support

  • Chrome: 118.
  • Edge: 118.
  • Firefox: 146.
  • Safari: 17.4.

Source

在编写选择器时,您可能会发现自己陷入两难境地。一方面,您需要非常明确地选择哪些元素。另一方面,您希望选择器易于替换,并且不会与 DOM 结构紧密耦合。

例如,当您想要选择“卡片组件内容区域中的主推图片”(这是一个相当具体的元素选择)时,您很可能不想编写 .card > .content > img.hero 这样的选择器。

  • 此选择器的特异性(0,3,1),随着代码的增长,很难覆盖它。
  • 由于依赖于直接子级选择器,因此它与 DOM 结构紧密耦合。如果标记发生更改,您也需要更改 CSS。

不过,您也不想只将 img 作为该元素的选择器,因为这样会选择整个网页中的所有图片元素。

如何在这方面找到合适的平衡点往往是一项相当大的挑战。多年来,一些开发者提出了解决方案和临时解决方法,可帮助您应对此类情况。例如:

  • 根据 BEM 等方法,您应为该元素指定 card__img card__img--hero 类,以保持较低的特异性,同时允许您具体指定所选内容。
  • 基于 JavaScript 的解决方案(例如 Scoped CSSStyled Components)会通过向选择器添加随机生成的字符串(例如 sc-596d7e0e-4)来重写所有选择器,以防止它们定位到网页另一侧的元素。
  • 有些库甚至完全废除了选择器,要求您直接在标记中放置样式触发器。

但如果您不需要上述任何功能,该怎么办?如果 CSS 提供了一种方法,让您能够非常具体地选择元素,而无需编写高特异性或与 DOM 紧密耦合的选择器,会怎么样?这时,@scope 就派上用场了,它提供了一种仅在 DOM 子树中选择元素的方法。

@scope 简介

借助 @scope,您可以限制选择器的覆盖范围。为此,您可以设置范围界定根,以确定要定位的子树的上限。设置了范围界定根后,所包含的样式规则(称为范围界定样式规则)只能从 DOM 的有限子树中进行选择。

例如,如需仅以 .card 组件中的 <img> 元素为目标,请将 .card 设置为 @scope at 规则的范围根。

@scope (.card) {
    img {
        border-color: green;
    }
}

范围限定样式规则 img { … } 实际上只能选择与匹配的 .card 元素在同一范围内<img> 元素。

为了防止选择卡片内容区域 (.card__content) 内的 <img> 元素,您可以使 img 选择器更具体。另一种方法是利用 @scope at 规则也接受用于确定下限的范围限制这一事实。

@scope (.card) to (.card__content) {
    img {
        border-color: green;
    }
}

此范围限定的样式规则仅以祖先树中放置在 .card.card__content 元素之间的 <img> 元素为目标。这种具有上限和下限的范围界定通常称为“甜甜圈范围”

:scope 选择器

默认情况下,所有作用域样式规则都相对于作用域根。还可以定位到范围界定根元素本身。为此,请使用 :scope 选择器。

@scope (.card) {
    :scope {
        /* Selects the matched .card itself */
    }
    img {
       /* Selects img elements that are a child of .card */
    }
}

范围限定的样式规则中的选择器会隐式添加 :scope 前缀。如果您愿意,也可以通过自行添加 :scope 前缀来明确指定。或者,您也可以使用 CSS 嵌套中的 & 选择器。

@scope (.card) {
    img {
       /* Selects img elements that are a child of .card */
    }
    :scope img {
        /* Also selects img elements that are a child of .card */
    }
    & img {
        /* Also selects img elements that are a child of .card */
    }
}

范围界定限制可以使用 :scope 伪类来要求与范围界定根的特定关系:

/* .content is only a limit when it is a direct child of the :scope */
@scope (.media-object) to (:scope > .content) { ... }

范围限制还可以使用 :scope 引用范围根元素之外的元素。例如:

/* .content is only a limit when the :scope is inside .sidebar */
@scope (.media-object) to (.sidebar :scope .content) { ... }

范围限定的样式规则本身无法逃脱子树。:scope + p 等选择器无效,因为它们尝试选择不在范围内的元素。

@scope 和特异性

您在 @scope 的序曲中使用的选择器不会影响所含选择器的特异性。在我们的示例中,img 选择器的特异性仍然是 (0,0,1)

@scope (#sidebar) {
  img { /* Specificity = (0,0,1) */
    ...
  }
}

:scope 的特异性与常规伪类(即 (0,1,0))的特异性相同。

@scope (#sidebar) {
  :scope img { /* Specificity = (0,1,0) + (0,0,1) = (0,1,1) */
    ...
  }
}

在以下示例中,内部会将 & 重写为用于限定范围根的选择器,并将其封装在 :is() 选择器中。最后,浏览器将使用 :is(#sidebar, .card) img 作为选择器进行匹配。此过程称为“去糖化”。

@scope (#sidebar, .card) {
    & img { /* desugars to `:is(#sidebar, .card) img` */
        ...
    }
}

由于 & 使用 :is() 进行反糖化,因此 & 的特异性是根据 :is() 特异性规则计算的:& 的特异性是其最具体实参的特异性。

在此示例中,:is(#sidebar, .card) 的具体程度取决于其最具体的实参(即 #sidebar),因此变为 (1,0,0)。将此值与 img 的特异性(即 (0,0,1))相结合,最终得到 (1,0,1) 作为整个复杂选择器的特异性。

@scope (#sidebar, .card) {
  & img { /* Specificity = (1,0,0) + (0,0,1) = (1,0,1) */
    ...
  }
}

@scope 内的 :scope& 之间的差异

除了特异性计算方式不同之外,:scope& 的另一个区别在于,:scope 表示匹配的范围根,而 & 表示用于匹配范围根的选择器。

因此,您可以多次使用 &。这与 :scope 不同,后者只能使用一次,因为您无法在范围界定根内匹配范围界定根。

@scope (.card) {
  & & { /* Selects a `.card` in the matched root .card */
  }
  :scope :scope { /* ❌ Does not work */
    
  }
}

无序范围

使用 <style> 元素编写内嵌样式时,如果不指定任何范围界定根,则可以将样式规则限定为 <style> 元素的封闭父元素。为此,您可以省略 @scope 的序曲。

<div class="card">
  <div class="card__header">
    <style>
      @scope {
        img {
          border-color: green;
        }
      }
    </style>
    <h1>Card Title</h1>
    <img src="…" height="32" class="hero">
  </div>
  <div class="card__content">
    <p><img src="…" height="32"></p>
  </div>
</div>

在上面的示例中,作用域限定的规则仅以类名称为 card__headerdiv 内的元素为目标,因为该 div<style> 元素的父元素。

级联中的 @scope

CSS 层叠中,@scope 还添加了一个新条件:范围邻近度。此步骤位于特异性之后,但位于出现顺序之前。

CSS 层叠的可视化效果。

根据规范

在比较出现在具有不同范围根的样式规则中的声明时,范围根与具有范围的样式规则主题之间的代际或同级元素跳数最少的声明胜出。

当您需要嵌套某个组件的多个变体时,此新步骤会非常有用。以以下示例为例,该示例尚未使用 @scope

<style>
    .light { background: #ccc; }
    .dark  { background: #333; }
    .light a { color: black; }
    .dark a { color: white; }
</style>
<div class="light">
    <p><a href="#">What color am I?</a></p>
    <div class="dark">
        <p><a href="#">What about me?</a></p>
        <div class="light">
            <p><a href="#">Am I the same as the first?</a></p>
        </div>
    </div>
</div>

查看该少量标记时,第三个链接将为 white 而不是 black,即使它是应用了 .light 类的 div 的子级也是如此。这是因为级联在此处使用了“显示顺序”这一标准来确定胜出者。系统会看到 .dark a 是最后声明的,因此它会从 .light a 规则中胜出

借助范围界定邻近位置条件,此问题现已解决:

@scope (.light) {
    :scope { background: #ccc; }
    a { color: black;}
}

@scope (.dark) {
    :scope { background: #333; }
    a { color: white; }
}

由于这两个范围限定的 a 选择器的特异性相同,因此范围限定邻近度条件开始发挥作用。它会根据两个选择器与各自范围根的邻近程度来衡量它们的权重。对于第三个 a 元素,它到 .light 范围根的距离只有 1 跳,但到 .dark 范围根的距离为 2 跳。因此,.light 中的 a 选择器将胜出。

选择器隔离,而非样式隔离

请注意,@scope 会限制选择器的覆盖范围。它不提供样式隔离。会向下继承到子级的属性仍会继承,但会超出 @scope 的下限。其中一个此类属性是 color 属性。在甜甜圈作用域内声明时,color 仍会向下继承到甜甜圈孔内的子级。

@scope (.card) to (.card__content) {
  :scope {
    color: hotpink;
  }
}

在此示例中,.card__content 元素及其子元素的颜色为 hotpink,因为它们从 .card 继承了该值。