🔭

CSSにそのうち導入されそうな@scopeとその関連概念

2022/05/25に公開

気がつけばCSSの@layerが全てのモダンブラウザに実装完了している今日この頃、みなさまはいかがお過ごしでしょうか。

CSSでは、@layerに次ぐ新機能として @scope が検討されています。最近これについて勉強したのですが、これを取り扱う日本語記事が見当たらなかったので今回ご紹介します。

この記事では、CSS Cascading and Inheritance Level 6のFirst Public Working Draftの内容を紹介します[1]。これは去年12月のバージョンで、より新しいEditor's Draftとして今年4月のものがありますが、特に大きな変更はありませんでしたので、この記事の内容が執筆時点の最新情報だと思って差し支えありません。

https://www.w3.org/TR/2021/WD-css-cascade-6-20211221/

@scopeとは: 基本的な構文

@scopeとは、次の例のような構文を持つ記法です。

@scope (main) {
  div {
    border: 1px dashed black;
  }
  p {
    margin-block: 1em;
  }
  strong {
    color: red;
  }
}

このように、スタイルルールを@scope (セレクタ) { ... }という構文で囲むのが基本的な@scopeの構文です。

@scope(セレクタ)部分にマッチした要素のことをscoping rootと言います。そして、@scopeのブロック内に書かれたセレクタは、scoping rootの子孫要素に対してのみマッチするようになります。

文書の木構造を描いた図。mainの配下に三角形状のスコープが広がり、mainの子孫であるdiv, p, strongにはマッチするが、mainの子孫でないpやstrongにはマッチしないことを示す。

上の例では、mainでスコープされた定義の中にdiv, p, strongがセレクタとして用いられています。よって、これらのセレクタはdivpstrongなら何でもマッチするわけではなく、mainの子孫であるdiv, p, strongにのみマッチします。

このように、中に書かれたスタイルルールの適用範囲を制限するのが@scopeの機能です。

上の例の場合、次のように書き換えることもできるでしょう。

/* @scope を使う例 */
@scope (main) {
  div {
    border: 1px dashed black;
  }
  p {
    margin-block: 1em;
  }
  strong {
    color: red;
  }
}

/* @scope を使わない例 */
main div {
  border: 1px dashed black;
}
main p {
  margin-block: 1em;
}
main strong {
  color: red;
}

両者はだいたい同じです。@scopeを使う場合と使わない場合には多少違いがありますが、それはこの記事で追々解説します。

ちなみに、scoping rootを指定するセレクタは何でも書くことができます。mainのような単純なものばかりでなく、次のような指定もできます。

@scope (table.price > tbody) {
  /* ... */
}

もしscoping rootに当てはまる要素が複数ある場合は、@scope内のセレクタはscoping rootのいずれかの子孫であればマッチします。

スコープの下限を設定する

@scopeの特徴的な機能は、スコープの下限を設定できることです。上の例は下限設定機能を使っていませんでした。そのため、@scopeを使わなくても似たようなことができます。下限設定機能を使うことで@scopeの本領が発揮されます。スコープの下限を設定するには、to構文を用います。

/* @scope を使う例 */
@scope (main) to (aside.ad) {
  div {
    border: 1px dashed black;
  }
  p {
    margin-block: 1em;
  }
  strong {
    color: red;
  }
}

この例を読み下せば、「mainからaside.adまで」となります。「まで」というのがどういう意味かというのは、やはり木構造の図で見ると分かりやすいでしょう。

木構造を用いた図。mainの子孫であるdiv, p, strongにはマッチするが、mainの子孫であってもaside.adの下にあるpやstrongはマッチしないことを示している。

意味としては、上記の@scopeが表すスコープの範囲は「mainの子孫かつ、その中のaside.adの子孫は除く」となります。これは、木構造で見ると、ルートから末端までの道の間でmainからaside.adまでの間にある要素に対してのみマッチすることを示しているということです(aside.adが無いパスの場合は、末端までスコープに含まれます)。

