このブログの更新は Twitterアカウント @m_hiyama で通知されます。
Follow @m_hiyama

メールでのご連絡は hiyama{at}chimaira{dot}org まで。

はじめてのメールはスパムと判定されることがあります。最初は、信頼されているドメインから差し障りのない文面を送っていただけると、スパムと判定されにくいと思います。

参照用 記事

JavaScriptモジュールは二回ロードされる可能性がある

ECMAScript Modules(ESM)方式のモジュールシステムを前提に、ブラウザ向けJavaScriptプログラムを書いていたら奇妙な現象に遭遇しました。同一のJavaScriptモジュールが、ブラウザ内に二回ロードされていたのが原因でした。

内容:

クラスの定義とクラスの利用

次のような名前の2つのファイルを作ります。

  1. fruits.mjs
  2. fruits_util.mjs

拡張子 .mjs は、ESM方式のJavaScriptファイルであることを明示しています。2つのファイルの内容は以下の通りです。

// fruits.mjs

export class Apple {
  constructor(name) {
    this.name = name;
  }
}

export class Orange {
  constructor(name) {
    this.name = name;
  }
}

export class Banana {
  constructor(name) {
    this.name = name;
  }
}
// fruits_util.mjs

import { Apple, Orange, Banana } from "./fruits.mjs";

export function stringifyFruit(fruit) {
  let s;
  if (fruit instanceof Apple) {
    s = "apple: ";
  } else if (fruit instanceof Orange) {
    s = "orange: ";
  } else if (fruit instanceof Banana) {
    s = "banana: ";
  } else {
    throw new Error(`${fruit} is not a fruit.`);
  }
  return s + fruit.name;
}

export function fruitSayHello(fruit) {
  let s = stringifyFruit(fruit);
  alert("Hello, from " + s);
}

*1

これらのJavaScriptモジュールを使うHTMLファイルも作りましょう。

<!-- index.html -->
<!DOCTYPE html>
<html>
  <head>
    <title>Fruits</title>
  </head>
  <body>
    <script type="module">
      import { Banana } from "./fruits.mjs";
      import { fruitSayHello } from "./fruits_util.mjs";

      let fruit = new Banana("バナちゃん");
      fruitSayHello(fruit);
    </script>
  </body>
</html>

最近は、ローカルのHTMLファイルをブラウザに読ませても、「クロスオリジン・リクエストだからダメだ」と言ってJavaScriptコードを実行してくれません。何らかのローカルサーバーを使ってHTMLファイル/JavaScriptファイルをHTTP通信でブラウザに送ってやる必要があります*2

ブラウザが index.html を読み込むとすぐさま「Hello, from banana: バナちゃん」というメッセージが出ます。

コンソールから使う

バナナのバナちゃん以外のフルーツにも挨拶をさせたいですね。HTMLのUIを作るのは面倒なので、ブラウザのコンソールから使うCLI(command line interface)を作りましょう。

コンソールのトップレベルは、JavaScriptのグローバルオブジェクトなので、グローバルオブジェクトの直下にモジュール名と同じ名前の名前空間オブジェクトを作って、クラスや関数をこの名前空間に収納して提供することにします。

// fruits.mjs

export class Apple {
  constructor(name) {
    this.name = name;
  }
}

export class Orange {
  constructor(name) {
    this.name = name;
  }
}

export class Banana {
  constructor(name) {
    this.name = name;
  }
}

window.fruits = {};
window.fruits.Apple = Apple;
window.fruits.Orange = Orange;
window.fruits.Banana = Banana;
// fruits_util.mjs

import { Apple, Orange, Banana } from "./fruits.mjs";

export function stringifyFruit(fruit) {
  let s;
  if (fruit instanceof Apple) {
    s = "apple: ";
  } else if (fruit instanceof Orange) {
    s = "orange: ";
  } else if (fruit instanceof Banana) {
    s = "banana: ";
  } else {
    throw new Error(`${fruit} is not a fruit.`);
  }
  return s + fruit.name;
}

export function fruitSayHello(fruit) {
  let s = stringifyFruit(fruit);
  alert("Hello, from " + s);
}

window.fruits_util = {};
window.fruits_util.stringifyFruit = stringifyFruit;
window.fruits_util.fruitSayHello = fruitSayHello;

index.html は先程と変わりません。しかし今度は、最初のメッセージが出た後、ブラウザのコンソールから次のようなことができます。

> fruits_util.fruitSayHello(new fruits.Orange("俺様"))
< undefined
> 

ブラウザのコンソールからコマンドライン入力すると、「Hello, from orange: 俺様」というメッセージが出ます。

モジュールコードのなかで、グローバルオブジェクトにクラスや関数を登録したので、コンソールからも使えるようになりました。

コンソールのコマンドラインから使えることが目的なら、最初に出るメッセージは不要なので、index.html を次のように書き換えてもいいでしょう。

<!-- index.html -->
<!DOCTYPE html>
<html>
  <head>
    <title>Fruits</title>
    <script type="module" src="./fruits_util.mjs"></script>
  </head>
  <body>
  </body>
</html>

