CSSNestedDeclarations で CSS のネストを改善

公開日: 2024 年 10 月 8 日

CSS のネストの奇妙な点を修正するため、CSS ワーキング グループは CSSNestedDeclarations インターフェースを CSS ネスト仕様に追加することを決断しました。この追加により、スタイルルールの後に続く宣言が上に移動しなくなるなど、いくつかの改善が行われています。

これらの変更は 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;
}

コードを見ると、background-color: green; 宣言が最後に来るため、<div class=foo> 要素に green background-color があると想定されます。しかし、バージョン 130 より前の Chrome ではそうなりません。CSSNestedDeclarations をサポートしていないこれらのバージョンでは、要素の background-colorred です。

実際のルールを解析した結果、Chrome が 130 回使用する前は次のようになります。

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

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

解析後の CSS には 2 つの変更が加えられました。

  • background-color: green; がシフトされて、他の 2 つの宣言を結合します。
  • ネストされた 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 のすべてのプロパティのうち、このケースに関連するのは次の 2 つです。

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-colorgreen ではなく red であることを意味します。

CSS ワーキング グループが検討したもう 1 つのアプローチは、ネストされた宣言をすべて @nest ルールでラップすることです。デベロッパー エクスペリエンスの低下が原因で、この機能は却下されました。

CSSNestedDeclarations インターフェースの概要

CSS Working Group が決定した解決策は、ネストされた宣言ルールの導入です。

このネストされた宣言ルールは、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.cssRulesCSSStartingStyleRule@starting-style ルールを表す)が配置されます。

Chrome 130 以降では、宣言はホイスティングされなくなり、CSSStyleRule.cssRules に 2 つのネストされた CSSRule オブジェクトが作成されます。順番に、1 つの CSSStartingStyleRule@starting-style ルールを表す)と、opacity: 1; scale: 1; 宣言を含む 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
}