このように、toで示された要素はスコープの終端(scoping limit)となり、それより下はスコープの範囲外となります。

スコープの便利な使用例

冒頭で紹介した仕様書では、@scopeの活用例のアイデアが載っています。それが面白かったのでここで紹介します。

そのアイデアとは、コンポーネントベースのCSSを@scopeを用いて実装する方法です。コンポーネントベースでアプリケーションを組み立てる際には、そのコンポーネント用のCSSはそのコンポーネントだけに適用されるということが重要です。@scopeを用いて、かつコンポーネントから出力されるHTMLにひと工夫加えることによって、これをいい感じに実装できます。

具体的には、各コンポーネント(MainとSubがあると仮定しましょう)のルート要素にdata-scope属性を持たせます。Reactで書くとしたらこんな感じでしょう。

const MainComponent = () => {
  return (
    <section data-scope="main-component">
      <p>...</p>
      <SubComponent />
    </section>
  );
}

const SubComponent = () => {
  return (
    <section data-scope="sub-component">
      <p>...</p>
    </section>
  );
}

これをレンダリングしたらこんな感じになるでしょう(次のHTMLは仕様書からの引用です)。

<section data-scope="main-component">
  <p>...<p>
  <section data-scope="sub-component">
    <!-- children are only in the inner scope -->
    <p>...<p>
  </section>
</section>

それに対して、次のようなCSSを書くことで、各コンポーネントだけに適用されるスタイルを書くことができます(次のCSSも仕様からの引用です。ただしコメントは省略)。

@scope ([data-scope='main-component']) to ([data-scope]) {
  p { color: red; }
  section { background: snow; }
}

@scope ([data-scope='sub-component']) to ([data-scope]) {
  p { color: blue; }
  section { color: ghostwhite; }
}

この例では2つ@scopeがあり、1つ目がmain-component用のスタイル、2つ目がsub-component用のスタイルを担当しています。例えば1つ目のスコープ定義は「data-scope属性が'main-component'という値を持つ要素から、何かしらのdata-scope属性を持つ要素まで」という意味です。すべてのコンポーネント(由来の要素)がdata-scopeを持つようにしておけば、この定義によってスコープを「main-componentの中の要素、ただし他のコンポーネントの中は除く」という範囲にすることができます。つまり、main-componentの中だけに適用されるスタイルを書くことができたのです。2つ目も同様です。

もちろん、上のReactのコードのようにすべてのコンポーネントに手作業でdata-scopeを持たせるのは現実的ではありませんから、実用の際にはUIライブラリとかCSS in JSライブラリとかがよしなにやってくれることになるでしょう。

仕様書では@scopeが無い場合の従来の方法も紹介されていますが、それはコンポーネント境界だけでなく内部も含むすべての要素にdata-scopeのような印を付けて回る必要があるものです。それに比べると、@scopeは実装の簡潔化に随分と貢献してくれることが期待できます。

スコープと詳細度

CSSには詳細度 (specificity) という概念があります。複数のスタイル宣言が同じ要素に適用されるとき、詳細度が高いほうのスタイル宣言が優先されます[2]

例えば、次のCSSを考えます。

.alert { color: red; }

p { color: blue; }

このとき、次のHTMLのp要素の文字色は何色になるでしょうか。

<p class="alert">ここは何色?</p>

答えは、赤色です。なぜなら、.alertというセレクタは詳細度が (0, 1, 0) である一方、pというセレクタは詳細度が (0, 0, 1) であるため、.alertのスタイルが優先されるからです。

@scope内に書かれたセレクタについては、詳細度に@scopeルートのセレクタの詳細度が加算されます。次の例では、mainが詳細度 (0, 0, 1) でありpも (0, 0, 1) なので、@scopeの中に書かれたpのスタイル宣言は両者を合計した詳細度 (0, 0, 2) を得ます。

