script要素にtype="module"を付ける利点 第2回 モジュールにするだけで得られるメリット
type="module"の利点は、ファイルを分割できることだけではありません。変数の衝突が起きない、DOMContentLoadedを待たなくてよい、strict modeになる、トップレベルでawaitが書けるなど、importを1行も書かなくても得られる特徴を整理します。
- カテゴリー
- JavaScript/TypeScript >
- JavaScriptの設計
発行
前回まで
前回は、script要素にtype="module"を指定するとimport/exportでファイルを分割できること、exportした機能のうち必要なものだけを取り出せること、import()で必要になった時点で読み込めることを見てきました。
今回は、importやexportを1行も書かなくても効いてくる特徴を扱います。変数のスコープ、実行のタイミング、CORS、strict mode、トップレベルのawaitと、順番に見ていきましょう。
グローバル変数が作られない
まずはスコープの話です。スコープとは、宣言した変数や関数が「どこから使えるか」という範囲のことです。たとえば関数の中で宣言した変数は、その関数の中でしか使えません。
従来のscript要素では、関数にもブロックにも入っていない、いちばん外側に書いた宣言(トップレベルの宣言)はグローバルスコープに入ります。ここはページ全体で共有される場所なので、ほかのscript要素からも使えてしまいます。
従来のscript要素では宣言が共有される
<script>
const a = 1;
</script>
<script>
console.log( a ); // 1と表示される
</script>
<script>
const a = 2; // エラー:`a`はすでに宣言されている
</script>
別々のscript要素なのに、2つ目から1つ目のaが使えていますね。そして3つ目のように、同じ名前をもう一度宣言することはできません。constやletでの宣言が、script要素をまたいで1つの場所にまとめられているからです。ライブラリやほかのスクリプトと名前が被ってしまう危険もあります。
moduleを指定すると、script要素ごとに独立したモジュールスコープが適用されます。
moduleではそれぞれ別のスコープになる
<script type="module">
const a = 1;
</script>
<script type="module">
console.log( a ); // エラー:aが見つからない
</script>
<script type="module">
const a = 2; // エラーにならない
</script>
同じ並びなのに、違う結果になります。2つ目からは1つ目のaが使えず、3つ目は同じ名前を宣言してもぶつかりません。モジュールの中で宣言したものは、そのモジュールの中だけのものなのです。
かつては、グローバルを汚さないために即時実行関数(IIFE)でファイル全体を囲む、という定番のテクニックがありました。モジュールではそれが言語の仕組みとして最初から用意されている、と考えるとわかりやすいでしょう。
そして、モジュールスコープの外に値を渡す手段はexportが基本になります。「どこかのファイルがこっそりグローバル変数を書き換えていた」という状況が起こりにくく、依存関係がコードの上に現れるようになりますね。
IIFEとは
IIFE(Immediately Invoked Function Expression、即時実行関数式)は、「その場で作って、その場で呼び出す関数」のことです。「イッフィー」と読みます。
IIFEでファイル全体を囲む
( function () {
const a = 1; // この関数の中だけの変数。グローバルスコープには入らない
} )();
console.log( a ); // エラー:aが見つからない
やっていることは、関数を作ってすぐ呼ぶ、それだけです。関数の中はその関数だけのスコープになるので、中で宣言したものはグローバルスコープに入りません。ほかのファイルに渡したいものだけ、window.myApp = ...のように自分で外へ出す、というのが当時の定番でした。
ファイル全体をこれで囲む作法は、ライブラリのソースを開くとよく見かけます。モジュールなら、囲む手間そのものが要らなくなるわけです。
HTMLのパースが完了するまで実行されない
従来のscript要素は、書いた場所でそのまま実行されます。head要素の中に書けば、body要素がまだ組み上がっていないうちに走ってしまいます。ですからDOMを触るコードは、HTMLのパースが終わるのを待つ必要がありました。
DOMの完成を待つ従来の書き方
document.addEventListener( 'DOMContentLoaded', function () {
// ここにコードを書く
} );
jQueryを使った経験があるなら、こちらのほうが馴染み深いかもしれません。やっていることは同じで、DOMの完成を待ってから中身を実行するための書き方です。
jQueryでDOMの完成を待つ
$( function () {
// ここにコードを書く
} );
この待ち合わせが、type="module"では要らなくなります。読み込みと実行のタイミングを、属性の組み合わせで整理してみましょう。
| 書き方 | ファイルの取得 | 実行タイミング |
|---|---|---|
<script src> |
パースを止めて取得 | 取得次第すぐ(パースは止まったまま) |
<script defer src> |
パースと並行して取得 | パース完了後、記述順 |
<script async src> |
パースと並行して取得 | 取得次第すぐ(順不同) |
<script type="module" src> |
パースと並行して取得 | パース完了後、記述順 |
<script type="module" async src> |
パースと並行して取得 | 取得次第すぐ(順不同) |
表のとおり、type="module"のscript要素は、deferを指定したときと同じタイミングで実行されます。defer属性を書き足しても意味はありません。急いで実行したい場合だけasyncを指定する、という関係です。
さらに、インラインで書いた場合も同じです。従来のscript要素は、インラインならその場で実行されますが、モジュールはインラインでもパース完了後まで待ちます。
DOMの完成を待つ必要がない
<script type="module">
// 実行される時点でHTMLのパースは完了済み
const app = document.getElementById( 'app' );
const footer = document.getElementById( 'footer' );
</script>
<div id="app"></div>
<div id="footer"></div>
script要素より後ろに書かれた#appも#footerも、問題なく取得できます。これは、実行されるのがパースの完了後だからです。DOMContentLoadedを書かずに済むわけです。
裏を返すと、描画より前に何かをしたい処理には向きません。たとえば、ちらつきを防ぐためにテーマのクラスをhtml要素に付ける、といった処理はモジュールでは間に合いません。そうした処理は、従来どおりのscript要素で書く必要があります。
document.write()でHTMLは差し込めない
パースが終わってから実行されるため、パースの途中にHTMLを差し込むことを前提にした機能は使えません。代表がdocument.write()です。
moduleでのdocument.write()
<body>
<script type="module">
document.open();
document.write( 'ここにヘッダーや臨時お知らせを配置したい' );
document.close();
</script>
コンテンツ
</body>
document.write()は、パース中のHTMLにその場で文字列を差し込む機能です。モジュールが実行される時点でパースは終わっているので、差し込む先がありません。上の例のdocument.open()は、その差し込み先を新しく開き直すメソッドです。開き直した時点でそれまでの中身は捨てられるので、「コンテンツ」のテキストは消え、書いた文字列だけのページになってしまいます。
なお、document.open()を省略すると、モジュールの中のdocument.write()は何も起こさずに終わります。ページは壊れませんが、書いた文字列もどこにも出ません。
これはtype="module"に限った話ではなく、deferやasyncを付けた従来のscript要素でも同じです。ページに要素を足したいなら、createElement()やinsertAdjacentHTML()を使いましょう。
CORSが適用される
モジュールとして読み込まれるJavaScriptは、常にCORSの仕組みを通して取得されます。ここも従来のscript要素との大きな違いです。
CORS(Cross-Origin Resource Sharing)は、別のオリジンにあるファイルの取得を、配信側が許可したときだけブラウザが通す仕組みです。オリジンとはhttps://example.comのようなスキーム・ドメイン・ポートの組み合わせで、どれか1つでも違えば別のオリジンと見なされます。
従来のscript要素は、別のオリジンにあるJavaScriptでも特別な設定なしに読み込めました。一方、モジュールを別のオリジンから読み込む場合は、配信側のサーバーがAccess-Control-Allow-Originヘッダーを返している必要があります。ヘッダーがなければ、読み込みは失敗します。
次回に出てくるCDNはいずれもこのヘッダーを返すので、そのままimportできます。自前のサーバーに置いたJavaScriptを別のオリジンから読み込む場合は、サーバー側の設定を確認しましょう。
ローカルファイルを直接開くと動かない
もう1つ、実作業でつまずきやすいのが、ローカルファイルを直接開いても動かないことです。HTMLファイルをダブルクリックして開くと、URLはfile://になります。file://ではオリジンが特定できないため、前述のCORS制限により読み込みが弾かれてしまいます。
対策はシンプルで、ローカルサーバーを立ててhttp://で開くことです。
ローカルサーバーを立てる
npx serve
エディタの拡張機能(VS CodeのLive Serverなど)を使うのも手軽です。ES Modulesを試すときは、まずローカルサーバーを用意する、と覚えておきましょう。
補足:VS CodeのLive Server
Live Serverは、VS Codeにローカルサーバーの機能を追加する拡張機能です。HTMLファイルを開いて画面右下の「Go Live」をクリックするだけでhttp://127.0.0.1:5500が立ち上がり、保存するたびに自動で再読み込みされます。
strict modeで実行される
strict modeは、JavaScriptの「厳しめの実行モード」で、うっかりミスを見逃さずにエラーとして知らせてくれる、いわば安全装置です。従来のscript要素で有効にするには、先頭に'use strict';と書く必要がありました。
従来は自分で宣言する
'use strict';
// ここからstrict modeになる
モジュールでは、この宣言が要りません。常にstrict modeで実行されます。
たとえば、constやletを書き忘れてundeclared = 1;としてしまったとしましょう。strict modeでなければ、これはグローバル変数を作る指示として黙って通ってしまいます。タイプミスが原因不明の不具合につながりやすい、よくある落とし穴です。モジュールなら、その場でエラーとして知らせてくれます。
moduleでは書かなくても有効
undeclared = 1; // エラー:undeclaredが定義されていない
strict modeでは、主に次のような挙動になります。
- 宣言していない変数への代入がエラーになる
- 関数を単独で呼び出したときの
thisがundefinedになる(windowではない) - 引数名の重複や8進数リテラルなど、紛らわしい構文が使えなくなる
with文が使えなくなる
いずれも、うっかりミスを早い段階でエラーとして知らせてくれるものです。古いコードをモジュールに移すときは、この段階でエラーが表面化することがあります。
トップレベルでawaitが使える
モジュールでは、async functionの中でなくてもawaitが書けます。トップレベルawaitと呼ばれる機能で、モジュール専用です。
config.js:設定を読み込んでからexportする
const response = await fetch( './config.json' );
const config = await response.json();
export default config;
このモジュールをimportした側は、fetch()が完了するまで待ってから実行されます。初期化に非同期処理が必要なとき、呼び出し側で待ち合わせのコードを書かずに済むのは快適です。
ただし、待っている間はそのモジュールに依存するモジュールの実行も止まります。ページの初期化がまるごと遅れてしまうこともあるので、使いどころは選びましょう。
補足:トップレベルawaitのブラウザサポート
トップレベルawaitは、まだBaselineに達していません。Safariに「トップレベルawaitを含むモジュールを、複数のモジュールから同時にimportすると動かない」という制限があるためで、Safari 27で解消されます。設定ファイルのように、あちこちから読み込まれるモジュールで使うときは気をつけてください。
importを1つも書かなくても得がある
type="module"というと、まず思い浮かぶのはimportでしょう。ですが、ここまで見てきたとおり利点はそれだけではありませんでした。
- 変数はそのscript要素の中に閉じこもり、ほかのスクリプトとぶつからない
- DOMの完成を待ってから実行されるので、
DOMContentLoadedが要らない - strict modeが自動で有効になり、うっかりミスがエラーとして表に出る
- トップレベルで
awaitが書ける
これらは、ファイルを分割する予定がなくても、type="module"と書くだけで有効になります。
ここまでのまとめ
今回は、importやexportを書かなくても効いてくる特徴を、スコープ、実行のタイミング、CORS、strict mode、トップレベルのawaitと順に見てきました。
次回は、importをもう一度使う話に戻ります。ESM対応のCDNからライブラリを直接読み込む書き方と、import mapでパッケージ名とURLを対応づける方法を扱います。npmのインストールもビルドの設定もしないまま、HTMLとJavaScriptだけでライブラリを使えるようになります。最後に、それでもバンドラーが必要になる場面も整理します。