現代の主要なブラウザでは、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が実行される。