🐙

CSS大解剖 11日目: 「セレクター 1/3」

に公開

本稿は、2024年2月頃に書き溜めていたシリーズです。最後まで温存させるのが勿体ないので、未完成ですがそのまま公開します(公開日: 2025/9/25)。そのため、内容の重複や記述方針の不一致があるかもしれませんが、ご理解ください。


CSSの仕様を理解するために、1日ごとにテーマを決めて説明する企画11日目です。今日のテーマは「セレクター」です。

セレクターはSelectors Level 3で定義されていますが、これは古く実態に即していないため、本稿ではWorking DraftのSelectors Level 4を基準に説明します。

セレクターとは

セレクターとは、与えられた要素擬似要素に対して、それが条件を満たすかどうかをブール値で判定することができる式です。言い換えれば、セレクターは、文書に対して、それを満たす全ての要素や擬似要素の集合を返します。前者は element.matches(selector) 形式、後者は document.querySelectorAll(selector) 形式と理解してもいいでしょう。

セレクターの意味論は、セレクターの構文に対する構造再帰で説明できるため、well-definednessはおおむね明らかです。

セレクターはCSSスタイルシートのスタイルルールのプレリュードとして使えるほか、いくつかのDOM APIの引数として使うことができます。このような理由からか、Selectors仕様の名称には "CSS" が含まれていません。

なお、本稿でセレクターの意味論を説明するにあたっては、高速化は基本的に考慮しません。セレクタのパフォーマンス特性はWebブラウザに固有のものですが、実際には同じような最適化が行われることが多いでしょう (ID selectorやclass selectorのためにインデックスを張っておくなど)

セレクターの構文

Selectorsの構文はCSSとは切り離された形で説明されていますが、CSSの一部として利用される都合上、CSS Syntaxで説明されている要素値の列に対する構文として考えたほうが自然です。要素値は以下のいずれかからなります。