@scope (main) {
  /* これは詳細度 (0, 0, 2) */
  p { color: red; }
}

/* これは詳細度 (0, 0, 1) */
p { color: blue; }

なお、スコープの加減(toの後ろに書かれるセレクタ)については詳細度に影響しません。

新たな概念: スコープ近接度

@scopeの導入に伴って、CSSに新たな概念が導入される見込みです。それはスコープ近接度 (scope proximity weights) です。なお、スコープ近接度というのは筆者が今考えた訳語で、これが今後定着するかどうかはよくわかりません。まだ、まだ原文でも用語が整理されていないのか、scope proximityとscoping proximityが混在しています。

スコープ近接度とは、セレクタがマッチした要素がscoping rootとどれくらい近いかを表す数です。言い換えれば、マッチした要素とscoping rootとの距離です。

そして、スコープ近接度は、詳細度と同様にカスケーディングに組み込まれます。つまり、2つのスタイル宣言のどちらが適用されるかの決定に関与します。両者の詳細度が同じ場合、どちらが優先されるかはスコープ近接度によって決定されます。具体的には、より近い方が優先されます。

例えば、次のCSSを考えます。

@scope (main) {
  p { color: red; }
}

@scope (body) {
  p { color: blue; }
}

つまり、「mainのスコープの中のp」と「bodyのスコープの中のp」に対して異なるスタイルを当てています。この場合、次のようなHTML断片においてp要素は何色になるでしょうか。

<body>
  <header>...</header>
  <main>
    <div>...</div>
    <div>
      <p>ここは何色?</p>
    </div>
  </main>
</body>

この場合、p要素は2つのスタイル指定の両方を満たしています。また、両者の詳細度も同じです。このような場合、スコープ近接度を比較して、より近いほうが適用されます。図を見ると分かりやすいでしょう。

セレクタにマッチしたp要素とscoping rootの距離を木構造上で示した図。mainとpは距離2で、bodyとpは距離3である。

スコープ近接度における要素間の距離は、親子関係が何代離れているかによって定義されます[3]。直接の子なら1、孫なら2です。今回の場合、mainとpの距離は2である一方、bodyとpの距離は3です。

今回の例の場合、よりルートとの距離が近いmainのルールが適用され、p要素の色は赤色になります。

ちなみに、スコープに属さないセレクタとスコープに属すセレクタをスコープ近接度で比較した場合、スコープに属すセレクタのほうが優先されます[4]

@scope (main) {
  /* こちらが優先される */
  p { color: red; }
}

main p { color: blue; }

これは、次の図で理解するとわかりやすいでしょう。スコープに属さないセレクタは仮想的なscoping rootがはるか上空にあり、距離∞と見なせます。

セレクタにマッチしたp要素とscoping rootの距離を木構造上で示した図。スコープを持たないセレクタは距離∞となる。

スコープに関連するその他の構文

実は今回参照している仕様(のドラフト)では、@scopeの他にもスコープの概念を利用する新しい構文が2つ定義されています。

セレクタスコーピング記法

英語ではselector scoping notationです。これは、@scopeという構文を使わなくても気軽にセレクタにスコープを付与できるというものです。

/* 以下の2種類の宣言は同じ意味 */
@scope (main) {
  p { color: red; }
}

(main) p { color: red; }

ここで(main) pという新記法が登場しました。これは、pというセレクタに(main)が前置されています。この部分がセレクタスコーピング記法です。このように(セレクタ1) セレクタ2と書くことで、セレクタ2セレクタ1をscoping rootとするスコープに属すると見なされます。このように、@scopeの中身が1つだけならば@scopeが必要ありません。

ちなみに、次のようにするとセレクタスコーピング記法を用いるほうが優先されます。

(main) p { color: red; } /* こちらが優先される */
main p { color: blue; }

