Shadow DOMのカプセル化とスロット合成
スタイル衝突もイベント漏れも起きない部品を、ブラウザと同じ理屈で設計できる。shadowツリーの境界、slotによる光と影のツリー合成、partや::slottedの貫通規則を原理から解き明かします。
- 1.shadowツリーはスタイルとセレクタの探索を境界で遮断し、内部のCSSは外へ漏れず外のCSSも入らない。イベントはバブリングするがcomposed=falseなら境界を越えず、越える場合もcomposedPathで実体を残しつつtarget/relatedTargetを再ターゲット(retarget)する。
- 2.slotはlight DOM(ホスト側の子)をshadowツリーへ差し込み、両者を合成したflattened treeがレイアウト・描画・継承の対象になる。要素の物理的な所属はlightのまま、見かけの位置だけがslot位置へ移る。
- 3.::part()と::slotted()だけが境界を貫通する。::part()はexportpartsを連ねた経路でホスト側から内部要素を狙え、::slotted()はslotに入ったlight要素の最上位ノードのみを選べる。color等の継承プロパティは境界を素通りする。
shadowツリーは「探索を止める境界」である
element.attachShadow({ mode: "open" }) を呼ぶと、そのホスト要素(shadow host)にshadowルートを根とする独立したツリーがぶら下がります。重要なのは、これが単なる入れ子のDOMではなく、セレクタ照合とスタイル解決の探索をその境界で打ち切る仕組みだという点です。shadowツリー内のスタイルシートが照合できるのはそのツリー内の要素だけで、外側(document)のセレクタはshadowルートを越えて内部を選べません。逆もまた然りで、内部の p { color: red } は外の <p> に届きません。カプセル化の本体は「DOMの隠蔽」ではなく「探索範囲(scoping)の分断」です。
ホスト自身は外側のツリーに属する普通の要素で、内部ツリーの根がshadowルートです。この二重性が後述する合成と再ターゲットのすべての出発点になります。mode には open と closed があり、open では host.shadowRoot で外から参照できますが、closed では null を返し参照経路を塞ぎます。ただし closed はあくまで参照のしにくさで、セキュリティ境界ではありません。
ホストを含む外側のツリーを light DOM(明るい木、light tree)、attachShadowで作った内側を shadow DOM(影の木、shadow tree)と呼びます。「light」はホストの子として書かれる利用者側のマークアップ、「shadow」は部品が内部に隠し持つ実装、というのが原語のニュアンスです。両者は別々のツリーであり、親子関係(parentNode)では直接つながっていません。橋渡しするのが次のslotです。
slotとflattened tree:光と影の合成
部品の利用者がホストの子として書いた要素(light DOMの子)は、そのままでは画面に出ません。shadowツリー内に置いた <slot> が差し込み口となり、ホストの子をその位置へ投影(projection)します。<slot name="x"> には light 側の slot="x" を持つ子が、名前なしslotには slot 属性を持たない子が割り当てられます。
ここで決定的なのが、ブラウザがレイアウト・描画・スタイル継承に使うのは元のDOMそのものではなく、light と shadow を合成した一本のツリー=flattened tree(平坦化ツリー)だという点です。slotの位置に、割り当てられた light の子が見かけ上挿入された木が組み立てられ、これが描画の対象になります。
light DOM(ホスト側に書いた木)
<my-card>
<span slot="title">見出し</span> ← slot="title"
<p>本文</p> ← 名前なし
</my-card>
shadow DOM(部品内部の木)
#shadow-root
<header><slot name="title"></slot></header>
<section><slot></slot></section>
flattened tree(合成後、描画に使う木)
<my-card>
#shadow-root
<header> → <span slot="title">見出し</span>
<section> → <p>本文</p>
ここで注意すべきは、要素の所属は移動しないことです。<span slot="title"> の parentNode は依然 <my-card> であり、shadowツリー内の <header> ではありません。移るのは「flattened tree 上の見かけの位置」だけです。slot.assignedNodes() で割り当て先を、element.assignedSlot で逆引きできます。CSSの継承(color や font-size)や z-index の文脈は flattened tree をたどるため、slotに入った light 要素は、light 側で指定したスタイルを保ちつつ、shadow 側のslot位置の継承も受けます。この合成こそが Web コンポーネント の柔軟さの源です。
スタイルの境界:何が漏れ、何が通り抜けるか
shadowツリーのスタイル隔離はセレクタの照合に対する境界であって、継承値に対する境界ではありません。ここを取り違えると挙動を見誤ります。
| 対象 | 境界を越えるか | 理由 |
|---|---|---|
| 外のセレクタで内部要素を選ぶ | 越えない | セレクタ照合がshadowルートで止まる |
| 内部セレクタで外の要素を選ぶ | 越えない | 内部スタイルの探索範囲はツリー内のみ |
| color / font 等の継承プロパティ | 越える(素通り) | 継承はflattened treeをたどり値が流れ込む |
| CSSカスタムプロパティ(--x) | 越える(素通り) | 継承プロパティとして境界を貫通する |
| ::part() / ::slotted() | 限定的に越える | 貫通用に定義された専用擬似要素 |
つまり「Shadow DOM に外のCSSが一切効かない」は不正確です。セレクタで内部要素を直接狙う経路は塞がれる一方、継承プロパティとカスタムプロパティは境界を素通りします。だからテーマ設計では、内部で color: var(--card-fg) のようにカスタムプロパティを読み、外からその変数値だけを流し込む方法が定石になります。ホスト要素自身は :host で内部から選べ、利用文脈に応じて :host(.dark)、slotの空状態は :host(:empty) ではなく slot 側の ::slotted 不在で扱うなど、専用セレクタが用意されています。詳細度やカスケードの基本規則は CSSカスケード・詳細度・継承の解決アルゴリズム と同じく適用されますが、適用範囲がツリー単位に区切られる点が違いです。
::part()と::slotted():貫通の二経路
境界を意図的に開ける穴が2つあります。狙う対象が影の木の中身か光の木の中身かで使い分けます。
::part() は、部品側が内部要素に part="label" と印を付けたものを、外から my-card::part(label) で狙う経路です。属性セレクタのような自由な照合はできず、作者が明示的に公開したpartだけが対象になります。多段にネストしたコンポーネントでは、内側のpartを外へ中継するために exportparts を使います。exportparts="label: card-label" のように、内側のpartを外向けの別名で再公開し、これを連ねた経路でのみ最外周まで届きます。中継が一段でも欠ければそこで止まります。
::slotted() は逆に、slotに差し込まれた light 要素を shadow 側のスタイルから狙う経路です。重要な制約は、::slotted(span) が選べるのはslotに割り当てられた最上位ノードだけで、その子孫(::slotted(span p) のような深い指定)は選べないことです。light 要素の中身のスタイルは light 側(document)の責任、という線引きです。
::part() は「部品が内部を外向けに公開する」穴、::slotted() は「部品が外から来た中身を内部スタイルで飾る」穴です。前者は外の作者が内部を触る、後者は部品作者が外の中身に触る、と向きが逆です。さらに ::slotted() で当てたスタイルは、light 側がdocumentで同じプロパティを指定すると、出自(origin)が同じなら通常のカスケードで決まり、light 側が後勝ちしやすい点も実務で混乱しがちです。
イベントの境界と再ターゲット(retargeting)
イベントは flattened tree に沿ってバブリングしますが、shadow 境界には固有の規則があります。第一に composed フラグです。composed: false のイベント(多くのUIイベントの一部や独自イベントの既定)はshadow境界の手前で止まり外へ出ません。composed: true(click、input、ほとんどのマウス・キーボードイベント等)は境界を越えて document まで上がります。
第二が再ターゲット(retargeting)です。composed: true のイベントが境界を越えて外のリスナへ届くとき、event.target はそのリスナから見えるツリー上の要素へ付け替えられます。shadowツリー内部の本当のクリック対象を外へそのまま見せると、カプセル化が破れるためです。外のリスナでは event.target はホスト要素になり、内部の実要素は隠れます。ただし event.composedPath() を呼べば、内部実要素からwindowまでの実際の伝播経路を取得でき、open なshadowツリー内のノードも見えます(closed では経路から内部が省かれます)。relatedTarget(mouseover等)や focus 系の対象も同様に再ターゲットされます。
shadow内部の <button> をclick(composed: true)
内部リスナ: event.target = <button>(実体)
ホスト外リスナ: event.target = <my-card>(再ターゲット後)
どちらでも: event.composedPath() で実経路 [<button>, …, <my-card>, …, window]
この再ターゲットの理解は、 delegation(イベント委譲)を境界をまたいで組む際の必須知識です。バブリング・キャプチャの基本順序そのものは イベント伝播の内部動作 と同じで、Shadow DOM はそこに「境界で止まるか」「targetを付け替えるか」の二層を追加していると捉えると整理できます。基礎となるツリー構造は DOM を押さえておくと、light/shadow/flattened の三つの木の区別が明確になります。
頻出は、(1) カプセル化の本体はセレクタ照合とスタイル探索のscope分断であり、継承プロパティとカスタムプロパティは境界を素通りする点、(2) 描画・継承の対象は light と shadow を合成した flattened tree で、slotは見かけの位置だけを移し parentNode は変えない点、(3) ::part()(exportpartsで中継、外→内)と ::slotted()(最上位ノードのみ、内→外の light)の貫通規則と向き、(4) composed: false は境界で止まり、composed: true は越える際に target を再ターゲットし composedPath で実経路が取れる点、(5) open/closed は参照のしやすさの差でセキュリティ境界ではない点。「Shadow DOMには外のCSSが一切効かない」「slotで要素の所属が移る」は定番の誤りです。
まとめ
Shadow DOM のカプセル化は、shadowルートを境界とするセレクタ照合・スタイル探索のscope分断が本体です。外のセレクタは内部を、内部のセレクタは外を直接選べませんが、color などの継承プロパティとCSSカスタムプロパティは境界を素通りします。利用者がホストの子として書く light DOM は、shadow 内の <slot> を通じて投影され、両者を合成した flattened tree がレイアウト・描画・継承の対象になります。このとき要素の物理的な所属(parentNode)は light のままで、移るのは見かけの位置だけです。境界を意図的に貫通する穴は、外→内の ::part()(多段は exportparts で中継)と、内→外の light を狙う ::slotted()(割り当て最上位ノードのみ)の二経路に限られます。イベントは flattened tree をバブリングしますが、composed: false は境界で止まり、composed: true は越える際に target を再ターゲットして内部実体を隠し、composedPath() で実経路を残します。基礎は DOM と Web コンポーネント、伝播順序は イベント伝播の内部動作、カスケード規則は CSSカスケード・詳細度・継承の解決アルゴリズム と合わせて押さえると、三つの木と二種類の境界が一本につながります。
Web/フロントエンド Article
Shadow DOMのカプセル化とスロット合成を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
Shadow DOM
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
slotはlight DOM(ホスト側の子)をshadowツリーへ差し込み、両者を合成したflattened treeがレイアウト・描画・継承の対象になる。要素の物理的な所属はlightのまま、見かけの位置だけがslot位置へ移る。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「Shadow DOM / Web Components」に近いか確認する。
- 強みである「shadowツリーはスタイルとセレクタの探索を境界で遮断し、内部のCSSは外へ漏れず外のCSSも入らない。イベントはバブリングするがcomposed=falseなら境界を越えず、越える場合もcomposedPathで実体を残しつつtarget/relatedTargetを再ターゲット(retarget)する。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。