引数
<ident-token> foo 名前
function block foo( ... ) 名前、中身 (要素値のリスト)
<at-keyword-token> @foo 名前
<hash-token> #foo 名前、種別
<string-token> "foo" 文字列
<url-token> url(foo) 文字列
<number-token> 123 数値、種別
<percentage-token> 123% 数値、種別
<dimension-token> 123px 数値、種別、単位名
<whitespace-token> -
<CDO-token> <!-- -
<CDC-token> --> -
<colon-token> : -
<semicolon-token> ; -
<comma-token> , -
<delim-token> & 記号
[]-block` [ ... ] 中身 (要素値のリスト)
()-block` ( ... ) 中身 (要素値のリスト)
{}-block` { ... } 中身 (要素値のリスト)
<]-token> ] -
<)-token> ) -
<}-token> } -
<bad-string-token> "foo -
<bad-url-token> url(foo -

特に <whitespace-token> があることに注意が必要です。セレクターでは空白が意味をもつ場面があるため、字句文法では空白トークンを明示的に発行するようになっています。

要素値のうち、 <]-token>, <)-token>, <}-token>, <bad-string-token>, <bad-url-token> はパースエラーの存在を示すのみで用途はありません。また、セレクタでは利用場面がほぼないものもいくつかあります。

セレクターの具象構造

セレクターを構成する演算子には3種類の優先度があります。

文法規則名 結合優先度 結合方向 演算子
単純セレクター (simple selector) ↑最優先 - -
合成セレクター (compound selector) 任意 連接 (演算子を使わずに並べる)
複素セレクター (complex selector)
>
+
~
|| など
セレクターリスト ↓最劣後 任意 ,

単純セレクターには以下の7種類があります。

詳細度 出現位置
要素型セレクター p (0, 0, 1) 頭のみ
全称セレクター * (0, 0, 0) 頭のみ
属性セレクター [open] (0, 1, 0) 任意
クラスセレクター .btn (0, 1, 0) 任意
IDセレクター #main (1, 0, 0) 任意
擬似クラスセレクター :hover (0, 1, 0)[1][2] 任意
擬似要素セレクター ::before (0, 0, 1) 任意

空白

2つの単純セレクターの間では、空白の有無は非常に大きな意味を持ちます。空白がない場合は連接となり、合成セレクターを構成します。空白がある場合は子孫結合子となり、複素セレクターを構成します。

/* <p class="important"></p> */
p.important {}
/* <p><span class="important"></span></p> */
p .important {}

単純セレクターのうち、識別子から開始する可能性があるのは要素型セレクターと全称セレクターのみです[3]。それ以外のセレクターは全て記号から始まっているため、直前の単純セレクターとの境界が曖昧になることはありません。

また、要素型セレクターまたは全称セレクターを複数連接させたい場合、それは単一の要素型セレクターまたは全称セレクターで表現できる (p + html|*html|p)か、または常にマッチしない結果になる (p + a → マッチなし) ため、これらを同じ合成セレクター内に複数配置する必要もありません。合成セレクター内の順序は変更できるため、高々1つしかない要素型セレクターまたは全称セレクターは常に先頭に配置することができます[4][5]

なお、コメントを使えば任意のトークンを強制的に区切ることができますが、実際には .important/**/p のようなセレクターを書いても意図した通りには認識されないようです。

左結合性

結合子は左結合であると解釈する必要があり、Selectors 4の現行のマッチ規則の説明でもそうなっています。

実際にやってみましょう。たとえば以下のセレクターと文書断片を考えます。

.foo .bar .baz
<div id="e1" class="foo">
  <div id="e2" class="bar">
    <div id="e3" class="baz"></div>
  </div>
</div>

<div id="e4" class="bar">
  <div id="e5" class="foo">
    <div id="e6" class="baz"></div>
  </div>
</div>

ここで .foo .bar .baz が左結合だと考えてセレクターの解釈を計算すると以下のようになります。

セレクター 該当する要素
.foo e1, e5
.bar e2, e4
.foo .bar e2
.baz e3, e6
:is(.foo .bar) .baz e3

いっぽう、 .foo .bar .baz が右結合だと考えてセレクターの解釈を計算すると以下のようになります。

セレクター 該当する要素
.foo e1, e5
.bar e2, e4
.baz e3, e6
.bar .baz e3, e6
.foo :is(.bar .baz) e3, e6

このうち正しい解釈は、左結合のほうです。

フラットセレクター意味論

セレクターの構文では、合成セレクターは結合子よりも強く結合するとされています。しかし、この解釈は擬似要素の意味論を適切に説明するのに向いていません。

そこで、ここではセレクターの構文をフラット化して再解釈する方法を説明します。これは本稿独自の概念であり、CSSに直接言及されているものではありません。ただし、Match a Selector Against a Pseudo-elementの記述はこの解釈と類似しており、この解釈の正しさをある程度示唆しています。

フラットセレクター

ここでは複素セレクターのかわりに、フラットセレクターという概念を導入します。フラットセレクターは「単純セレクター」と「結合子」を区別せずに一列に並べたものです。たとえば、

p.foo ul.bar > li

に対応するフラットセレクターは、以下の7要素からなるリストです。

  1. p (要素型セレクター)
  2. .foo (クラスセレクター)
  3. (子孫結合子)
  4. ul (要素型セレクター)
  5. .bar (クラスセレクター)
  6. > (子結合子)
  7. li (要素型セレクター)

フラットセレクターは、通常の複素セレクターとして書けないものも書くことができます。具体的には以下のパターンがあります。

  • 結合子が先頭に出現する。 (> p)
  • 結合子が末尾に出現する。 (p >)
  • 結合子が連続して出現する。 (ul > > a)
  • 空のフラットセレクター。

フラットセレクターの解釈は、それにマッチする全ての要素・擬似要素の集合という形で定義されます。これは、以下の方法で再帰的に決定されます。

  • 空のフラットセレクター \epsilon は、デフォルトでは全ての要素・擬似要素にマッチします。あるいは、文脈が指定されていれば、それに従います。
  • あるフラットセレクター S_1 の最後に要素 x をつけ足した新しいフラットセレクター S = S_1; x の解釈 \llbracket S \rrbracket は、以下のように決定します。
    1. まず、 S_1 にマッチする要素・擬似要素の集合 \llbracket S_1 \rrbracket を計算します。
    2. 次に、セレクターまたは結合子 x の種類を調べます。その種類ごとに固有の方法で、 \llbracket S_1 \rrbracket から \llbracket S \rrbracket を計算します。
      • たとえば x が要素型セレクター p であった場合は \llbracket S \rrbracket = \llbracket S_1 \rrbracket \cap \{ e \mid e \text{はp要素である} \} となります。
      • また、たとえば x が子結合子 > であった場合は \llbracket S \rrbracket = \{ e \mid \exists e_1 \in \llbracket S_1 \rrbracket.\ e \text{は} e_1 \text{の子要素である} \} となります。

フラットセレクターの正当性

フラットセレクターの計算と、通常のセレクターの計算の違いは、以下の2点です。

  • 差異その1: 単純セレクターの横に結合子があるときに、単純セレクターを結合子とは独立に評価する。
    • 通常のセレクターの計算では p > apa に対する二項演算として評価します。
    • いっぽう、フラットセレクターの計算では p > a を評価するために p >a を分けて評価します。
  • 差異その2: 結合子が、その右側にある単純セレクターの並びよりも強く結合する。
    • 通常のセレクターの計算では .foo > .bar.baz において .bar.baz をひとまとまりとみなします。
    • いっぽう、フラットセレクターの計算では .foo > .bar.baz において .foo > .bar をひとまとまりとみなします。

実はこれらの違いは問題になりません。というのも、CSSに登場する結合子はすべて、以下の性質が成り立っているからです。

  • 結合子の評価は必ず、右辺の部分集合のみを返す。たとえば > について、 A > B は常に B が選択する要素しか選択しない。[6]
  • 加えて、左辺が右辺をどのようにフィルタリングするかは、右辺の定義によらない。[7]

これを言い換えると、たとえば > セレクター A, B に対して A > B:is(A > *):is(B) とおおむね等価であり、それが > 以外の結合子についても言えるということになります。これは偶然ではなく、セレクターの構文に対する設計者や利用者の期待が反映されているものだと考えられます。

さて、 A > B = :is(A > *):is(B) という性質は「差異その1」が実際には吸収されることを表しています。では「差異その2」はどうでしょうか。実はこの差異も吸収されることが、以下の計算でわかります。

  • A > :is(B):is(C)
  • = :is(A > *):is(is(B):is(C))   (結合子の左右独立性)
  • = :is(:is(A > *)is(B)):is(C)   (連接の結合律)
  • = :is(A > B):is(C)   (結合子の左右独立性を逆適用)

これによりフラットセレクター意味論は一定の正当性があることが確認できます。

擬似要素のマッチ

わざわざこのようにフラットな定義に置き換える目的は、擬似要素の振る舞いを適切に記述することにあります。

前の節でフラットセレクターの正当性をインフォーマルに証明しましたが、実はこの証明にはもうひとつ暗黙的に仮定していることがありました。それは、連接の結合律です。実はこれは擬似要素が関与すると成立しません。[8]

2つ以上の単純セレクターを連接させて合成セレクターを作ったとき、通常これはこれらのセレクターのAND演算を行っているものと考えています。そのため、必然的に結合律も満たされています。しかし、擬似要素の連接はこの方法では説明がつきません。

このことがわかりやすく説明されているのが、 ::first-line:hover vs. :hover::first-line 問題です[9]。この2つが異なるというのは、まさに交換律の破れに他なりません。連接がAND演算であったならば、このようなことは起こらないはずですから、これは擬似要素の連接がAND演算ではないことを端的に表していることになります。

では擬似要素とは何であるかというと、これは単純セレクタよりも結合子に近い概念であると考えるのが適切です。これは以下を比較するとわかります。

  • 擬似要素以外の単純セレクタの連接は左辺を右辺で絞り込んでいます。 p.btn において、 .btnp のリストを絞り込む役割を持っています。
  • 結合子は左辺の集合を加工しています。 p > a において、 > ap のリストを絞り込むのではなく、 p のリストをもとに全く新しいリストを作っています。それは元の要素の集合に包含されるとは限りません。
  • この観点からは、擬似要素の連接は左辺の集合を加工しています。 p::before において、 ::beforep のリストを絞り込んでいるわけではありません。

結合子に近い役割であるにもかかわらず、具象構文上は連接として扱われているこの不都合は、この2つを同じ優先度の演算として再解釈することで綺麗に整理できます。


脚注
  1. :is(), :not(), :has(), :where(), :nth-child(), :nth-last-child() には特別なルールが適用されます ↩︎

  2. :before, :after, :first-letter, :first-line は詳細度計算上も擬似要素と解釈されます ↩︎

  3. html|* のようなセレクターも全称セレクターであり、これは識別子で始まっています。 ↩︎

  4. 続く節で説明するように、合成セレクター内に擬似要素セレクターがある場合はこの議論はそのままでは通用しません。しかし、要素型セレクターや全称セレクターは擬似要素にはマッチしないため、擬似要素セレクターの後に要素型セレクターや全称セレクターが来てもマッチすることはありません。そのため、いずれにせよ要素型セレクターや全称セレクターを合成セレクター内の先頭に置けないような場面はないということになります。 ↩︎

  5. まあ、現代のCSSであれば :is() があるのでいかようにもなる、という風に議論を終えてしまってもいいのですが。 ↩︎

  6. たとえば、2つのセレクター A, B の演算を :is(A, B) で定義すると、この演算はこの条件を満たしません。ここの記述は、このような演算は単一の結合子で書くことはできないということを言っています。 ↩︎

  7. たとえば、2つのセレクター A, B の演算を B > A > B で定義すると、この演算はこの条件を満たしません。ここの記述は、このような演算は単一の結合子で書くことはできないということを言っています。 ↩︎

  8. 別の話として、実は現行仕様では :is() が擬似要素をサポートしていません。ただし、本稿のここまでの議論で :is() という記法を使っているのは単に演算子の結合優先度を明示して議論を行いやすくするためであり、実際のCSSの :is() の特殊な振る舞いに注目したものではないので注意してください。 ↩︎

  9. 現時点では ::first-line:hover のような形のネストは主要Webブラウザでは実装されていないようです。しかし、実装されていないという事実を用いることでもここでの議論は同様に成立します。 ↩︎

Discussion