なぜなら、どちらのセレクタも詳細度は同じですが、前者((main) p)のpはスコープに属するためスコープ近接度で勝っているからです。

そして、この記法はscoping limitにも対応しています。

/* 以下の2種類の宣言は同じ意味 */
@scope (main) to (aside.ad) {
  p { color: red; }
}

(main / aside.ad) p { color: red; }

このように、セレクタスコーピング記法を使う場合はカッコの中をtoの代わりに(始まり / 終わり)とします。

記法がややこしくて@scopeだけあればいいような気もしますが、こちらの記法にも利点があります。それは、セレクタが書けるところならどこでも書けるということです。

例えば、querySelectorの引数にも書けます。

const niceEm = document.querySelector('(main / aside.ad) em.nice');

スコープ付き子孫セレクタ

英語ではscoped descendant combinatorです。これはA >> Bという記法の新しいセレクタです。意味は Aの子孫であるB です。

鋭い方は「それってA Bと変わらないんじゃない?」とお思いでしょう。実際基本的な意味は同じで、違いは「スコープ付き」のところにあります。

A >> Bでは、ABに対するscoping rootであると見なされます。

つまり、マッチ時の挙動は一緒ですが、セレクタの優先順位を決める際に>>を使っている場合はスコープ近接度が与えられます。前述の通り、スコープ近接度が設定されているセレクタの方が設定されていないセレクタより優先されるので、次の2つの場合上が優先されます。

main >> p { color: red; } /* こちらが優先される(スコープ近接度を持つため) */
main p { color: blue; }   /* こちらは使用されない(スコープ近接度を持たない=無限) */

一応述べておくと、いずれも同じスコープを作るという観点から、以下の3つはすべて同じ意味となります。

/* 以下の3つは同じ意味 */
@scope (main) {
  p { color: red; }
}

(main) p { color: red; }

main >> p { color: red; }

一方で、>>は使用場所が肝心です。以下の3つはすべて異なる意味となるので注意してください。

/* 以下の3つはすべて別の意味 */
body.light >> div p { color: black; } /* (1) */
body.light div >> p { color: black; } /* (2) */
(body.light) div p  { color: black; } /* (3) */

この3つの例では、マッチする対象はすべて「body.lightの中のdivの中のp」であり一緒ですが、スコープ近接度の設定のされ方が異なります。(1)はbody.lightdivの間にスコープ近接度が設定されます。(2)はdivpの間、(3)はbody.lightpの間です。このように(特に(1)の例のように)、>>セレクタはセレクタの末端だけでなく途中にスコープ近接度を設定できる点が特徴的です。

下の図のようなbody-main-div-pという親子関係の木構造があった場合、上の例の(1)から(3)はいずれも末端のpにマッチしますが、この場合の勝者は(2)となります。なぜなら、(2)のセレクタは最も距離が近い(距離1の)スコープ近接度を含むからです。

body-main-div-pという親子関係に発生するスコープ近接度を木構造上で示した図。(1)はbodyとdivの間に距離2のスコープ近接度を発生させ、(2)はdivとpの間に距離1のスコープ近接度を発生させ、(3)はbodyとpの間に距離3のスコープ近接度を発生させる。

スコープ付き子孫セレクタ >> の活用例

>>に関しては、仕様に面白い例があったので引用して紹介します。

.light-scheme >> a { color: darkmagenta; }
.dark-scheme >> a { color: plum; }

意図としては、.light-schemeは明るいカラーテーマの場所、.dark-schemeは暗いカラーテーマの場所なのでしょう。そして、明るいカラーテーマの中と暗いカラーテーマの中ではリンクの文字色が異なります。

このような書き方のよい点は、.light-scheme.dark-schemeがネストしていても正しく動くという点です。次のようなHTMLを考えましょう。

<div class="light-scheme">
  <p>明るい!</p>
  <div class="dark-scheme">
    <p>暗い……</p>
    <a href="#">ここは何色?</a>
  </div>
