今時のJavaScript開発において、JavaScriptが持つモジュールの機能は欠かすことができません。我々はプログラムをいくつものファイル(モジュール)に分割し、import文とexport文を使ってそれらを繋げています。各モジュールはexport文を用いてそのモジュール内で定義した変数・関数などをエクスポートすることができ、別のモジュールがimport文でそれらの値を取得することができるのです。
皆さんは、このimport・export文がどのように働いているのか正確に説明できるでしょうか。実は、import文やexport文というのは値をインポート・エクスポートしているのではなく、言わば変数そのものをインポート・エクスポートしているのです。これを理解するのがこの記事のゴールです。
※ 本当は変数ではなく「バインディング」といったほうが用語としてより正確なのですが、この記事では分かりやすさのために変数という用語を使用しています。
export文は変数をエクスポートする
モジュールから何かをエクスポートするときに使うのがexport文ですが、これにはいくつかの種類があります。実は、export文は必ず変数をエクスポートしています。
最も代表的なexport const foo = ...;という構文は、const宣言により変数fooを作ると同時にそれをエクスポートしています。export let bar = ...のようにconst以外を使うことも可能です。それどころか、export const {foo, bar} = obj;のように分割代入で作られる変数をエクスポートすることもできます(この場合はfooとbarがエクスポートされます)。
ほかにexport function 関数名() { }という関数宣言の形もよく使われます。この場合も、関数宣言を通してその関数が入った変数が作られています。
変数に外向きの名前をつけてエクスポートする
また、export { foo, bar }という構文は、事前に定義された変数foo、barをエクスポートするという意味ですから、やはり変数をエクスポートしています。この構文の特徴はexport { foo as foo2 };のように違う名前でエクスポートする機能を持っている点です。これにより「モジュールの中ではfooと呼ばれている変数を、外向きにはfoo2という名前でエクスポートする」ということが可能です。このように、モジュールからエクスポートされている変数は「内向きの名前」と「外向きの名前」を持ちます。export const foo = ...のような宣言の場合は内向きの名前と外向きの名前が同じで、どちらもfooです。
defaultエクスポートの扱い
ところで、export default 値;という構文では変数をエクスポートしていないように見えますね。しかし、実はこれは内向きには*default*という名前の変数を作成し、外向きにdefaultという名前でエクスポートしています。ここで作られた*default*という名の暗黙の変数は、そんな変な名前の変数にアクセスする構文的な手段が存在しないため、モジュール内からアクセスすることはできません。
defaultエクスポートはdefaultという名前でエクスポートする機能です。逆に言えば、export default以外の方法でもdefaultという名前でエクスポートすればdefaultエクスポートと同様の挙動をするということです。次のa.mjsとindex.mjsを用意してindex.mjsを実行すれば、コンソールにsomething is 123と表示されるでしょう。
////////// a.mjs
const bar = 123;
export {
bar as default
};
////////// index.mjs
import something from "./a.mjs";
console.log("something is", something);
このように、export defaultといったdefaultエクスポートの構文は実は「defaultという名前をつけてエクスポートする」という行為を短く書くだけの構文なのです。
ちなみに、「変数をdefaultという名前でエクスポートする」というのはこの例のようにas defaultを使わないとできません。defaultは予約語であり、export const default = ...のような気がしてやり方だと構文エラーとなってしまうのです。うまくできていますね。
再エクスポートの構文
export文は再エクスポートの機能も持っています。まず、export * from "module"はモジュールからエクスポートされた変数を同じ名前でエクスポートします。面白いのは、この構文は自身のスコープ内にその変数を作らないということです。下の例ではb.mjsはfooという名前で変数をエクスポートしています。a.mjs内のexport * from "./b.mjs";はb.mjsからエクスポートされているfooを同じfooという名前で再エクスポートするという働きをします。言い換えれば、a.mjsは「b.mjsのfoo」をfooという外向きの名前でエクスポートしているのです。
再エクスポートが普通のエクスポートと決定的に違う点は、「自身のスコープの変数」をエクスポートするのではなく「他のモジュールの変数」をエクスポートしているという点です。これはつまり、再エクスポートは自身のスコープに何の影響も与えないということです。a.mjsの中でfooという変数が宣言されていたとしても、再エクスポートされているfooとはまったく無関係です。
////////// b.mjs
export const foo = 123;
////////// a.mjs
// b.mjsのfooを再エクスポート
export * from "./b.mjs";
// この変数fooはb.mjsがエクスポートするfooとは無関係
const foo = 0;
console.log(foo);
////////// index.mjs
import { foo } from "./a.mjs";
// このfooはb.mjsのfooなので123が表示される
console.log(foo);
この例を見ると、a.mjs内のconsole.log(foo)は0を表示します。これは、a.mjsのスコープにあるfooはそのモジュール内で宣言されている変数fooだからです。一方、index.mjs内のconsole.log(foo)は123を表示します。これは、このfooがa.mjsからインポートしたfooであり、a.mjsがfooという名前でエクスポートしているのは「b.mjsがエクスポートするfoo」だからです。
ちなみに、a.mjsの中でexport const foo = 0のように書いた場合はexport *の方よりも優先されてこちらがfooとしてエクスポートされます。
他にexport * as ns from "module"構文やexport { foo, bar } from "module"構文が再エクスポートを行いますが、これらも現在のモジュールのスコープ内には影響を与えません。
また、defaultという名前でエクスポートされている変数はexport * from "module"構文で再エクスポートされません。defaultエクスポートを再エクスポートしたければexport { default } from "module"という方法が有効です。
import文は変数をインポートする
export文が変数をエクスポートするなら、import文がインポートするのも当然変数です。そのことがたいへんよく分かる例がこれです。
////////// a.mjs
export let foo = 0;
export const setFoo = (value) => {
foo = value;
}
////////// index.mjs
import { foo, setFoo } from "./a.mjs";
// a.mjs内のfooは0なので0が表示される
console.log(foo);
// a.mjs内のfooが100になる
setFoo(100);
// a.mjs内のfooは100なので100が表示される
console.log(foo);
この例ではa.mjsが変数fooをエクスポートし、index.mjsがインポートしています。すると、index.mjsのスコープに存在するfooはa.mjsに存在するfooと同じになります。より正確には、index.mjsに存在する変数fooは、「参照されるとa.mjsの変数fooの中身を返す変数」となります。これが意味することは、index.mjsの変数fooの値は常にa.mjsの変数fooの値と同じであるということです。
このことは、a.mjsが提供するsetFoo関数を用いてa.mjs内の変数fooを書き換えると分かります。setFooの呼び出し後は、index.mjsの変数fooの中身が勝手に変わっています。これはもちろん、a.mjsの変数fooの中身が変わったからです。
ここで重要なのは、インポートは値のコピーではないということです。あくまで変数そのものをインポートしているのであり、だからこそ、インポート後に元の変数の値が変わっても追随できるのです。言い方を変えれば、インポートはモジュール間で変数のエイリアスを作る機能であるとも言えます。上の例では、index.mjs内の変数fooはa.mjs内の変数fooのエイリアスであるとの見方もできますね。JavaScriptのモジュール間連携とは、モジュールの間に張られたエイリアスによって成り立つものなのです。
ただし、変数に再代入できるのはその変数を所有するオリジナルのモジュールだけです。index.mjsでfoo = 123;のようにしてa.mjs内の変数fooを書き換えることはできません(ランタイムエラーになります)。インポートされた変数は読み取り専用のエイリアスなのです1。
一応、インポートと対比して、明示的に値をコピーする例も用意しておきます(a.mjsの中身は同じなので省略)。
////////// index.mjs
import { foo, setFoo } from "./a.mjs";
// これを実行した時点でのfooの値をmyFooに代入
const myFoo = foo;
// 0が表示される
console.log(myFoo);
// a.mjs内のfooが100になる
setFoo(100);
// myFooは0のまま
console.log(myFoo);
こうした場合、当然ながら変数myFooには「代入を実行した時点でのfooの値」が入ります。変数myFooは変数fooとは無関係ですから、fooがどう変化してもmyFooの値は変化しません。
この例と対比することでも、「import { foo } from "./a.mjs";」が「const foo = (a.mjsの変数fooの値);」のような意味ではないことがお分かりになるでしょう。
モジュール名前空間オブジェクト
import文にはimport * as mod from "module";のような構文もあります。これは、モジュールからエクスポートされている変数を全部まとめてオブジェクトにしてインポートするという意味です。この構文によって得られるオブジェクトがモジュール名前空間オブジェクトです。長いので以降は名前空間オブジェクトと呼びます。
名前空間オブジェクトが持つ各プロパティは、インポート元の変数の値を常に反映します。別の言い方をすれば、名前空間オブジェクトのプロパティがインポート元の変数のエイリアスになっていると言えます。先ほどのsetFooの例を少し書き換えることでこれを確かめましょう。
////////// a.mjs
export let foo = 0;
export const setFoo = (value) => {
foo = value;
}
////////// index.mjs
import * as a from "./a.mjs";
// 0 が表示される
console.log(a.foo);
a.setFoo(100);
// 100 が表示される
console.log(a.foo);
index.mjs内の変数aにa.mjsの名前空間オブジェクトが入っています。結果から分かるように、a.fooの値はa.mjs内の変数fooの値を常に反映しています。
名前空間オブジェクトはこの点で特別なオブジェクトです。例えば、次のようにしても再現できません。
import { foo, setFoo } from "./a.mjs";
// これは名前空間オブジェクトの挙動にならない
const a = { foo, setFoo };
なぜなら、このように作ったオブジェクトはa.fooが「aを作った瞬間の変数fooの値」になり、変数fooの変化に追随しないからです。先ほどの説明の通りこのようにインポートした変数fooはインポート元のfooのエイリアスですが、それは変数自体の性質であり、「変数fooを評価して得た値」は何の変哲のないただの値でしかありません。そのただの値をaのプロパティに入れても、名前空間オブジェクトのような挙動にはならないのです。
ちなみに、defaultエクスポートは「defaultという名前でエクスポートされている変数」だったので、名前空間オブジェクトのdefaultプロパティとして取得できます。
また、dynamic import(import("./a.mjs"))の結果として得られるのもやはり名前空間オブジェクトです。よって、index.mjsを次のように書き換えても同じ結果となります。
import("./a.mjs").then(a => {
// 0 が表示される
console.log(a.foo);
a.setFoo(100);
// 100 が表示される
console.log(a.foo);
})
なぜ値ではなく変数をエクスポートするのか
ここまで、JavaScriptのモジュールは変数をエクスポートしているのだということを解説しました。しかし、なぜそのような挙動になっているのでしょうか。値をエクスポートした方が単純で分かりやすいような気がします。
その答えは、ECMAScriptの仕様書がホストされているGitHubリポジトリにひっそりと置かれているFAQ.mdというファイルにわざわざ書かれています。ちなみに、これは自慢ですが、筆者はECMAScript仕様書にプルリクエストを送ってマージされたことがあります。
このFAQ.mdには、この挙動の理由について次のように書かれています。
The biggest reason for this is that it allows cyclic module dependencies to work.
つまり、循環参照があるようなプログラムでも動くようにするためというのが最大の理由です。
循環参照の問題点
まず、そもそも循環参照の何が問題なのかを考えてみましょう。これまで見てきたプログラム例は循環参照がありませんでしたが、その場合モジュールは依存されている側から順番に実行されていました。つまり、index.mjs→a.mjs→b.mjsという依存関係がある場合、まずb.mjsが実行され、次にa.mjsが実行され、index.mjsが実行されました。この実行順序は、import文でインポートした変数には最初から値が入っているということを保証するためのものです。先ほどの例を再掲します。
////////// a.mjs
export let foo = 0;
export const setFoo = (value) => {
foo = value;
}
////////// index.mjs
import { foo, setFoo } from "./a.mjs";
// a.mjs内のfooは0なので0が表示される
console.log(foo);
// a.mjs内のfooが100になる
setFoo(100);
// a.mjs内のfooは100なので100が表示される
console.log(foo);
この例では、a.mjsが先に実行されて、そのあとindex.mjsが実行されます。これにより、index.mjsの最初のconsole.log(foo);を実行した時点でfooにはすでに0という値が入っています。ここでfooに0が入っている理由は、index.mjsよりも先にa.mjsが実行され、a.mjsの中のexport let foo = 0;が実行されることでa.mjsの変数fooに0が代入されたからなのです。
もしa.mjsよりも先にindex.mjsが実行されていたら、console.log(foo)の時点でfooにはまだ何も入っていないことになってしまいます。
このように、インポートした変数がすぐ使えることを保証するするために、依存されている側から先に実行するという実行順序になっています。
しかし、循環参照がある場合はこの保証ができなくなります。循環参照とは、例えばa.mjsがb.mjsをインポートし、b.mjsもa.mjsをインポートしているというような状態を指します。この場合、どちらを先に実行しても問題が発生してしまいますね。a.mjsを先に実行すれば、a.mjsはb.mjsをインポートしているのにb.mjsよりa.mjsが先に実行されてしまいます。逆でも同じ問題が起きます。
この問題は、実は根本的にはどうしようもありません。JavaScriptでモジュール間の循環参照があった場合、前述の保証は諦めて一定の順番でモジュールを実行します。試しに、循環参照が原因のエラーを発生させてみましょう。
////////// b.mjs
import { varFromA } from "./a.mjs";
export const varFromB = "b";
console.log("varFromA is", varFromA);
////////// a.mjs
import { varFromB } from "./b.mjs";
export const varFromA = "a";
console.log("varFromB is", varFromB);
////////// index.mjs
import "./a.mjs";
この例では、a.mjsとb.mjsが循環参照しています。それぞれが変数をエクスポートし、互いにインポートした変数を利用しています。これを実行すると、次のようなエラーが発生します(Node.js v13.8.0で確認)。
console.log("varFromA is", varFromA);
^
ReferenceError: Cannot access 'varFromA' before initialization
つまり、b.mjsの実行時に、まだ初期化されていない変数varFromAを読もうとしたことによるエラーです。
これは、a.mjsよりも先にb.mjsが実行されたことが原因です。b.mjsはa.mjsからインポートした変数varFromAを評価しましたが、まだa.mjs内でexport const varFromA = "a";が実行されていないため、変数varFromAはまだ初期化されていない変数となっているのです。まだ初期化されていない変数はアクセスすることができません。
ちなみに、「初期化されていない変数」は循環参照に特有の現象ではありません。1ファイル内でも、変数宣言よりも前に変数にアクセスすると同じエラーになります。このことからも先のvarFromAがa.mjs内のvarFromAに対するエイリアスであることが分かります。
// ReferenceError: Cannot access 'foo' before initialization
console.log(foo);
const foo = 123;
なお、変数がまだ初期化されていない区間はTemporal Dead Zone (TDZ) と呼ばれています。letやconstで宣言された変数はその宣言が評価された際に初期化されるためTDZが存在しますが、varで宣言された変数は最初からundefinedに初期化されているため、TDZが存在しません。
循環参照でもエラーが発生しない場合
本題に戻ると、ここで重要なのはTDZにある変数をインポートするだけではエラーにならないということです。import文により変数のエイリアスができても、それはまだTDZにある変数へのエイリアスを作ったというだけです。そのエイリアスを通じて実際にアクセスしなければエラーは起きないのです。
つまり、TDZにある変数にアクセスしなければ、モジュールが循環参照していてもエラーは起きないということになります。そして、実際のところ、モジュールが循環参照しているがTDZにある変数にアクセスしないという例は結構あります。最も典型的なのは、モジュールが関数だけエクスポートしている場合です。先ほどのFAQ.mdに載っている例を引用します。
////////// Even.js
import {isOdd} from "./Odd.js";
export function isEven(num) {
if (num === 0) {
return true;
} else {
return isOdd(num - 1);
}
}
////////// Odd.js
import {isEven} from "./Even.js";
export function isOdd(num) {
if (num === 0) {
return false;
} else {
return isEven(num - 1);
}
}
////////// main.js
import {isOdd} from "./Odd";
isOdd(2);
この例では、Even.jsとOdd.jsが循環参照しています。それぞれ、isEvenとisOddという関数をエクスポートしています。また、お互いにお互いがエクスポートしている関数をインポートし、相互再帰の形で利用しています。
実は、これは循環参照があるにも関わらずエラーが発生しません。その理由は、Even.jsやOdd.jsが実行された瞬間はそれぞれ関数をひとつ定義されているだけであり、インポートした変数をすぐに参照するわけではないからです。実際にこれらの関数が実行されるのはmain.jsの中でisOdd(2)が実行されたタイミングです。main.jsは、Even.jsとOdd.jsの実行が終わってから実行されます。つまり、isOdd(2)が実行されるタイミングではすでにisOddもisEvenも定義済みである(TDZを抜けている)ということです。これにより、isOddはその中でisEvenを呼び出すことができ、またisEvenもisOddを呼び出すことができます。この用意して、循環参照がある状態で関数を定義することができました。
これは、変数がエイリアスとしてインポートされているからこそ実現できることです。この例ではEven.js→Odd.js→main.jsの順にモジュールが実行されるので、Even.jsが実行された(isEvenが初期化された)段階ではまだisOddは(これはOdd.jsがエクスポートしているisOddのエイリアスなので)TDZにあります。しかし、isOddはisEvenの中身から参照されており、この段階でisOddを評価することはないのでTDZによるエラーは起こりません。次にOdd.jsが実行されたタイミングでisOddが初期化されると、当然ながらEven.jsから見えるisOddも初期化済みになります。このように変数がエイリアスされていることで、インポートした変数が後から初期化されるのでOKというパターンが生まれるのです。
バンドラによるインポート・エクスポートの扱い
現在のフロントエンド開発では、モジュールを駆使して書かれたプログラムはWebpackに代表されるバンドラによって処理し、インポート・エクスポートの無い単一のプログラムに変換してから実行されます2。つまり、ここまで説明してきたインポート・エクスポートの挙動を実際に処理しているのはバンドラだということです。
ということで、先ほどから出てきているこの例をWebpackでバンドルしたものを見てみましょう。
////////// a.mjs
export let foo = 0;
export const setFoo = (value) => {
foo = value;
}
////////// index.mjs
import { foo, setFoo } from "./a.mjs";
// a.mjs内のfooは0なので0が表示される
console.log(foo);
// a.mjs内のfooが100になる
setFoo(100);
// a.mjs内のfooは100なので100が表示される
console.log(foo);
これをwebpack --mode noneでバンドルしたものからindex.mjsに相当する部分を抜き出すとこのようになります。
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _a_mjs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1);
// a.mjs内のfooは0なので0が表示される
console.log(_a_mjs__WEBPACK_IMPORTED_MODULE_0__["foo"]);
// a.mjs内のfooが100になる
Object(_a_mjs__WEBPACK_IMPORTED_MODULE_0__["setFoo"])(100);
// a.mjs内のfooは100なので100が表示される
console.log(_a_mjs__WEBPACK_IMPORTED_MODULE_0__["foo"]);
最も注目すべき点は、元々のソースコードで変数fooを参照していたところが_a_mjs__WEBPACK_IMPORTED_MODULE_0__["foo"]というプロパティアクセスに置き換わっている点です。_a_mjs__WEBPACK_IMPORTED_MODULE_0__はa.mjsの名前空間オブジェクト(をWebpackがエミュレートしているもの)ですね。さすがに「よそのモジュール(=スコープ外の存在)により変数の中身が勝手に書き換わる」はそのまま実現することができませんが、「よそのモジュールによりオブジェクトのプロパティが勝手に書き換わる」は(よそのモジュールがオブジェクトを参照できれば)実現できそうなので、このような方式が取られています。
次にa.mjsに相当する部分はこんな感じです。__webpack_exports__というのが多分a.mjsの名前空間オブジェクトであり、そのオブジェクトに対してfooやsetFooというアクセサプロパティを定義しています。これらのプロパティはゲッタを持ち、アクセスされると実際の変数fooやsetFooの値を返します。
__webpack_require__.r(__webpack_exports__);
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "foo", function() { return foo; });
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "setFoo", function() { return setFoo; });
let foo = 0;
const setFoo = (value) => {
foo = value;
}
このことから、Webpackの方針はimportを頑張って名前空間オブジェクト経由で変換し、名前空間オブジェクトはゲッタを使って再現するというものであることが分かりますね。
モジュールのアンチパターン
この記事の説明を理解すると、モジュールを書く際に避けるべきパターンが見えてきます。基本的には、「エイリアスを作れば済むのにわざわざ値を取得してエクスポートしているもの」がアンチパターンとなります。
1. 変数を経由して再エクスポート
変数を再エクスポートしたい場合は、再エクスポート用の構文を使って再エクスポートすることで、変数のエイリアス性を保って再エクスポートできます。途中でローカルスコープの変数を経由させると、エイリアス性が途切れてしまいます。
////////// b.mjs
// 変数fooは1秒後に9999になる
export let foo = 1;
setTimeout(()=> { foo = 9999; }, 1000);
////////// a.mjs
import * as b from "./b.mjs";
// fooはa.mjsが実行された瞬間のb.fooの値(1)になる
export const foo = b.foo;
////////// index.mjs
import { foo } from "./a.mjs";
setTimeout(()=> {
// 2秒後にfooを表示すると1が表示される
console.log(foo);
}, 2000);
この例では、b.mjsがエクスポートしている変数fooの値が最初は1で、1秒後に9999に変化します。a.mjsはb.mjsのfooを再エクスポートしているつもりですが、できていません。a.mjsのローカル変数として別にfooを定義しており、それをb.fooの値で初期化されているからです。このfooはb.mjsのfooのエイリアスではなく、初期化時にb.fooの値を使っただけでまったく無関係のfooだからです。
よって、この例でindex.mjsを実行すると、2秒後に1と表示されます。index.mjsがインポートしているfooはa.mjsのfooであり、b.mjsのfooが変化しても影響されないからです。
b.mjsのfooの変化がindex.mjsに伝わってほしければ、正しく再エクスポートしなければいけません。例えば次のようにすれば再エクスポートできます。こう変更すると、index.mjsを実行した2秒後に9999が表示されます。
////////// a.mjs
import * as b from "./b.mjs";
export { foo } from "./b.mjs";
また、次のように「インポートされた変数」を直にexport { }構文に渡した場合は再エクスポートとして扱ってもらえるので、これでもOKです。こちらの方式だと、a.mjs内でもb.mjsからインポートしたfooを参照することができるという利点があります。とにかく、ローカル変数を経由してしまうとだめなのです。
////////// a.mjs
import { foo } from "./b.mjs";
export { foo };
2. オブジェクトに入れて再エクスポート
別のアンチパターンとして、次のように親切にも「インポートされたものをまとめたオブジェクト」を作ってエクスポートしている場合があります。
////////// utils.mjs
import { someNiceFunc } from "./foo.mjs";
import { otherNiceFunc } from "./bar.mjs";
import { veryUsefulFunc } from "./baz.mjs";
// 変数からエクスポートするパターン
export const utils = {
someNiceFunc,
otherNiceFunc,
veryUsefulFunc,
};
// default exportのパターン
export default {
someNiceFunc,
otherNiceFunc,
veryUsefulFunc,
}
これもやはり、変数のエイリアス性が途切れるのでアンチパターンです。変数に入れようがdefaultエクスポートだろうがだめです。例えば、utils.someNiceFuncの値はこのモジュールが実行された瞬間のsomeNiceFuncの値であり、その後someNiceFuncの値が変化しても追随できません。これは、utils.mjsが実行された瞬間に{ someNiceFunc, otherNiceFunc, veryUsefulFunc }というオブジェクトリテラルが評価され、その過程で変数someNiceFuncの値が参照されているからです。
実際のところ「モジュールからエクスポートされている便利関数が後から変わる」というのは非現実的なシチュエーションですが、これと循環参照を組み合わせると割と現実的な問題となります。
ちょっと長いですがこんな感じの例で考えてみます。
////////// utils.mjs
import { someNiceFunc } from "./foo.mjs";
import { otherNiceFunc } from "./bar.mjs";
import { veryUsefulFunc } from "./baz.mjs";
export default {
someNiceFunc,
otherNiceFunc,
veryUsefulFunc,
}
////////// foo.mjs
export const someNiceFunc = (arg)=> {
return arg * 2;
}
////////// bar.mjs
import utils from "./utils.mjs";
export const otherNiceFunc = (arg)=> {
return utils.someNiceFunc(arg) + 1;
}
////////// baz.mjs
import utils from "./utils.mjs";
export const veryUsefulFunc = (arg)=> {
console.log(utils.otherNiceFunc(arg));
}
////////// index.mjs
import { veryUsefulFunc } from "./baz.mjs";
veryUsefulFunc(100);
この例ではfoo.mjs, bar.mjs, baz.mjsがそれぞれとてもいい感じの関数をエクスポートしており、それらをutils.mjsがオブジェクトにまとめています。これらの関数を使いたい場合はutils.mjsを経由して使用する想定です。bar.mjsやbaz.mjsもutils.mjs経由で他の関数を使用しています。
ところが、そこに行儀の悪いindex.mjsが現れて、baz.mjsから直接veryUsefulFuncを読み込んで使用してしまいました。この瞬間にまずい循環参照が発生します。具体的には、index.mjs→baz.mjs→utils.mjs→bar.mjs→utils.mjsという循環参照の発生により、baz.mjsよりも先にutils.mjsが実行されます。その結果、utils.mjsが実行された段階でbaz.mjsからエクスポートされているveryUsefulFuncはTDZ下にあるため、utils.mjs内で以下のエラーが発生してしまいます。
ReferenceError: Cannot access 'veryUsefulFunc' before initialization
さらに、Webフロントエンド状況が悪化することがあります。フロントエンドではいまだにES5へのトランスパイルが行われることが珍しくなく、その場合constはvarに変換されてTDZが消えます。その結果、上の例は「TDZによるエラーが発生する」という結果の代わりにutilsが以下のようなオブジェクトになるという結果になります。
{
someNiceFunc: [Function: someNiceFunc],
otherNiceFunc: [Function: otherNiceFunc],
veryUsefulFunc: undefined
}
つまり、なぜかutils.veryUsefulFuncだけundefinedになってしまうのです。上記のソースコードからこの結果が得られたとき、あなたは原因を特定することができますか? ビルドシステムが一因なのでソースコードだけ見ても分からない上に、循環参照が原因なのでimport文の順番を変えたら直るということすらあり得ます。「JavaScriptはクソ言語」と吐き捨てて投げ出したくなる誘惑に負けずにバグの修正までこぎつけることができるでしょうか。
このバグの原因は主に2つあります。ひとつは循環参照を作ったこと、そしてもう一つはインポートしたものをオブジェクトに詰めてエクスポートするというアンチパターンを行なったことです。
後者が100%いつでも避けるべきかは疑問符が付くところですが、避けられるなら避けるべきです。今回の場合は、アンチパターンを避けてutils.mjsをこのようにすれば万事解決です。
import { someNiceFunc } from "./foo.mjs";
import { otherNiceFunc } from "./bar.mjs";
import { veryUsefulFunc } from "./baz.mjs";
export {
someNiceFunc,
otherNiceFunc,
veryUsefulFunc,
}
それに合わせてutils.mjsを使う側もimport * as utilsにするか、あるいは必要なものだけutils.mjsから読み込むようにします。
皆さんもこの先「インポートしたはずのものがundefinedだ」という謎のバグに出会うことがあるかもしれませんが、その場合は循環参照+エクスポートのアンチパターンという組み合わせを疑ってみるのも悪くないでしょう。
まとめ
この記事では、JavaScriptのimport・export文の挙動を「何をエクスポートしているのか」という点を中心に解説しました。とくに、export文は常に変数をエクスポートしており、importはその変数へのエイリアスを作るのだという点が重要です(export defaultの場合は変数が暗黙に作成されていましたが)。
この挙動は循環参照が発生したときでもなるべくいい感じに動くするようにするためのものですが、その恩恵を得るためには記事で扱ったようなアンチパターンを避ける必要があります。
また、実際にモジュールを使って書かれたコードを処理するのに現在広く使われているwebpackを例にとり、この記事で説明したような挙動が再現されていることを確かめました。
さらに詳しく知りたい方へ
筆者による以下の記事では、top-level awaitという新たな要素がモジュールシステムに与える影響について解説しています。また、この記事の後半ではECMAScript仕様書を読みながらモジュールの挙動を追っていきます。
モジュールの理解をさらに深いものにしたい方はこちらの記事もぜひご覧ください。