使用 CSSNestedDeclarations 改进了 CSS 嵌套

发布时间:2024 年 10 月 8 日

为了修复 CSS 嵌套的一些奇怪问题,CSS 工作组决定向 CSS 嵌套规范中添加 CSSNestedDeclarations 接口。除了这项改进之外,样式规则后面的声明也不再向上移动,此外还进行了一些其他改进。

这些更改从 Chrome 130 版开始生效,并且已可在 Firefox Nightly 132 和 Safari Technology Preview 204 中进行测试。

浏览器支持

  • Chrome:130.
  • Edge:130。
  • Firefox:132。
  • Safari:不受支持。

CSS 嵌套的注意事项之一是,最初,以下代码段的运作方式与您最初预期的可能不一致:

.foo {
    width: fit-content;

    @media screen {
        background-color: red;
    }
    
    background-color: green;
}

查看代码后,您可能会认为 <div class=foo> 元素有一个 green background-color,因为 background-color: green; 声明位于最后。但在 130 版之前的 Chrome 中,情况并非如此。在这些版本中,由于不支持 CSSNestedDeclarations,因此该元素的 background-colorred

解析 Chrome 130 之前使用的实际规则后,结果如下所示:

.foo {
    width: fit-content;
    background-color: green;

    @media screen {
        & {
            background-color: red;
        }
    }
}

解析后的 CSS 发生了两项更改:

  • background-color: green; 已向上移,以加入其他两个声明。
  • 嵌套的 CSSMediaRule 已重写,以使用 & 选择器将其声明封装在额外的 CSSStyleRule 中。

您在这里会看到的另一项典型更改是,解析器会舍弃不支持的属性。

您可以通过从 CSSStyleRule 中读回 cssText,自行检查“解析后的 CSS”。

您可以在此互动式 Playground 中亲自试用:

为什么要重写此 CSS?

如需了解发生这种内部重写的原因,您需要了解此 CSSStyleRule 在 CSS 对象模型 (CSSOM) 中的表示方式。

在 130 之前的 Chrome 版本中,之前分享的 CSS 代码段会序列化为以下内容:

↳ CSSStyleRule
  .type = STYLE_RULE
  .selectorText = ".foo"
  .resolvedSelectorText = ".foo"
  .specificity = "(0,1,0)"
  .style (CSSStyleDeclaration, 2) =
    - width: fit-content
    - background-color: green
  .cssRules (CSSRuleList, 1) =
    ↳ CSSMediaRule
    .type = MEDIA_RULE
    .cssRules (CSSRuleList, 1) =
      ↳ CSSStyleRule
        .type = STYLE_RULE
        .selectorText = "&"
        .resolvedSelectorText = ":is(.foo)"
        .specificity = "(0,1,0)"
        .style (CSSStyleDeclaration, 1) =
          - background-color: red

在本例中,CSSStyleRule 的所有属性中,以下两个属性相关:

  • style 属性,它是一个表示声明的 CSSStyleDeclaration 实例。
  • cssRules 属性,它是一个 CSSRuleList,用于存储所有嵌套的 CSSRule 对象。

由于 CSS 代码段中的所有声明最终都会位于 CSStyleRulestyle 属性中,因此会丢失信息。查看 style 属性时,不太清楚 background-color: green 是在嵌套的 CSSMediaRule 之后声明的。

↳ CSSStyleRule
  .type = STYLE_RULE
  .selectorText = ".foo"
  .style (CSSStyleDeclaration, 2) =
    - width: fit-content
    - background-color: green
  .cssRules (CSSRuleList, 1) =
    ↳ …

这会带来问题,因为 CSS 引擎必须能够区分出现在样式规则内容开头的属性与与其他规则交错出现的属性,才能正常运行。

至于 CSSMediaRule 中的声明突然被封装在 CSSStyleRule 中:这是因为 CSSMediaRule 并非旨在包含声明。

由于 CSSMediaRule 可以包含嵌套规则(可通过其 cssRules 属性访问),因此声明会自动封装在 CSSStyleRule 中。

↳ CSSMediaRule
  .type = MEDIA_RULE
  .cssRules (CSSRuleList, 1) =
    ↳ CSSStyleRule
      .type = STYLE_RULE
      .selectorText = "&"
      .resolvedSelectorText = ":is(.foo)"
      .specificity = "(0,1,0)"
      .style (CSSStyleDeclaration, 1) =
        - background-color: red

如何解决此问题?

CSS 工作组研究了多种解决此问题的方法。

建议的解决方案之一是使用嵌套选择器 (&) 将所有裸声明封装在嵌套的 CSSStyleRule 中。这种想法因各种原因而被舍弃,包括 & 脱糖给 :is(…) 带来的以下不必要的副作用:

  • 这会影响特异性。这是因为 :is() 会继承其最具体的参数的具体性。
  • 它不适用于原始外部选择器中的伪元素。这是因为 :is() 不接受其选择器列表参数中的伪元素。

请看以下示例:

#foo, .foo, .foo::before {
  width: fit-content;
  background-color: red;

  @media screen {
    background-color: green;
  }
}

解析该代码段后,该代码段在 Chrome 130 之前变为:

#foo,
.foo,
.foo::before {
  width: fit-content;
  background-color: red;

  @media screen {
    & {
      background-color: green;
    }
  }
}