</div>

この場合、.light-scheme >> a.dark-scheme >> aは両方ともaにマッチしますが、.dark-scheme >> aのほうが優先されます。これは、次の図を見ると分かるように、後者の方がスコープ近接度が近いからです。

前の例の木構造を示した図。div.light-schemeとaは距離2である一方、div.dark-schemeとaは距離1であることを示している。

これはスコープ近接度を有効に活用したスタイリングの例となっています。もし>>を使わずに次のようにしていた場合、ネストした場合にうまくいきません(詳細度もスコープ近接度も差がないため、記述の順番が後のほうが勝つというルールに従って常に.dark-scheme aが適用されてしまいます)。

.light-scheme a { color: darkmagenta; }
.dark-scheme a { color: plum; } /* 両方にマッチしたら常にこちらが勝ってしまう */

余談ですが、仕様には>>を追加したモチベーションとして、「これが多くの人々が子孫セレクタ(A B)に対して求めていた挙動だから」[5]とされています。このようにネストしていた場合は内側が勝つという挙動のほうが直感的だからということでしょうか。

決まっていないこと色々

この記事で紹介しているのはFirst Public Working Draftという非常に早い段階の仕様なので、まだ決まっていないことがたくさんあります。

例えば、これまで紹介してきた>>セレクタや(main / aside.ad) pのような記法については、ドラフトへのメモ書きとして「そもそもこれ要るの?」と書いてあります。とりあえず需要がありそうだから作ってみたが、これを本当に残すかどうかすら結論はまだ出ていないということです。

この記事の最後の話題として、このようなまだ決まっていないことを紹介します。

scoping limitは下端を含むか含まないか

@scopetoを用いてスコープの下端を設定できるということを思い出しましょう。用例を再掲します。

@scope (main) to (aside.ad) {
  div {
    border: 1px dashed black;
  }
  p {
    margin-block: 1em;
  }
  strong {
    color: red;
  }
}

すでに説明した通りこのスコープの範囲は「mainからaside.adまで」ですが、ここで問題があります。それは「aside.ad自身はこのスコープに含むのか含まないのか」ということです。例えば、こう定義してみましょう。

@scope (main) to (aside.ad) {
  aside {
    opacity: 0.5;
  }
}

すると、mainの下にaside.adがある場合、このaside.adはこの@scopeで定義されるスコープに含まれるかどうかが問題になります。含まれるならば、aside.adopacity: 0.5;が適用されることになります。

main-div-aside.adという親子関係を木構造上で表した図。このaside.adがスコープに含まれ、半透明になるかどうかが問題となることを示す。

含むか含まないのかについて、結論は出ていません。これに関する議論は次のGitHub Issueで見ることができます。

https://github.com/w3c/csswg-drafts/issues/6577

ざっと要約すると、to inclusive (aside.ad)to exclusive (aside.ad)のようなキーワードを用いて両方を出し分けられるようにするとよいのではという意見に比較的人気があるように見えます。ただ、そうなると(main / aside.ad) asideというもうひとつの記法と構文を揃えられないという問題があります。こちらでtoではなくスラッシュが用いられていたのはtoだとそれがto要素という要素を示しているかもしれないため、ここでは他のセレクタと被らない記号を用いる必要があるからです。inclusiveexclusiveについても要素名と捉えられる恐れがあるのでこの構文には導入できません。キーワードを使う方法にはこの問題があり、まだ結論が出ていないようです。

スコープ近接度は弱いか強いか

この記事の中盤くらいでスコープ近接度は「弱いスコープ近接度」と「強いスコープ近接度」があるとちらっと述べていました。ここまで説明したのは「弱いスコープ近接度」に基づく説明です。実は、スコープ近接度を弱くするか強くするかという問題も残っています。

弱いスコープ近接度と強いスコープ近接度の違いは、カスケーディングにおいて詳細度よりも強いか否かです。