scriptタグからは ./fruits_util.mjs しかロードしてませんが、./fruits_util.mjs 内のimport文により ./fruits.mjs もロードされます。2つのモジュールがロードされて、コンソールのCLIを提供します。ここまでは特に問題はありません。

scriptタグを追加してみる

ここからがトラブルの再現です。ただし、以下のとおりにやっても必ずトラブルが再現するとは断言できません*3。僕の環境(vite/3.2.2 win32-x64 node-v16.14.0, Google Chrome 107.0.5304.107, Windows 11 Version 21H2)ではトラブルが起きたので、「そんなこともあるぞ」と警告したいと思います。

index.html にscriptタグを追加します。

<!-- index.html -->
<!DOCTYPE html>
<html>
  <head>
    <title>Fruits</title>
    <script type="module" src="./fruits_util.mjs"></script>
    <script type="module" src="./fruits.mjs"></script>
  </head>
  <body>
  </body>
</html>

僕は、scriptタグを追加してもブラウザの挙動は変わらないだろう、と推測しました。なぜなら、最初のscriptタグを処理した段階で、モジュール ./fruits.mjs はロード済みなので、二番目のscriptタグでは何も起こらないだろうと思ったのです。

ところが、コンソールから先程と同じことをすると:

> fruits_util.fruitSayHello(new fruits.Orange("俺様"))
Uncaught Error: [object Object] is not a fruit.   fruits_util.mjs:14 
    at stringifyFruit (fruits_util.mjs:14:11)
    at Object.fruitSayHello (fruits_util.mjs:20:11)
    at :1:13
> 

どこで例外が発生したかと言うと、次の関数のコメントを付けた部分です。

export function stringifyFruit(fruit) {
  let s;
  if (fruit instanceof Apple) {
    s = "apple: ";
  } else if (fruit instanceof Orange) {
    s = "orange: ";
  } else if (fruit instanceof Banana) {
    s = "banana: ";
  } else {
    //
    // ここまで制御が到達して例外が投げられた
    //
    throw new Error(`${fruit} is not a fruit.`);
  }
  return s + fruit.name;
}

new fruits.Orange("俺様") で生成したオブジェクトはOrangeクラスのインスタンスなので、次のif文に捕まえられて、その下に制御が落ちていくはずはないですよね。

  } else if (fruit instanceof Orange) {

実は、ここの instanceof が期待通りに動いてないのです。いやっ、instanceof に罪はなくて、fruit instanceof Orange が真になるだろうという推測が事実とは違うのです。

Orangeクラスは二回作られた

JavaScriptのクラスの実体は関数オブジェクトです。関数オブジェクトは、JavaScriptコード内の functionclass の実行で作られます。Orangeクラスなら、次のコードが実行されたタイミングで作られます。

export class Orange {
  constructor(name) {
    this.name = name;
  }
}

上記コードはモジュールファイル fruits.mjs 内にあるので、モジュールロード(コードの実行も含む)のとき、Orangeクラスが作られます。ファイル fruits.mjs に対するモジュールロードが一回だけなら、Orangeクラスも一個だけ作られるはずです。が、実際には二個作られたのです。

scriptタグ <script type="module" src="./fruits_util.mjs"> はファイル fruits_util.mjs をモジュールとしてロードしますが、fruits_util.mjs 内のimport文によりファイル fruits.mjs が芋づる式にロードされます。このタイミングでOrangeクラスが作られます。これが一個目。

次のif文は、fruit一個目の Orange のインスタンスであるかどうかを調べる条件文です。

  } else if (fruit instanceof Orange) {

さて、予想外だったのですが; 二番目のscripタグ <script type="module" src="./fruits.mjs"> は実行されるようです。その結果、二個目のOrangeクラスが生成されwindow.fruits.Orange にセットされます。

コンソールCLIから見えているOrangeクラス window.fruits.Orange は二個目のヤツです。一方で、くだんの instanceof は一個目のOrangeのインスタンスかどうかを調べます。二個目のOrangeで生成したインスタンスが一個目のOrangeのインスタンスかどうか? 答は‥‥ そう NO ですよね。したがって、if文で制御が止まらず例外発生に至ったのです。

scriptとimportは別扱い

僕は当初、scriptタグでロードされたモジュールも、import文でロードされたモジュールも、まとめて統合的に管理されていると思っていました。そうじゃないんですね。「import文は言語機能で、scriptタグは言語処理系をホストしている環境の機能だから別だ」と言われれば、「まー、そうかもな」という気もしますが、なーんか話がややこしい。

同じモジュールが二回以上ロードされてしまうと、ろくでもないことが起こります。モジュールシステムってのは、そんなことが起きないようにするもんだと思うんだけど、事実として、重複したロードにより“異なる実体を持つ同じクラス”ができちゃいます。ご注意ください。

*1:画像は https://www.thestar.com/life/food_wine/2013/11/04/apples_oranges_or_bananas_which_fruit_is_nutritionally_the_best.html より。

*2:僕は Vite を使っています。

*3:HTMLとJavaScriptの仕様と、ブラウザごとの実装状況を調べたわけではないので、何とも言えません。しかし最近では、ブラウザの挙動は割と共通なので、特殊事例ではなくて一般的な現象だと思います。