这会带来问题,因为使用 & 选择器的嵌套 CSSRule

  • 扁平化为 :is(#foo, .foo),并在此过程中丢弃选择器列表中的 .foo::before
  • 具有 (1,0,0) 的特异性,这使得以后难以覆盖。

您可以通过检查规则序列化为的内容来检查这一点:

↳ CSSStyleRule
  .type = STYLE_RULE
  .selectorText = "#foo, .foo, .foo::before"
  .resolvedSelectorText = "#foo, .foo, .foo::before"
  .specificity = (1,0,0),(0,1,0),(0,1,1)
  .style (CSSStyleDeclaration, 2) =
    - width: fit-content
    - background-color: red
  .cssRules (CSSRuleList, 1) =
    ↳ CSSMediaRule
      .type = MEDIA_RULE
      .cssRules (CSSRuleList, 1) =
        ↳ CSSStyleRule
          .type = STYLE_RULE
          .selectorText = "&"
          .resolvedSelectorText = ":is(#foo, .foo, .foo::before)"
          .specificity = (1,0,0)
          .style (CSSStyleDeclaration, 1) =
            - background-color: green

从视觉上看,这也意味着 .foo::beforebackground-colorred,而不是 green

CSS 工作组考虑的另一种方法是,让您将所有嵌套声明封装在 @nest 规则中。由于这会导致开发者体验下降,因此我们已拒绝此请求。

推出 CSSNestedDeclarations 接口

CSS 工作组最终确定的解决方案是引入嵌套声明规则

从 Chrome 130 开始,Chrome 中会实现此嵌套声明规则。

浏览器支持

  • Chrome:130。
  • Edge:130。
  • Firefox:132。
  • Safari:不受支持。

引入嵌套声明规则后,CSS 解析器会更改为自动将连续的直接嵌套声明封装在 CSSNestedDeclarations 实例中。序列化后,此 CSSNestedDeclarations 实例最终会位于 CSSStyleRulecssRules 属性中。

再次以以下 CSSStyleRule 为例:

.foo {
  width: fit-content;

  @media screen {
    background-color: red;
  }
    
  background-color: green;
}

在 Chrome 130 或更高版本中序列化时,它看起来如下所示:

↳ CSSStyleRule
  .type = STYLE_RULE
  .selectorText = ".foo"
  .resolvedSelectorText = ".foo"
  .specificity = (0,1,0)
  .style (CSSStyleDeclaration, 1) =
    - width: fit-content
  .cssRules (CSSRuleList, 2) =
    ↳ CSSMediaRule
      .type = MEDIA_RULE
      .cssRules (CSSRuleList, 1) =
        ↳ CSSNestedDeclarations
          .style (CSSStyleDeclaration, 1) =
            - background-color: red
    ↳ CSSNestedDeclarations
      .style (CSSStyleDeclaration, 1) =
        - background-color: green

由于 CSSNestedDeclarations 规则最终出现在 CSSRuleList 中,因此解析器能够保留 background-color: green 声明的位置:在 background-color: red 声明(属于 CSSMediaRule 的一部分)之后。

此外,使用 CSSNestedDeclarations 实例不会引入其他(现已废弃)潜在解决方案带来的任何不良副作用:嵌套声明规则与其父级样式规则匹配完全相同的元素和伪元素,具有相同的特异性行为。

您可以通过读回 CSSStyleRulecssText 来证明这一点。由于嵌套声明规则的存在,它与输入 CSS 相同:

.foo {
  width: fit-content;

  @media screen {
    background-color: red;
  }
    
  background-color: green;
}

对您的影响

这意味着,从 Chrome 130 开始,CSS 嵌套功能有了很大改进。但这也意味着,如果您将裸声明与嵌套规则交错使用,则可能需要检查一些代码。

请看以下使用出色的 @starting-style 的示例

/* This does not work in Chrome 130 */
#mypopover:popover-open {
  @starting-style {
    opacity: 0;
    scale: 0.5;
  }

  opacity: 1;
  scale: 1;
}

在 Chrome 130 之前,这些声明会被提升。最终,opacity: 1;scale: 1; 声明会进入 CSSStyleRule.style,后跟 CSSStyleRule.cssRules 中的 CSSStartingStyleRule(表示 @starting-style 规则)。

从 Chrome 130 开始,系统不会再提取声明,最终在 CSSStyleRule.cssRules 中会出现两个嵌套的 CSSRule 对象。依次为:一个 CSSStartingStyleRule(表示 @starting-style 规则)和一个包含 opacity: 1; scale: 1; 声明的 CSSNestedDeclarations

由于这一行为发生了变化,@starting-style 声明会被 CSSNestedDeclarations 实例中包含的声明覆盖,从而移除进入动画。

如需修复代码,请确保 @starting-style 块位于常规声明的后面。如下所示:

/* This works in Chrome 130 */
#mypopover:popover-open {
  opacity: 1;
  scale: 1;

  @starting-style {
    opacity: 0;
    scale: 0.5;
  }
}

如果您在使用 CSS 嵌套时将嵌套声明放在嵌套规则之上,那么您的代码在支持 CSS 嵌套的所有浏览器版本上基本都能够正常运行。

最后,如果您想对可用的 CSSNestedDeclarations 进行特征检测,可以使用以下 JavaScript 代码段:

if (!("CSSNestedDeclarations" in self && "style" in CSSNestedDeclarations.prototype)) {
  // CSSNestedDeclarations is not available
}