現代の主要なブラウザでは、ES Modules(以下、ESM)を利用することができる。
つまり、import
文やexport
文を使った JavaScript ファイルを、トランスパイルすることなくそのまま使えるということである。
モジュールシステムをそのまま使えるので、複数のファイルをバンドルする必要もない。
この記事ではまず、ブラウザで ESM を使う方法について説明していく。
その後、処理の流れを詳しく確認していく。これを理解していないと、パフォーマンスが非常に悪いページになってしまう恐れがある。
動作確認は Google Chrome の84.0.4147.105
で行っている。
ESM 利用の基本
まずは検証用にサーバを立てる。
以下のコードを Deno(バージョンは1.2.2
)で実行する。
そうすると、http://localhost:8080/
にアクセスしたときにindex.html
が表示されるので、その HTML ファイルから JavaScript ファイルを実行することにする。
import { listenAndServe, } from "https://deno.land/std@0.63.0/http/mod.ts"; listenAndServe( { port: 8080 }, async (req) => { if (req.method !== "GET") { req.respond({ status: 405, }); return; } switch (true) { case /^\/$/.test(req.url): req.respond({ status: 200, headers: new Headers({ "content-type": "text/html", }), body: await Deno.readFile("./index.html"), }); break; case /\.js$/.test(req.url): req.respond({ status: 200, headers: new Headers({ "content-type": "text/javascript", }), body: await Deno.readFile("." + req.url), }); break; default: req.respond({ status: 404, headers: new Headers({ "content-type": "text/plain", }), body: "Not found\n", }); break; } }, ); console.log("Server running on localhost:8080");
まず、HTML ファイルに直接import
を書く方法を試してみる。
インポートの対象となるsub.js
を用意する。
export const x = 1; export default 9;
そしてそれをindex.html
から読み込むのだが、そのためにはscript
要素にtype="module"
をつける必要がある。
<script type="module"> import {x} from '/sub.js'; console.log(x); // 1 </script>
type="module"
をつけないと、以下のようにUncaught SyntaxError: Cannot use import statement outside a module
というエラーがでる。
<script> // Uncaught SyntaxError: Cannot use import statement outside a module import {x} from '/sub.js'; </script>
但し、type="module"
をつけなくても Dynamic Import は使えるので、以下のコードは問題ない。
<script> import('/sub.js').then(res => {console.log(res.x)}); // 1 </script>
次に、src
属性で JavaScript ファイルを読み込み、そのファイルが別の JavaScript ファイルをインポートする方法を試す。
index.html
からindex.js
を読み込み、index.js
がsub.js
をインポートする。
これも問題なく動作する。
<script type="module" src="/index.js"></script>
// index.js import {x} from '/sub.js'; console.log(x); // 1
この場合も、type="module"
がないとUncaught SyntaxError: Cannot use import statement outside a module
というエラーになる。
Dynamic Import ならエラーにならないのも同じ。
パスとして URL を指定することもできる。当然、読み込まれる側のファイルが ESM に対応している必要がある。
// index.js import Vue from 'https://cdn.jsdelivr.net/npm/vue@2.6.11/dist/vue.esm.browser.js'; console.log(Vue.version); // 2.6.11
type="module" をつけた script タグの実行順序について
次に、script
タグの実行順序について見ていく。
type="module"
をつけないケースについては、以下の記事で説明した。
結論から言うと、type="module"
をつけるとdefer
と同じ挙動になる。
- HTML ファイルのパースを中断しない
- HTML ファイルのパースが終わるまで実行を待機する
type="module"
をつけたscript
タグが複数ある場合は、上から順番に実行する
これを検証するために、サーバの実装を以下のように書き換えた。
1.js
はリクエストから 1 秒後に返し、それ以外の JavaScript ファイルはリクエストから 3 秒後に返す。
case /1\.js$/.test(req.url): setTimeout(async () => { req.respond({ status: 200, headers: new Headers({ "content-type": "text/javascript", }), body: await Deno.readFile("." + req.url), }); }, 1000); break; case /\.js$/.test(req.url): setTimeout(async () => { req.respond({ status: 200, headers: new Headers({ "content-type": "text/javascript", }), body: await Deno.readFile("." + req.url), }); }, 3000); break;
そして、以下の内容のindex.html
と各 JavaScript ファイルを用意した。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> </head> <body> <script type="module" src="/1.js"></script> <script src="/2.js"></script> <p>content</p> </body> </html>
// 1.js console.log(1, window.document.querySelector('p'));
// 2.js console.log(2, window.document.querySelector('p'));
こうすると、ページにアクセスした直後は、何も表示されていない。
そして約 3 秒後にcontent
が表示されたあと、以下のログが流れる。
2 null 1 <p>content</p>
1.js
はページ読み込み後の 1 秒後にダウンロードが完了するが、HTML ファイルのパースが完了するまで実行しない。
そして、<script src="/2.js"></script>
によってパースが中断されるので、2.js
のダウンロードが終わるまで処理が止まる。
2.js
はダウンロードが終わり次第実行されるが、この時点ではまだp
要素の行までパースされていないので、p
要素を取得できない。
そして HTML ファイルのパースが終わった時点で、1.js
が実行される。この時点ではパースが全て終わっているので、p
要素を取得できる。
index.html
を以下のようにすると、約 3 秒後に2.js
、1.js
の順番で実行される。
<body> <script type="module" src="/2.js"></script> <script type="module" src="/1.js"></script> <p>content</p> </body>
1.js
のダウンロードが先に終わるが、script
タグの順番で実行されるため、まず2.js
が実行される。
また、HTML のパースを中断させる要素がないため、ページを読み込んだ時点でcontent
が表示されている。
async
属性をつけると、ダウンロードが完了次第、パースの完了を待たずに実行する。
そのため、以下の内容だと、ページ読み込みの約 1 秒後に1.js
が実行される。
パースが終わっていないため、p
要素を取得することはできずログにはnull
が表示される。
<body> <script async type="module" src="/1.js"></script> <script src="/2.js"></script> <p>content</p> </body>
以下の HTML ファイルにはパースを中断させる要素がないため、ページ読み込みと同時にcontent
が表示される。
<body> <script async type="module" src="/2.js"></script> <script async type="module" src="/1.js"></script> <p>content</p> </body>
そして、script
タグの順序とは無関係にダウンロードが終わったファイルから実行していくので、まず1.js
が実行され、その後2.js
が実行される。
全ての依存関係を解決してから実行する
ファイルの実行は、import
しているファイルを全てダウンロードし終わってから行われる。
以下の 3 つの JavaScript ファイルは、main -> a -> b
という依存関係を持っている。
// main.js import '/a.js'; console.log('main');
// a.js import '/b.js'; console.log('a');
// b.js console.log('b');
そして各ファイルのダウンロードには、それぞれ 3 秒かかる。
このときに以下のようにmain.js
を読み込むと、このファイルが実行されるまで 9 秒もかかる。
<script type="module" src="/main.js"></script>
ページを読み込んでもしばらくはログには何も表示されず、9 秒後に以下のログが流れる。
b a main
main.js
をダウンロードするのに 3 秒かかるが、ブラウザはその時点で初めて、main.js
がa.js
に依存していることを知る。
そのためa.js
のダウンロードを開始するが、また 3 秒かかる。そして今度はa.js
がb.js
に依存していることが分かるので、b.js
のダウンロードを開始する。
3 秒後にb.js
をダウンロードし終わると、それ以上は依存関係がないことが分かり、ファイルの実行を開始する。
そのため、実行まで 9 秒かかってしまうのである。
既述した通り、async
をつけていない場合、script
タグの順番で実行される。
そのため以下のように書くと、main.js
が実行されるまで1.js
の実行も待機されてしまう。
ページ読み込みの 1 秒後には1.js
のダウンロードは終わっているのにもかかわらず、である。
<script type="module" src="/main.js"></script> <script type="module" src="/1.js"></script> <p>content</p>
この記事の冒頭に書いたように、ブラウザでもimport
やexport
をそのまま使えるため、バンドルしなくても実行することができる。
しかし、依存関係が深い JavaScript ファイルの場合は、その解決に時間が掛かってしまうため、バンドルしてしまったほうがよいことも多い。
依存関係が重複している場合の挙動
main.js
を以下のように書き換える。
// main.js import '/a.js'; import '/b.js'; console.log('main');
こうすると、main.js
とa.js
の両方がb.js
に依存している状態になる。
このときにブラウザからmain.js
を読み込むと、ページ読み込みから 6 秒後に、以下のログが流れる。
b a main
これは、main.js
をダウンロードし終わったタイミングでa.js
とb.js
への依存が判明し、その時点で両者のダウンロードを開始するためである。
a.js
をダウンロードし終わるとa.js
もb.js
に依存していることが分かるが、その時点で既にb.js
のダウンロードも完了しているため、この段階でファイルを実行できる。
Dynamic Import は読み込まれた時点で依存関係の解決を開始する
再びmain.js
を書き換えて、以下のようにする。
import('/a.js'); console.log('main');
main -> a -> b
という依存関係だが、main.js
によるa.js
のインポートは Dynamic Import で行っている。
この状態でmain.js
をブラウザから読み込むと、3 秒後にログにmain
が表示される。
そして、ページ読み込みから 9 秒後に、以下のログが流れる。
b a
main.js
にはimport
文がないため、依存関係の解決を待たず、ダウンロードが終わり次第実行される。
そのため、3 秒後にmain
というログが表示された。
Dynamic Import でa.js
を読み込んでいたので、a.js
のダウンロードを行う。
累計で 6 秒後に、a.js
のダウンロードが終わる。a.js
の中身を見ると、import
文でb.js
を読み込んでいたので、a.js
の実行は待機してb.js
のダウンロードを開始する。
3 秒後、つまり累計で 9 秒後にb.js
のダウンロードが完了する。b.js
の中身を見ると依存関係はここで終わりなので、このタイミングでb.js
とa.js
が実行される。