Astroでサイトを作っていたとき、JavaScriptで後からDOMへ追加した要素だけCSSが効かないことがありました。
同じclass名を付けている既存の要素にはスタイルが当たっています。DevToolsで確認しても、JavaScriptが生成した要素には狙ったclassが付いていました。それでも見た目が変わりません。
原因はJavaScriptではなく、Astroのscoped CSSの仕組みにありました。
Astroのstyleはそのまま出力されるわけではない
Astroコンポーネント内に通常の<style>を書くと、そのCSSは既定でコンポーネント内だけに適用されるようスコープされます。
たとえば、次のようなコードを書いたとします。
<div class="notice">お知らせ</div>
<style>
.notice {
border: 1px solid #ccc;
padding: 1rem;
}
</style>
ブラウザへ出力される段階では、CSSは概念的に次のような形へ変換されます。
.notice[data-astro-cid-xxxx] {
border: 1px solid #ccc;
padding: 1rem;
}
Astro側で書いたHTMLにも、対応する属性が付きます。
<div class="notice" data-astro-cid-xxxx>お知らせ</div>
この属性があるため、同じ.noticeというclassを別のコンポーネントで使っても、CSSが不用意に外へ漏れません。
これは普段かなり便利な仕組みです。しかし、ブラウザ上のJavaScriptで後から要素を作る場合には注意が必要でした。
JavaScriptで作った要素にはscope用の属性が付かない
たとえば、ブラウザ側で次のように要素を追加します。
const notice = document.createElement("div");
notice.className = "notice";
notice.textContent = "JavaScriptで追加したお知らせ";
document.querySelector("#notice-area")?.appendChild(notice);
生成される要素は、基本的には次のようなHTMLです。
<div class="notice">JavaScriptで追加したお知らせ</div>
classはあります。しかし、Astroがビルド時に付けていたdata-astro-cid-*属性はありません。
一方、CSS側のselectorは次の形です。
.notice[data-astro-cid-xxxx]
そのため、class名が一致しているだけではselectorに一致せず、スタイルが適用されません。
ここで重要なのは、「JavaScriptで作った要素にはCSSが効かない」のではないという点です。実際には、Astroが生成したscoped selectorの条件を満たしていなかっただけでした。
対処1:動的要素のCSSをglobalにする
JavaScriptで生成する要素がサイト内で再利用されるUIなら、通常のCSSファイルへスタイルを移すのが分かりやすい方法です。
/* global.css など */
.notice {
border: 1px solid #ccc;
padding: 1rem;
}
この場合はdata-astro-cid-*を要求しないため、JavaScriptで後から作った.noticeにもそのまま適用されます。
動的に生成する要素のclassを「コンポーネント内だけのもの」と考えなくてよい場合は、この方法が扱いやすいです。
対処2:必要なselectorだけ:global()にする
CSS全体をglobalにしたくない場合は、Astroの:global()を使えます。
たとえば、Astro側で用意した領域の中にJavaScriptで項目を追加するなら、次のように書けます。
<div class="notice-area" id="notice-area"></div>
<style>
.notice-area :global(.notice) {
border: 1px solid #ccc;
padding: 1rem;
}
</style>
.notice-area側はそのコンポーネントにスコープされたまま、内側の.noticeだけをglobal selectorとして扱えます。
サイト全体の.noticeへ無条件でスタイルを当てるより、影響範囲を絞りやすい方法です。
対処3:style全体をis:globalにする
<style is:global>を使えば、そのstyleブロック全体をスコープしないCSSとして出力することもできます。
<style is:global>
.notice {
border: 1px solid #ccc;
padding: 1rem;
}
</style>
ただし、そのコンポーネントの外にも同じselectorが効くようになります。
一部の動的要素だけが問題なら、最初からstyle全体をglobalにするより、通常のCSSファイルへ移すか:global()で対象を限定する方が意図を追いやすいと思います。
data-astro-cid-*をJavaScriptで付ける方法は避けた
理屈だけなら、JavaScriptで生成した要素へ同じdata-astro-cid-*属性を付ければselectorには一致します。
ただ、この値はAstroがスコープ管理のために生成しているものです。アプリ側のコードから特定の値を前提にすると、コンポーネントの変更やビルド結果に依存する実装になります。
そのため、scope属性を手動で再現するのではなく、動的DOMに適用したいCSS側のスコープを見直す方を選びました。
DevToolsでは「classがあるか」だけでなくselectorを見る
この問題で分かりやすかった確認方法は、DevToolsで生成後の要素とCSS selectorを両方見ることでした。
要素に.noticeが付いているのに、Styles欄で期待するルールが候補にすら出てこない。そのうえ生成済みCSSを見ると.notice[data-astro-cid-xxxx]になっている。
ここまで分かれば、JavaScriptのclass付与処理ではなく、CSSのスコープを確認すべきだと切り分けられます。
コンパイル時と実行時の境界を意識する
Astroのテンプレートに書いたHTMLは、Astroがビルド時に処理できます。一方、document.createElement()などでブラウザ上に後から作るDOMは、その処理が終わった後に生まれます。
今回ハマったのは、このビルド時にAstroが管理するDOMとブラウザ実行時にJavaScriptが作るDOMの境界を意識していなかったことでした。
scoped CSS自体を避ける必要はありません。むしろ通常のコンポーネントでは便利です。ただ、JavaScriptでDOMを後から生成する場所では、その要素を誰が生成するのかに合わせてCSSのスコープも決めた方がトラブルを減らせます。
Astro公式ドキュメントにも、通常の<style>が既定でscopedになり、:global()や<style is:global>でスコープ外の要素へ適用できることが説明されています。