30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

ブラウザにおける ES Modules の利用とパフォーマンスについて

現代の主要なブラウザでは、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.jssub.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"をつけないケースについては、以下の記事で説明した。

numb86-tech.hatenablog.com

結論から言うと、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.js1.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.jsa.jsに依存していることを知る。
そのためa.jsのダウンロードを開始するが、また 3 秒かかる。そして今度はa.jsb.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>

この記事の冒頭に書いたように、ブラウザでもimportexportをそのまま使えるため、バンドルしなくても実行することができる。
しかし、依存関係が深い JavaScript ファイルの場合は、その解決に時間が掛かってしまうため、バンドルしてしまったほうがよいことも多い。

依存関係が重複している場合の挙動

main.jsを以下のように書き換える。

// main.js
import '/a.js';
import '/b.js';

console.log('main');

こうすると、main.jsa.jsの両方がb.jsに依存している状態になる。

このときにブラウザからmain.jsを読み込むと、ページ読み込みから 6 秒後に、以下のログが流れる。

b
a
main

これは、main.jsをダウンロードし終わったタイミングでa.jsb.jsへの依存が判明し、その時点で両者のダウンロードを開始するためである。
a.jsをダウンロードし終わるとa.jsb.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.jsa.jsが実行される。

参考資料