これまでみてきた弱いスコープ近接度とは、優先順位の判断順が詳細度より弱いものです。つまり、セレクタの詳細度が同じだった場合にスコープ近接度を用いてどちらを優先するか決めます。

一方で、強いスコープ近接度というセマンティクスを採用した場合、詳細度よりもスコープ近接度のほうが先に判断されます。つまり、スコープ近接度で勝っていれば、詳細度は関係なく優先されることになります。スコープ近接度が同じ場合にのみ詳細度が比較されます。

次の木構造とCSSで考えてみましょう。

body-main-p.warningという親子関係の図。p.warningがmainのスコープの中にあることを示す。

@scope (main) {
  p { color: black; } /* 詳細度 (0, 0, 2) */
}
  
p.warning { color: red; } /* 詳細度 (0, 1, 1) */

この例においてp.warningにどちらのスタイルが適用されるのかは、スコープ近接度が強いか弱いかによって異なります。

これまで見たような弱いスコープ近接度の場合、詳細度が高いp.warning { color: red; }のほうが適用されます。

一方で、強いスコープ近接度の場合、p { color: black; }は距離1を持つ一方、p.warning { color: red; }は距離∞のため、p { color: black; }が優先されます。詳細度は関係ありません。

このように、強いスコープ近接度を採用した場合、CSS設計にかなり影響を与えます。@scopeの中に書かれているスタイルは、@scopeの外に書かれているスタイルよりも問答無用で優先されることになります(>>など他の方法でスコープ近接度を与えれば別ですが)。

また、スコープ近接度は詳細度とは異なり、CSSの定義だけを見て判断するのではなく実際に木構造のどこにマッチしたかを見て決まるものです。これが詳細度よりも優先されるとなると、CSSの挙動を追うのがかなり苦しくなりそうです。

以上のことから、強いか弱いかの議論においては弱いほうが優勢です。詳しくは以下のGitHub issueを参照してください。

https://github.com/w3c/csswg-drafts/issues/6790

ただ、スコープ近接度よりさらに上位の力である@layerを使えばうまく制御できるかもという意見も見られます。また、そもそもスコープ近接度を与える3種類の記法(@scope>>(セレクタ1) セレクタ2)で強い・弱いを統一する必要があるのかどうかといったことも議論の余地があります。

確固たる結論が出ているというよりは、強いスコープ近接度は強すぎて扱い方が分からないので弱い方に傾いているという印象を受けます。もし強いスコープ近接度を活用する素晴らしいアイデアを思いついた方がいれば、GitHubのissueにコメントすれば未来が変わるかもしれません。

まとめ

この記事では、現在早期の検討段階にあるCSSスコーピングの概念について紹介しました。

繰り返し述べている通り、この記事の内容はぜんぜん確定していません。

逆に言えば、今がフィードバックのチャンスだということです。フィードバックは、ブラウザ開発者などの視点の他に、著者(実際にCSSを書く人)の視点も必要です。あなたもアイデアがあればぜひフィードバックをしてみてください。フィードバック先はGitHubのissuesが推奨されています。忘れずに対応するラベル(この記事の場合はcss-cascade-6)を付けましょう。

https://github.com/w3c/csswg-drafts/issues

脚注
  1. 一部の概念はSelectors Level 4にも依存しています。 ↩︎

  2. ただし、詳細度以外の要因で決定されることもあります。例えば@layerによる序列は詳細度よりも優先されます。 ↩︎

  3. 原文 “generational hops between the ancestor/descendant element pair” ↩︎

  4. 原文からはスコープに属するセレクタとスコープに属さないセレクタを比較した場合の明確な定義が見つけられませんが、EXAMPLE 4にこの場合の説明が書かれています。 ↩︎

  5. 原文 “It’s defined here to work the way many people expected the regular descendant combinator to work...” ↩︎

GitHubで編集を提案

Discussion