今時の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仕様書を読みながらモジュールの挙動を追っていきます。
モジュールの理解をさらに深いものにしたい方はこちらの記事もぜひご覧ください。