CSS Module Scripts の夢を見る・script 要素の書き方による動作の違いまとめ
CSS Module Scripts という仕様が出来て、JS (ESModules) から CSS ファイルを import して適用できるようになったらしい。
Webpack によるビルドを前提にした React プロジェクトなんかだと、以前から「CSS Modules」という形で JS から CSS を import して適用する書き方があったが、CSS Module Scripts というのはそれとは別物。ビルドの必要はなく、ブラウザのみで動作するネイティブな仕様だ。
コードとしてはこんな感じで書く。
<script type="module">
import styles from './styles.css' assert { type: 'css' };
document.adoptedStyleSheets.push(styles);
</script>
import 構文は ESModules の中でしか書けないので、type="module" が必須。
assert というところが独特だ。assert 部分ではファイルタイプを指定しているが、CSS の他にも JSON ファイルを import する時にファイルタイプを指定するために策定されている模様。
んで、読み込んだスタイル (変数 styles) をページに適用するには、document.adoptedStyleSheets に追加してやれば良い。コイツは配列で、
document.adoptedStyleSheets = [styles];
こんな風にも書いたりできるのだが、最近 push() 関数が生えたので、コレを使うと「既存のスタイルはそのままに、スタイルの追加 (カスケード)」ができるようになる。
2022年6月現在、この書き方によって CSS を適用できるのは Chrome 系ブラウザのみで、Firefox や iOS Safari では上手く適用できなかった。
ところで、type="module" を書いた script 要素って、どのタイミングで読み込みと実行が入るんだろう?あと、外部 JS ファイルを読み込む場合と、インラインに JS を書く場合とに違いはあるのか?head 要素に書いた時と body 要素の最後に書いた時とでの違いは?async と defer とかもあった気がするけどイマイチ分かっていない…。
ということで、以下のデモページでは CSS Module Scripts の動作確認と、色んな script 要素の書き方をセットで試している。開発者コンソールを確認してみてほしい。
- デモページ : Practice CSS Module Scripts
- ソースコード : frontend-sandboxes/practice-css-module-scripts at master · Neos21/frontend-sandboxes
<script> の書き方の違いによる動作の違いは、
- 外部 JS ファイルの読み込み
- JS コードの実行
のタイミングがいつになるのか、と、HTML パースを中断するかどうかが変わってくる。つまり src="" 属性を書かないインライン JS に対して async とか defer とかを書いた時は「JS ファイルの読み込み」に関しては関係してこないようだ。
簡単にまとめると以下のとおり。CSS Module Scripts に関連する type="module" (ESModules) に関してだけ言うと、コレは defer 属性を付与したのと同じ動きをする。
- 通常の
<script>(何も属性を書かない) は、その場で JS の読み込みと実行が入る。その間 HTML のパースは中断されるので、<script>の後に続く HTML はまだ存在しないテイになる- なので、
document.addEventListener('DOMContentLoaded')でラップした中に全処理を書くような書き方をしないと、DOM 操作が正常に行えない - この挙動は、コード内に
document.write()という同期的に HTML をインサートできる関数が存在していた時の名残りといえる
- なので、
<script async>は、その行が登場した瞬間から JS の読み込み (外部 JS ファイルのダウンロード) が始まるが、HTML のパース処理の裏で非同期に行われる。JS が読み込めるとその場で HTML パースが中断し JS の実行処理が挟まる- 複数の
<script async>が書かれていた場合、実行順序は HTML 中の書かれた順番とはならないことに注意 - JS の読み込みにどれだけ時間がかかるか次第であり、DOMContentLoaded イベントを待たない
- 複数の
<script defer>は、その行が登場した瞬間から JS の読み込みが始まり、HTML パースの裏で非同期に行われる点はasyncと同じ。しかし実行されるタイミングが異なり、DOMContentLoaded の直前に実行される- 複数の
<script defer>が書かれていた場合、実行順序は HTML 中の書かれた順番どおりになる - HTML のパースが完了した後に JS が実行されることが保証されるので、
<script defer>を HTML 中のどこに書いていても、HTML 中の全ての DOM 要素にアクセスできる document.addEventListener('DOMContentLoaded')でラップしてもしなくても同じように動作させられる
- 複数の
<script type="module">はdefer属性を書いたのと同じ扱いになる- つまり JS のロードは HTML パース中に非同期で行われるが、実行タイミングは常に DOMContentLoaded の直前からになるので、DOM 要素は全て存在する状態で ESModules が動き始めることになる
<script type="module" async>と書くこともできる。この場合、実行のタイミングが「JS のロードが完了したらすぐ」に変わるので、HTML パースの完了を待たないことになる- DOM 要素のパースを待たずに早く実行させたい ESModules って何だろう、あんまり使い所が想像できない…
<script async defer>と両方書いた場合は、ブラウザがasyncに対応していればasyncとして処理し、できなければdeferの挙動にフォールバックされる
「簡単にまとめると」とは書いたが、全然簡単じゃないよな…w
JS を書いて HTML DOM を操作したい、という一般的な用途で考えると、SPA のように全てが JS によって操作される場合なら、<script> か <script defer> を書いて、DOMContentLoaded 直前から JS を実行させるようにするのが良いだろう。
<script>の場合、「JS で組み立てた DOM を HTML 中の<div id="app">内に展開する」みたいな処理があるとすると、body要素の終了直前に書くと良いかな。head要素内に書いた場合は HTML パースが終わっていないために#appが見つからなくなるので、どこに書いても良いようにdocument.addEventListener('DOMContentLoaded')でラップするのは必須、としておくと安心だろうか<script defer>の場合も、deferを書き忘れたり、deferを認識しないような古いブラウザのことも考えるなら、初回処理はdocument.addEventListener('DOMContentLoaded')でラップしておけば安心かと
んで、ESModules のような新しい記法を使いたいのであれば <script type="module"> を使い、注意点は defer と同じ、と覚えておく。
最後に、<script async> は普段使わないから、覚えなくてよしw。「HTML パースを邪魔せず読み込ませつつ、即実行したい」という場面があるとすると、Google Analytics みたいにページの内容に関係なくバックグラウンドで動かしたいモノになるだろう。ウェブアプリみたいな想定だとイマイチ使いづらいというか、実行タイミングが保証されなくて安心できないと思う。w
つーワケで CSS Module Scripts から脱線したけど、JS だけで CSS まで上手い具合に扱えるようになったら、何か可能性広がりそうではあるなーと思って、まだ Chrome 系でしか動かない仕様を試してみた。以下、参考文献。
- CSS Module Scripts
async・defer・type="module"- script タグに async / defer を付けた場合のタイミング - Qiita
- Scriptタグの属性についてまとめ - Qiita
- async scripts, defer scripts, module scripts: explainer, comparison, and gotchas
- 【scriptの非同期読み込み】asyncとdeferの違いと使い方。bodyではなくheadで読み込んだ方が良い理由。 - ソロ学
- スクリプト: async, defer
- JavaScript モジュール - JavaScript | MDN
- クライアントサイド JavaScript の呼び出し方の変遷
- ブラウザに実装されている ECMAScript modules について | 69log
type="module"の Safari サポート状況 (crossorigin属性を書く必要があるとかあったけど、今回検証した CSS Module Scripts のロードには関係せず)