使用 CSSNestedDeclarations 改善 CSS 巢狀結構

發布日期:2024 年 10 月 8 日

為修正 CSS 巢狀結構的某些奇怪異常現象,CSS 工作小組決定將 CSSNestedDeclarations 介面加入 CSS 巢狀結構規格。有了這項新增功能,樣式規則後方的宣告就不會再向上移動,其他部分也已改善。

這些變更適用於 Chrome 130 版,且可在 Firefox 版本 132 和 Safari 技術預覽 204 中進行測試。

瀏覽器支援

  • Chrome:130。
  • Edge:130。
  • Firefox:132。
  • Safari:不支援。

沒有 CSSNestedDeclarations 的 CSS 巢狀結構問題

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」。

您可以前往這個互動式遊樂場親自試用:

為什麼這個 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 Working Group 研究了數種方法來解決這個問題。

我們建議的解決方案之一是使用巢狀選取器 (&) 將所有裸名宣告納入巢狀 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

基於這個變更的行為,CSSNestedDeclarations 執行個體中包含的宣告會覆寫 @starting-style 宣告,因此會移除項目動畫。

如要修正程式碼,請確認 @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
}