先日、TypeScript 3.8 RCが公開されました。TypeScript 3.8はクラスのprivateフィールド(#name
みたいなやつ)を始めとして、ECMAScriptの新機能のサポートがいくつか追加されています。この記事で取り扱うtop-level awaitもその一つです。
この記事ではtop-level awaitに焦点を当てて、その意味や使い方について余すところなく解説します。top-level awaitは一見単純な機能に見えますが、実はモジュール (ES Modules) と深い関係があり、そこがtop-level awaitの特に難しい点です。そこで、この記事ではECMAScriptのモジュールについても詳しく解説します。この記事を読んでtop-level awaitを完全に理解して備えましょう。
**※ この記事は3分の1くらい読むと「まとめ」があり、残りはおまけです。**記事の長さに怖気付かずに、ぜひおまけの前までだけでも読みましょう。おまけでは、top-level awaitに限らずES Modulesについて完全に理解することを目指します。
top-level awaitとは
top-level awaitはECMAScriptに対するStage 3プロポーザルです。実際のプロポーザルは以下のURLで閲覧できます。プロポーザルというのはECMAScript (JavaScript) に対する新機能であり、まだ正式採用には至っていないものを指します。とはいえ、Stage 3というのはもう言語機能のデザインが終了して正式採用間近という段階であり、このステージに達したものはTypeScriptにも導入されます。また、v8にはすでに実装されており、フラグ付きですが利用できます。
名前の通り、top-level awaitというのは**async
関数の外でもawait
式が書ける**という機能です。従来await
が書けるのはasync
関数の中だけでしたが、その制限が緩和されてファイルのトップレベル(関数の外の部分)にもawait
が書けるようになります。ただし、async
ではないただの関数の中にawait
を書くのは相変わらずだめです。
top-level awaitは色々な議論の種となった機能です。議論ではよくTLAと略されているのが見かけられます。
top-level awaitの嬉しい点 (1) ラッパーasync
関数が不要
top-level awaitがあると、特にちょっとしたスクリプトが書きやすくなります。非同期処理を行いたい場合従来はmain
みたいな名前でasync
関数を定義してその中に処理を書いていましたが、top-level awaitを用いるとこれを直に書くことができます。node.jsの例だとこんな具合です(エラー処理は適当です)。変数を使うならちゃんと関数に入れたほうがいいという考え方もありますが、書き捨てのスクリプトなどでは一々関数にまとめるのは面倒なだけということも多いでしょう。
import fs from "fs";
const { readFile, writeFile } = fs.promises;
// 従来の書き方
async function main() {
const content = await readFile("./from", "utf8");
await writeFile("./to", content.toUpperCase());
}
main().catch(err => {
console.error(err);
process.exit(1);
});
// top-level awaitを用いた書き方
const content = await readFile("./from", "utf8");
await writeFile("./to", content.toUpperCase());
ちなみに、top-level awaitはモジュールでのみ使用できます。というのも、JavaScriptではソースコード(ファイル単位)はスクリプトかモジュールかのいずれかに分類され、外的要因や内的要因によってどちらなのかが決められるのです1。import
文やexport
文もモジュールでのみ利用できる構文です。
top-level awaitの嬉しい点 (2) 非同期処理の結果をエクスポートできる
書き捨てのスクリプトだけでなく、他のファイルから依存されるモジュールの場合もよい点があります。それは、非同期処理の結果をエクスポートできる点です。例えば、設定ファイルを読み込んだ結果をエクスポートしたければ次のようにすることができるでしょう(実際は読んだあとに追加処理があるでしょうが省略します)。
export const content = await readFile("./setting", "utf8");
export
文は必ずトップレベルに存在することから、top-level awaitが無いとこのような書き方は不可能です。例えば、次のようにしてはいけません。これだと、モジュールが読み込まれた瞬間はcontent
がundefined
となってしまうからです。
// だめな例
export let content;
readFile("./setting", "utf8").then(c => {
content = c;
});
代わりのワークアラウンドとしては、関数をエクスポートするとかPromiseをエクスポートするといった方法を取ることになります。これらの方法は使う側で一手間必要になりますから、これらに比べるとtop-level awaitはだいぶすっきりと書くことができて嬉しいですね。
// 関数をエクスポート
let content;
export const getContent = async () => {
if (content) return content;
content = await readFile("./setting", "utf8");
return content;
};
// Promiseをエクスポート
export const contentPromise = readFile("./setting", "utf8")
top-level awaitとモジュール
一見すると極めて単純な機能拡張に見えるtop-level awaitですが、実はそうでもありません。この機能拡張は本質的にはトップレベルの実行が非同期実行になるということであり、仕様化のためにはそれに伴って現れる諸問題を解決する必要がありました。
従来、非同期実行(中でawait
が可能な実行)はasync
関数の中でのみ行うことができました。async
関数の返り値はPromiseであるため、async
関数の中でawait
により行われる「待つ」という操作はそのPromiseが解決するまでの時間が延びるという形で吸収されます。
しかし、トップレベルのawait
はそうではなく、await
で待つという操作はモジュール全体の実行が止まることを意味します。このことは、主に**import
されたモジュールがtop-level awaitを持っていた場合にどうするのか**という問題に繋がります。直感的にはモジュール全体が暗黙のasync
関数に包まれた
感じの挙動になりますが、モジュール間の依存関係という特有の事情の存在により状況がややこしくなっています。
単純な例
最も単純な例でtop-level awaitとモジュールの関係を見てみましょう。foo.mjs
とroot.mjs
の2つのファイルがあるとします。foo.mjs
はさっきの例のもので、root.mjs
はfoo.mjs
を読み込みます。
export const content = await readFile("./setting", "utf8");
import { content } from "./foo.mjs";
console.log(content);
ここでroot.mjs
を実行するとどうなるのか見てみます。root.mjs
はfoo.mjs
に依存している(foo.mjs
からインポートしている)ため、root.mjs
よりも先にfoo.mjs
が実行されます。これはtop-level awaitが無い場合でも同様です。
ここで実行されるfoo.mjs
はtop-level awaitを含んでいます。つまり、foo.mjs
の実行が完了するまでには時間がかかるということです。特に、root.mjs
はfoo.mjs
からcontent
をインポートして使用していますが、foo.mjs
がcontent
の中身を計算し終えるまでには時間がかかります。
top-level awaitがある場合、foo.mjs
の実行が完了してからroot.mjs
が実行されます。これにより、root.mjs
はfoo.mjs
がエクスポートするcontent
を計算し終えてから実行を開始するため、content
の内容をちゃんと得ることができます。
このように、top-level awaitを使用する場合はそれに依存するモジュールの実行をブロックすることになります。これはtop-level awaitの重要な特徴ですから理解しておきましょう。
並列な依存関係がある場合
ここまでは、さもありなんという内容でした。次は登場人物をひとり増やしましょう。
例えば、次のように3つのモジュールがあるとします。root.mjs
を実行した場合にどのような挙動をするか説明できるでしょうか。説明を簡単にするため、sleep
関数はいい感じにもともと用意されているものとします(例えばsleep(5000)
は5秒後に解決するPromiseを返します)。
await sleep(5000);
console.log("I am a.mjs");
await sleep(3000);
console.log("I am b.mjs");
import "./a.mjs";
import "./b.mjs";
console.log("I am root.mjs");
root.js
は2つの依存関係a.mjs
とb.mjs
を持ち、どちらもtop-level awaitを使用します。先ほどと同様に考えれば、これらの実行が終わってからでないとroot.mjs
を実行することはできません。しかし、今回は2つのimport
が直列的に実行されるのか並列的に実行されるのかという2つの可能性があります。それによって答えが変わってきますね。
答えは、並列的に実行されます。すなわち、import
された全てのモジュールは同時に実行されます(ただし、直列との対比で並列という言葉を用いましたが、JavaScriptはマルチスレッドではないため同時に実行されるのは一つだけです2。複数のasync関数を順番に呼び出してPromise.all
でまとめるのと同じ感じです)。
この場合、先にimport
されているa.mjs
がまず実行されますが、await
により待機に入った時点でb.mjs
の実行に移り、こちらもawait
により待機状態になります。両方の依存モジュールが待機状態になりましたが、これらの実行が完了したわけではないのでまだroot.js
は実行されません。これら両方の待機状態が解消され、a.mjs
とb.jms
の実行が完了して初めてroot.js
が実行されます。
つまり、上記のサンプルの実行結果はこのようになります。
- 3秒後に
I am b.mjs
と表示される。 - さらに2秒後(実行開始から5秒後)に
I am a.mjs
と表示される。 - 即座に
root.mjs
の実行が開始され、I am root.mjs
と表示される。
なお、root.mjs
の中でimport
文がどこに書かれていようとも、全てのimport
が解決される(依存モジュールの実行が完了する)までは実行されません。例えroot.mjs
をこのように書いたとしても、表示の順番はI am b.mjs
→I am a.mjs
→I am root.mjs
となります。
console.log("I am root.mjs");
import "./a.mjs";
import "./b.mjs";
この点は直感に反するという人がいるかもしれませんので注意が必要です。どこにimport
を書こうとも必ず依存モジュールの方が先に実行されるのです。通常はimport
はファイルの先頭にまとめて書かれますが、そもそもどこに書こうとも動作が変わりませんから、ファイルの先頭以外に書く意味が無いのです。ただし、import
同士の順番は実行順に影響しますから注意してください。
まとめると、モジュールの循環参照が無い場合はtop-level awaitとimport
の関係はそんなに難しくありません。「import
されている各モジュールは並行的に実行され、それらが完了する(モジュールの最後まで実行される)と自身が実行される」と説明できます。
なお、ES2020で導入予定のdynamic importについてはまた話が違います。コード中にdynamic importが存在したとしても、それを待たずに(静的なimport
はもちろん待ちますが)コードの実行が開始されます。そして、import(...)
式が評価されたタイミングで初めてモジュールの読み込み・依存先の実行が行われるのです。
ということで、top-level awaitとdynamic importを組み合わせることで、コード実行とimport
の順番をより細かく制御できるようになります。
console.log("I am root.mjs");
await import("./a.mjs");
await import("./b.mjs");
この場合、まずI am root.mjs
と表示→5秒後にI am a.mjs
と表示→さらに3秒後にI am b.mjs
と表示、という実行になります3。
この例では静的なimport
文が無いため、実行するといきなりroot.mjs
の実行が開始されます。これにより最初にI am root.mjs
と表示されます。その後import("./a.mjs")
が評価された時点でa.mjs
の読み込みと実行が開始されます。import("./a.mjs")
はPromiseを返しますが、これが解決されるのはa.mjs
の実行が完了したあと、すなわちtop-level awaitによる中断を乗り越えて最後まで実行されたあとです。よって、await import("./a.mjs")
でroot.mjs
の実行は5秒間停止します。同様に、await import("./b.mjs")
にも3秒かかります。
基本的には、dynamic importよりも静的なimport
文を用いたほうがよいでしょう。その理由は主に2つあります。一つは静的なimport
文ならば上述のように依存モジュールを並列的に実行できることです(dynamic importもPromise.all
と組み合わせれば同じことができますが、そこまでするよりは普通にimport
文を書いた方がきれいな記述になります)。もう一つは次に説明する循環参照のハンドリングをうまくやってくれることです。
循環参照がある場合 (1) そもそも同期的なモジュールの循環参照は?
モジュールの循環参照はあまり勧められたことではないのですが、ES Modulesでは循環参照があっても一応動くようになっています。top-level awaitから一旦離れて同期的なモジュールの循環参照について理解しましょう。
import "./b.mjs";
console.log("I am a.mjs");
import "./a.mjs";
console.log("I am b.mjs");
import "./a.mjs";
console.log("I am root.mjs");
このサンプルでは、root.mjs
→a.mjs
→b.mjs
→a.mjs
という循環参照が発生しています。なお、実際に実行した結果はこうです。
I am b.mjs
I am a.mjs
I am root.mjs
一見すると「a.mjs
を実行すると先にb.mjs
を実行する必要があり、b.mjs
を実行すると先にa.mjs
を実行する必要がある」という無限ループに陥るように思えますが、そうはなりません。この場合は依存先よりも先に自身が実行されるという現象が発生します。
具体的な実行順は、依存関係グラフを深さ優先探索することで決められます。依存関係グラフに属する各モジュールは葉から順に(帰りがけ順で)実行されます。ただし、同じモジュールを訪れるのは1回だけです。
今回の例の場合は、root.mjs
→a.mjs
→b.mjs
という順で依存関係が探索されますが、その次のb.mjs
→a.mjs
という依存関係は実行順を決める際に無視されます。これは、a.mjs
は探索の途中ですでに一度訪れているからです。よって、実行順は葉から順に辿るのでb.mjs
→a.mjs
→root.mjs
となります。
ここで、b.mjs
がimport "./a.mjs"
という依存を持っているにもかからわず、a.mjs
よりもb.mjs
が先に実行されるという現象が発生しました。このように、循環参照が発生した場合は探索順に依存して一部の依存関係を無視することで実行順を決定します。
ここで少し気になるのは、「a.mjs
よりも先にb.mjs
の内容が実行されるのなら、b.mjs
からa.mjs
がエクスポートするものを使用したらどうなるのか」ということです。これを確かめるようには例を次のように書き換えます。
import "./b.mjs";
console.log("I am a.mjs");
export const a = "aaaaaa";
import { a } from "./a.mjs";
console.log("I am b.mjs", a);
import "./a.mjs";
console.log("I am root.mjs");
root.mjs
→a.mjs
→b.mjs
→a.mjs
という循環参照があるのは変わりませんが、b.mjs
はa.mjs
がエクスポートするa
を使用しています。
問題は、先ほど説明した理屈によりa.mjs
よりも先にb.mjs
が実行されることです。つまり、a.mjs
のexport const a = "aaaaaa";
が実行されるよりも前にb.mjs
が実行されてしまいます。
実際にこれを実行すると、b.mjs
は以下のようなエラーを発生させます。
console.log("I am b.mjs", a);
^
ReferenceError: Cannot access 'a' before initialization
つまり、a.mjs
よりも先にb.mjs
が実行される場合、export const a = "aaaaaa";
が実行されるよりも前にa.mjs
からエクスポートされたa
を使用しようとするのはエラーとなるのです。ただし、a
をインポートするだけではエラーにならず、a.mjs
が実行される(a
に値がセットされる)よりも前にそれを使用するのがエラーとなります。よって、a.mjs
が実行されてからa
を使用するようにするとエラーが消えます。
例えばb.mjs
を次のように変えることで、エラーを発生させずに次のような出力を得ることができます。
import { a } from "./a.mjs";
console.log("I am b.mjs");
setTimeout(() => {
console.log("a is", a);
}, 1000);
I am b.mjs
I am a.mjs
I am root.mjs
a is aaaaaa
余談ですが、a
をインポートするだけではエラーとならず、それを使うタイミングでエラーが起きるのは少し変ですね。まるで変数a
の内容が勝手に書き換わっているようにも見えます。実際には、import
やexport
でやりとりされているのが値ではなくbindingであることからこのような挙動が実現されています。実際、webpackなどのバンドラを通すと、a
が__importedModule.a
みたいに変換され、a
は変数ではなくオブジェクト(モジュール名前空間)のプロパティとなります。これにより「import後に値が変わる」という挙動を再現しているのです。また、バンドラを用いる場合、未初期化のbindingを用いた場合はエラーが発生するのではなくundefinedとなることがあります。
また、そもそもexport
されていないものをimportするのは、自分より先にa.mjs
が実行されるかどうかとは無関係にエラーとなります。これは、何がそのモジュールからexport
されているかはコードを実際に実行しなくてもパースするだけで分かるからです4。
// こうするとエラーが発生
import { abcdefg } from "./a.mjs";
循環参照がある場合 (2) top-level awaitとの関係
以上で説明した循環参照のハンドリングは、top-level awaitがある場合でも基本的には同様に成り立ちます。
import "./b.mjs";
await sleep(3000);
console.log("I am a.mjs");
import "./a.mjs";
await sleep(5000);
console.log("I am b.mjs");
import "./a.mjs";
console.log("I am root.mjs");
この例は先ほどと似ていますが、a.mjs
とb.mjs
にはtop-level awaitを用いたsleep
呼び出しが追加されています。この場合、やはり循環参照があり、a.mjs
よりも先にb.mjs
が実行されます。
その結果、次のような実行経路を辿ります。
- 最初に
b.mjs
が実行される。よって、実行開始から5秒後にI am b.mjs
と表示。 - 次に
a.mjs
が実行される。よって、さらに3秒後にI am a.mjs
と表示。 - 直後に
root.mjs
が実行され、I am root.mjs
と表示。
これは先ほどのtop-level awaitが無い場合の例と何も変わっていませんね。それぞれのモジュールの実行完了まで時間がかかるようになっただけです。
このように、top-level awaitのある無しによってモジュールの実行順が変わることはありません。これはプロポーザル文書にも明記されており、プログラマがこれまでに得た直感を破壊しないように気が遣われています。
ただし、この場合に保証されるモジュールの実行順とは「モジュールの実行開始順」です。モジュールがawait
に到達した時点でそのモジュールは中断し、次のモジュールの実行に移ることができます。これはasync
関数の場合と同じものとして理解することができます。すなわち、async
関数を呼び出したら最初のawait
まではそのまま実行されるものの、そのタイミングで呼び出し元に制御が返るのでした(多くの場合ですぐawait
されるのであまり意味はありませんが)。
循環参照がある場合 (3) 強連結成分の扱い
dynamic importでは、import("./a.mjs")
のような構文でPromiseを得ることができます。そして、a.mjs
が読み込まれたらこのPromiseが解決されます。a.mjs
がtop-level awaitを含む場合には、それが全部解決されてa.mjs
の実行が終了して初めてPromiseが解決されます。
このことはすでに見た通りですが、これに循環参照が関わると一つ特殊な挙動が発生します。それは、循環参照の強連結成分に含まれるモジュールのPromiseは全て同時に解決されるというものです。強連結成分というのは要するに互いに循環参照しているモジュールたちのことです。このことは次のような例で確かめられます。
import "./b.mjs";
await sleep(5000);
console.log("I am a.mjs");
import "./a.mjs";
await sleep(3000);
console.log("I am b.mjs");
import("./a.mjs").then(() => console.log("a.mjs is loaded"));
await sleep(1000);
import("./b.mjs").then(() => console.log("b.mjs is loaded"));
a.mjs
とb.mjs
は普通に循環参照を形成しており、root.mjs
はそれぞれに対してdynamic importを行います。これによって、a.mjs
とb.mjs
がそれぞれ読み込まれた瞬間にconsole.log
が実行されます。このroot.mjs
を実行したらどのような挙動になるかはお分かりでしょうか。ただし、ファイルシステムからファイルを読み込んでそのJavaScriptファイルを実行するのにかかる時間は1秒より十分短いものとします。
答えは次の通りです。
- 実行から3秒後に
I am b.mjs
と表示される。 - そのさらに5秒後(実行から合計8秒後)に
I am a.mjs
と表示される。 - その直後に
a.mjs is laoded
→b.mjs is loaded
の順で表示される。
この挙動になる理由を順に説明します。
- まず、
root.mjs
を実行するとdynamic importによりimport("./a.mjs")
が実行されます。直後のawait sleep(1000)
によりroot.mjs
の実行は中断します。import("./a.mjs")
の返り値であるPromiseを$P_a$とします。 - この間に、
import("./a.mjs")
によりa.mjs
が読み込まれます。これはb.mjs
をimport
しているので、さらにb.mjs
が読み込まれます。これはa.mjs
への循環参照を持ちますが、先ほど説明したメカニズムにより、b.mjs
が先に実行されます。b.mjs
はawait sleep(3000)
にさしかかって待機します。 - 開始から1秒後に
root.mjs
のawait
が終了し、import("./b.mjs")
が実行されます。b.mjs
はすでにa.mjs
経由で実行されているため、ここでは何も起こりません。import("./b.mjs")
の返り値であるPromiseを$P_b$とします。 - 開始から3秒後に
./b.mjs
のawait
が終了し、console.log
によりI am b.mjs
が出力されます。ここが一番のポイントですが、b.mjs
の実行が終了しても**$P_b$はまだ解決されない**点に注意してください。 -
b.mjs
の実行が終了したので、それに依存していたa.mjs
の実行が開始されます。これはawait sleep(5000);
に差し掛かって停止します。 - 開始から8秒後に
a.mjs
のawait
が終了し、console.log
によりI am a.mjs
が出力されます。 - この直後、$P_a$と$P_b$が同時に解決されます。$P_a$の方が先に作られたため、$P_a$の
then
ハンドラが先に呼び出されてa.mjs is loaded
が先に表示されます。その後にb.mjs is loaded
が表示されます。
4と7の動作がポイントです。$P_a$や$P_b$はどちらもimport()
によって作成されたPromiseですが、これらはa.mjs
の実行が完了した時に解決されるPromise $P$ にthen
で繋がっています。そのため、$P$が解決された段階で$P_a$と$P_b$もresolveされます。$P_b$はb.mjs
をimport
してできたPromiseですが、これが(a.mjs
の完了を意味する)$P$に繋がっているというのが特徴的です。これは、import("./b.mjs")
の時点ですでにb.mjs
が実行済であり、かつb.mjs
を含む強連結成分の“親”5がa.mjs
であったことから、$P_b$は$P$に繋がることになります。
このように、循環する依存関係(強連結成分)中のモジュールの実行が終わるのを待機した場合、どのモジュールを待機した場合でも親の実行が終わる(=強連結成分全体の実行が終わる)まで待機することになります。これはtop-level awaitに関して仕様上保証する性質のひとつであり、強連結成分の外から内部のモジュールの結果を利用したい場合、内部の実行が全部終わって初めてその結果が利用できるようになります。
今回の場合、b.mjs
の実行は開始から3秒で終了しているはずなのに、実際にimport("./b.mjs")
の結果 ($P_b$)が解決されるのが8秒後であるという点にこの性質が現れています。実際の実行順序がどうであれ、b.mjs
は構文上a.mjs
に依存するものとされているのだから、a.mjs
の実行が完了する(=強連結成分全体の実行が完了する)までは利用できないということになります。
この仕様は、中途半端な状態のモジュール(まだ実行されていないモジュールからエクスポートされている変数を使用するモジュールなど)を循環参照の外に露出しないことが目的です。top-level awaitが無くても循環参照の内部でそのような状態を作ることは可能でしたが、やはりそのような状態は外に漏れないようになっていました。
循環参照がある場合 (4) dynamic importでデッドロックを作る
top-level awaitがモジュールの依存関係の中に現れるとなると、気になるのがデッドロックが発生しないかどうかです。モジュールたちが互いに互いを待機し合う状況になると先に進まなくなってしまうかもしれません。
結論から言えば、静的なimport
だけ使っていれば、たとえ循環参照していてもデッドロックにはなりません。これまで説明してきたように、循環参照があったとしても適当な順番でモジュールが実行されるからです。
ただし、dynamic importを用いている場合にはデッドロックの可能性が一応あります。特に、先ほど説明したdynamic importの挙動を使ってデッドロックを発生させることができます。具体例を見てみましょう。
import "./b.mjs";
await import("./b.mjs");
console.log("I am a.mjs");
import "./a.mjs";
console.log("I am b.mjs");
import "./a.mjs";
console.log("I am root.mjs");
この状態でroot.mjs
を実行すると、I am b.mjs
だけが表示された状態で停止します。プロセス自体は終了せずに停止することから、デッドロックに陥ったことが伺えます。では、なぜこれはデッドロックとなるのか考えてみましょう。
静的なimport
文によるroot.mjs
→a.mjs
→b.mjs
→a.mjs
という依存関係がありますから、これまでの例と同様にまずb.mjs
が実行されます。何の問題もなくI am b.mjs
が表示されます。次に実行されるのはa.mjs
ですが、ここでawait import("./b.mjs")
に差し掛かります。
ここで重要な点は、a.mjs
とb.mjs
は静的なimport
文によって循環参照を作っており、同じ強連結成分に属するという点です。つまり、import("./b.mjs")
はa.mjs
とb.mjs
の実行が完了するまで解決されません。ここで、今a.mjs
を実行中なのに、a.mjs
の実行が完了するまでawait
で待ち続けてしまうという状況が発生しました。これがデッドロックです6。よって、このプログラムはこのawait
で永遠に待ち続けるため終了できずI am a.mjs
やI am root.mjs
も出力されません。
ただ、実際にこの方法でデッドロックが発生することは滅多に無いでしょう。すでに静的なimport
で循環参照が生成されている内部でさらにdynamic importで同じモジュールを読み込まないといけないからです。とはいえ、込み入った依存関係の中に循環参照が形成されてしまい気づかないうちにdynamic importも併用していたということはもしかしたらあるかもしれません。変なところでプログラムが止まってしまう場合はこの現象を疑うべきかもしれません。尤も、循環参照を避けるのが一番良いことではありますが。
top-level awaitのサポート
さて、ここまででtop-level awaitがどのように動作するのか理解できたことと思います。では、top-level awaitはどのように実用すれば良いのでしょうか。
答えは、残念ながらまだです(2020年2月現在)。
top-level awaitを使う方法は主に2つあります。ES Modulesをサポートした環境で直接用いるか、バンドラを用いるかです。前者に関しては、v8エンジンにはすでに実装されています(--harmony-top-level-await
フラグが必要ですが)。ただし、v8を利用しているnode.jsやGoogle Chromeにおけるサポートはまだです。とはいえ、近いうちにtop-level awaitが利用できるようになるでしょう。
とはいえ、ES Modulesを直に使っているという人はかなり少ないのが現状です。node.jsに関してはES Modulesが正式にサポートされたのがv13.2.0とかなり最近ですし、ブラウザでは後方互換性の問題やローディング速度の問題7からES Modulesをそのまま使うという選択肢が取られることはほとんどありません。
ということで、少なくともブラウザにおいてはtop-level awaitが最初に実用的に利用されるのはバンドラ経由のはずです。そもそも、top-level awaitはモジュールがどう読み込まれて実行されるかという点を変化させるものですから、Babelなどのトランスパイラが対応する範疇のものではありません8。代わりにtop-level awaitのサポートを実装すべきなのはwebpackなどのバンドラというわけです。
バンドラとしてはwebpackが一番早く動いており、v5系でサポートされると思われます。top-level awaitのサポートは5.0.0-alpha.15にて実験的なものが導入されました。歴史的経緯から、webpackには古いプロポーザル内容のサポートも含まれています。top-level awaitのプロポーザルをより良いものにするために、webpackなどのバンドラがある種の実験場として活躍してきたという経緯があります。
node.jsにおけるES Modulesサポートとtop-level await
上述の通り、ES2015でES Modulesが導入されてから、実際にnode.jsでES Modulesがサポートされる(2019年後半)までには随分時間がかかりました。その要因には色々ありますが、top-level awaitの存在もその一因だったようです。こちらの記事が詳しいので解説を譲ります。
まとめ
この記事では、Stage 3となりTypeScript 3.8によるサポートが追加されたtop-level awaitについて解説しました。top-level awaitが本質的にはES Modulesの意味を変えるものであることを説明し、併せてES Modulesについても必要に応じて解説しました。
TypeScript 3.8によりサポートされたといっても構文を理解できるようになったという話で、実際にtop-level awaitが使えるようになるまでには各種実行環境やバンドラによるサポートが必要となりますから、まだ先の話でしょう。
top-level awaitは非同期処理を行なった結果をエクスポートしたい場合などにたいへん便利ですから、実用化されたらぜひ使っていきたいですね。
おまけ: 仕様書を読んでES Modulesを完全理解する
ここまでこの記事を読んだ皆さんはES Modulesの挙動やtop-level awaitの挙動について詳しくなりました。しかし、やはり仕様書を読んで理解しないと完全理解とまでは言えませんよね。そこで、おまけとしてES Modulesの挙動を仕様書を見ながら追っていきます。なお、ES2020の仕様を参照したいのでこちらのドラフト (https://tc39.es/ecma262/)の2020年2月13日版を参照しながら説明します。あなたがこの記事を閲覧した日時によっては細かい節番号等が変わっているかもしれませんが、適宜調整してください。
Q. おまけが本編では?
A. はい。
モジュールという概念は仕様でどう扱われるのか
ECMAScriptでは、JavaScriptのソースコードがどのように解釈・実行されるのかが定められていますが、JavaScriptのソースコードの最も大きな単位(文法定義でいう開始記号)はScriptまたはModuleであり、これらは1つのソースコード(すなわち1つのファイルに記述されたひとかたまりのソースコード)に対応するものです。
その一方で、モジュールというのはimport
文を通じて複数のソースコードが連結されて実行されるものです。それゆえに、「モジュールを実行する」というのは従来のように「ソースコードを読み込んで構文解析を行い、規則にしたがって実行する」という枠には収まらない行為であると言えます。
ECMAScriptでは、このような概念を定義するための仕様が存在します。モジュールを完全に理解する最初の一歩として、まずこの辺りを見てみましょう。
Module Record
Module Recordとは、モジュール一つ一つを表す仕様書上のオブジェクト(Record)です。この記事ではa.mjs
とb.mjs
、root.mjs
という3つのファイルが出てくる例などを示しましたが、この場合はそれぞれのファイルに対応するModule Recordが存在します。つまり、root.mjs
を実行する際には裏で(少なくとも仕様書上で)3つのModule Recordが生成されていたことになります。仕様からModule Recordの説明を引用します。
A Module Record encapsulates structural information about the imports and exports of a single module. This information is used to link the imports and exports of sets of connected modules. A Module Record includes four fields that are only used when evaluating a module.
モジュールはimport
文とexport
文によって繋がるのですから、Module Recordはそれらの情報を含んでいなければいけません。実際、仕様書を見るとModule Recordは「GetExportedNames」といった(仕様書上の)メソッドを持つことが定義されています。また、この記事でも説明したように、実行時にも各モジュールが決められた順番で実行されます。これも仕様書的には「Module Recordを実行する」というような形で表されます。
Module Recordについては、3種類の分類が仕様書上で定義されています。それぞれAbstract Module Record, Cyclic Module Record, そしてSource Text Module Recordです。これらは分離したものではなく、ある種の継承関係にあります。実際、任意のModule RecordはAbstract Module Recordであり、Cyclic Module RecordはAbstract Module Recordの一瞬であり、Source Text Module RecordはCyclic Module Recordの一種です。
なぜこのような分類が用意されているのかと言えば、モジュールはJavaScriptソースファイルに限らず、処理系定義のモジュールがあるかもしれないからです。例えばWebpackでCSS Modulesを用いる環境では以下のようなimport
文が用いられます。
import styles from "./styles.css";
これがどうバンドルされるかは別として9、ES Moduleを用いるコードとして見ればstyles.css
というファイルをモジュールとしてimport
していることになります。ECMAScript仕様上ではこのようなものが許されるのです。
そもそも、import
のspecifier(インポート元を示す文字列部分)をどう解釈するのかも処理系依存です。ブラウザ上では"https://.../styles.css"
のようにURLを示すことができますが、その場合の処理はECMAScript仕様書には書かれていません。また、node.jsではimport fs from "fs"
のように組み込みモジュールを読み込むことができますが、当然これもECMAScript仕様書の埒外です。ECMAScriptが定めるのは、「何らかの方法で読み込まれたモジュールをどう実行するか」という部分なのです。
ということで、ECMAScript仕様書では、その実態が何とも知れない対象に対して物事を定義する必要があるのです。Abstract Module Recordは、モジュールとして最低限備えるべき性質を規定します。すなわち、何をexport
しており何をimport
しているのか分かる、またモジュールとして実行できるという性質です。
次に**Cyclic Module Record**とはもう少し条件が厳しいモジュールで、仕様書では次のように定義されています。
A Cyclic Module Record is used to represent information about a module that can participate in dependency cycles with other modules that are subclasses of the Cyclic Module Record type. Module Records that are not subclasses of the Cyclic Module Record type must not participate in dependency cycles with Source Text Module Records.
その名が示す通り、Cyclic Module Recordとは、dependency cycle(循環した依存関係)の中に現れることができるモジュールを指します。逆に言えば、Cyclic Module Recordではないモジュールは循環参照を作ることができません。Cyclic Module Recordの特徴は、依存関係の解決(後述するLink)や実際の実行順(後述するEvaluate)が仕様書で定義されるということです。逆に言えば、Cyclic Module Recordでないモジュールに関しては、どのように依存関係が解決され、どのような順番で実行されるのかということは、ECMAScriptの範疇には含まれないということになります(別の仕様書がCyclic Module RecordでないModule Recordを定義してそこで挙動が定義される可能性はあります)。
この記事では循環参照が発生した場合に挙動について説明しましたが、それもこのCyclic Module Recordに対して定義されたものになります。
最後の**Source Text Module Record**は、ECMAScriptソースコードにより定義されるモジュールです。我々が普段扱うモジュールは基本的にこれになります10。実際、この記事にこれまで出てきたモジュールは全てSource Text Module Recordになります。Source Text Module Recordについては「そのモジュールが実行されたらどのような挙動をするのか」という点が仕様に含まれます。まあ、モジュールの中身がECMAScriptソースコードなのだからこれは当然ですね。
A Source Text Module Record is used to represent information about a module that was defined from ECMAScript source text (10) that was parsed using the goal symbol Module. Its fields contain digested information about the names that are imported by the module and its concrete methods use this digest to link, link, and evaluate the module.
ということで、主にプラットフォームによる拡張を可能にする目的で、どこまで仕様が関与するかを基準にモジュールが3階層に区分されていることを解説しました。次は、モジュールに関する仕様の中身をもう少し見てみましょう。
LinkとEvaluate
Abstract Module Recordは(言い換えれば全てのModule Recordは)、LinkとEvaluateという2つのメソッドを持つと定義されています。それぞれについて、仕様では次のように説明されています。
Link()
Prepare the module for evaluation by transitively resolving all module dependencies and creating a module Environment Record.
Evaluate()
If this module has already been evaluated successfully, return undefined; if it has already been evaluated unsuccessfully, throw the exception that was produced. Otherwise, transitively evaluate all module dependencies of this module and then evaluate this module.
Link must have completed successfully prior to invoking this method.
これを読むと、Link()は依存関係の解決を行うメソッドであることが分かります。module Environment Recordを作るとありますが、これはモジュールがエクスポートしている名前の一覧を保持する名前空間です。
Evaluate()は実際にモジュールを実行するメソッドです。説明に書いてある通り、このメソッドにはいくつかの制約が存在します。このメソッドよりも前にLink()が呼ばれている必要があること、2度目以降の呼び出しではモジュールを再度実行せずに前回と同じ結果を返す必要があること、そしてこのモジュール本体を実行する前に依存先のモジュールを先に呼び出す必要があることです11。
Abstract Module Recordの場合、Link()とEvaluate()を実行すると何が起こるのかは定義されていません。もし別の仕様がAbstract Module Recordの具体例を定義した場合は、そちらでその場合のLink()とEvaluate()の実装が定義されることになるでしょう。
Cyclic Module RecordにおけるLink()の挙動
一方、Cyclic Module RecordではLinkとEvaluateの実装が定義されています。まずはCyclic Module RecordのLinkの実装を
見てみます(長いので引用はしません)。
Linkの挙動を要約すると、「依存関係を深さ優先探索で順に辿り、各モジュールに対してInitializeEnvironment()メソッドを実行する。また、依存関係中にCyclic Module Recordでないモジュールがある場合はそれのLink()を実行する」となります。InitializeEnvironment()についてはCyclic Module Recordに対しては定義されておらず、次のように説明されています。
Initialize the Lexical Environment of the module, including resolving all imported bindings, and create the module's execution context.
また、Source Text Module Recordに対しては具体的な挙動が仕様で定義されています。説明には「execution contextを定義する」などがあり、具体的には(Source Text Module Recordの場合は)そのモジュール用の変数スコープを初期化・生成するといった処理が含まれます。基本的には仕様書上必要な初期化処理であり、我々が直接気にするものではありません。
Cyclic Module RecordにおけるLink()の定義には、記事中で説明したような循環参照の対処も含まれています。すでに訪れたモジュールか否かはCyclic Module Recordの内部スロット[[Status]]で管理されます。[[Status]]の初期状態はunlinkedであり、深さ優先探索の行きがけにlinkingに変更されます。また、帰りがけにlinkedになります。これにより、すでに訪れられたモジュールか否かを[[Status]]を見ることで判断できるのです。
もうひとつ重要なポイントは、「依存関係を辿る」部分で用いられる**HostResolveImportedModule**という処理です。
HostResolveImportedModule
HostResolveImportedModuleは、"./a.mjs"
のようなspecifierを解決し、実際のModule Recordを取得・生成する処理です。名前の頭にHostとついているのは、仕様書で具体的な処理を定義しないということを示唆しています。先ほども触れたように、specifierから実際のモジュールを得るためにすべきことは環境によって異なるからです。
HostResolveImportedModule(referencingScriptOrModule, specifier)の処理については次の条件を満たさなければいけないと定義されています。
The implementation of HostResolveImportedModule must conform to the following requirements:
- The normal return value must be an instance of a concrete subclass of Module Record.
- If a Module Record corresponding to the pair referencingScriptOrModule, specifier does not exist or cannot be created, an exception must be thrown.
- Each time this operation is called with a specific referencingScriptOrModule, specifier pair as arguments it must return the same Module Record instance if it completes normally.
すなわち、HostResolveImportedModuleは与えられたspecifierに対するModule Recordを返さないといけないこと、またspecifierに対するモジュールが存在しない場合はエラーを発生させなければいけないことが示されています。また、同じモジュールが複数回HostResolveImportedModuleされようとした場合には、同じModule Recordインスタンスが返されないといけないとしています。
ただし、HostResolveImportedModuleはreferencingScriptOrModuleも受け取っている点に着目してください。これは、「どのモジュール(実際にはモジュールとは限りませんが)からspecifierが参照されているか」を示すものです。この引数が必要な理由は、specifierだけからモジュールを特定できないかもしれないからです。例えば./a.mjs
のような相対パスのspecifierは、どこからimport
されているかによって参照先が変わります。
HostResolveImportedModuleはspecifierからModule Recordを作るので、一般にはネットワークアクセスなどを含みうる時間のかかる処理です。これはJavaScriptにおいては非同期処理として表現されるものですが、仕様書上のアルゴリズムは必ずしも(JavaScriptにおける意味で)同期的なものとは限りません。HostResolveImportedModuleを呼び出した場合は、必要なネットワークアクセスなどの処理が完了するまでそこでブロックすることになります。
また、Module Recordはそれが何をexportしているかとか、どのモジュールを依存関係に持つかという情報を含んでいますから、Module Recordを作成するためには読み込まれたモジュールをパースするところまで行う必要があります。HostResolveImportedModuleはこれらの処理を内包したものとなっています。
Cyclic Module RecordにおけるLink()の定義を読んだ方は、モジュールが依存する各モジュールに対してループしながらHostResolveImportedModule()を呼んでいるのが気にかかったかもしれません(次の部分)。
For each String required that is an element of module.[[RequestedModules]], do
a. Let requiredModule be ? HostResolveImportedModule(module, required).
(後略)
愚直に解釈すれば、複数の依存が存在するときにそれらを直列的に読み込むように思えるかもしれません。もちろんそのようにしても仕様違反ではありませんが、実際の処理系(ブラウザなど)はより最適化された機構を持ち、全ての依存関係に対して並列的に読み込みを行うことができます。これもまた、仕様違反とはなりません。なぜなら、仕様には「HostResolveImportedModuleが呼ばれてから読み込みを開始しなければいけない」などとは書いておらず、投機的に読み込みを開始していてもよいからです(後述)。
まとめると、Cyclic Module RecordのLink()の工程ではモジュールの全ての依存関係の読み込み・パースを行い、依存関係グラフ状の全てのモジュールに対するModule Recordを用意するということです。
Cyclic Module RecordにおけるEvaluate()の挙動
Link()の挙動を理解したので、次はEvaluate()の定義を読んでみましょう。こちらも要約すると、「モジュールの依存関係を深さ優先探索で読み込み、各モジュールに対してExecuteModule()を実行する。Cyclic Module Recordでないモジュールに対してはExecute()を実行する」というものです。実際にみてみると、Link()と非常に似た定義になっていることが分かります。これは、深さ優先探索の部分が同じだからです(linking, linkedという[[Status]]の代わりにevaluating, evaluatedが使われています)。ExecuteModule()についてはInitializeEnvironment()と同様にCyclic Module Recordにおいては実装が定義されていないメソッドであり、以下のように説明されています。
Evaluate the module's code within its execution context.
例によって、Source Text Module Recordに対しては具体的な実装が規定されています。Source Text Module Recordに対してはExecuteModule()の動作は単純で、モジュールに書かれているコードを実行するだけです。例えばa.mjs
に次のように書かれていた場合、I am a.mjs
と表示されるのはこの段階です。
import from "./b.mjs";
console.log("I am a.mjs");
ちなみに、Evaluate()からも先ほどのHostResolveImportedModuleが使用されています。すでに説明した「複数回読んだら同じ結果を返す」という制約により、ここでは特に何も行われずにModule Recordが得られます(Link()時にすでに同じ呼び出しが行われているため)。
モジュールは誰が実行するのか: HTML仕様書に例を見る
ここまでで、モジュールの実行を司るのはLink()とEvaluate()という2つの(仕様書内で用いられる)メソッドであることが分かりました。まずLink()により依存関係が全て解決され、そのあとにEvaluate()を実行することでモジュールが(依存関係の末端から順に)実行されます。
ここでひとつの疑問が発生します。それは「最初のModule Recordは誰が作るのか」ということです。Link()やEvaluate()自体もModule Recordが持つメソッドであり、それらを実行することで、そのモジュールとそれが依存するモジュール全体を実行することができるのでした。しかし、ここまで解説した仕様には「一番の根に当たるModule Recordはどのように作られるのか」という点が含まれていません(根以外はHostResolveImportedModuleが作成してくれます)。
実は、この点はECMAScript仕様書の管轄ではありません。ECMAScript仕様書はModule Recordがどう振る舞うかについて定義する一方で、Module Recordをどう作るのかには関与しないのです。ECMAScript仕様書内でModule Recordを作る手段であるHostResolveImportedModuleについてもその具体的な挙動は示されていません。
そこで、実際にModule Recordが作られる場面を見るために**HTML仕様書**を見に行きましょう。
script要素の挙動を調べる
HTMLでは、<script type="module">...</script>
を用いることで、その中に書かれたソースコードをModule扱いで実行するはずです。ということで、script要素の仕様を見てみます。
script要素の中に書かれたプログラムが実行される場合に入り口となる仕様は以下の記述から始まります。
When a script element that is not "parser-inserted" experiences one of the events listed in the following list, the user agent must immediately prepare the script element:
- The script element becomes connected.
- The script element is connected and a node or document fragment is inserted into the script element, after any script elements inserted at that time.
- The script element is connected and has a src attribute set where previously the element had no such attribute.
これはscript要素がprepareされる条件を示した仕様であり、HTMLのソース中に書かれているscript要素の場合は一番上の「The script element becomes connected」に該当してプログラムが実行されます。これは要するに「文書の木構造にscript要素が挿入(追加)されたとき」を意味していますが、HTMLパーサーがは文書を上から読んで適宜読んだ要素を文書に挿入していくので、この条件が満たされるのはHTMLパーサーが<script> ... </script>
を読み終わった瞬間ということになります(勿論このことはHTML仕様書に明記されていますが、HTMLのパーサーの仕様をこの記事で解説するのはつらいので割愛します)。
Module Recordが作成されるまで
気を取り直して、script要素がprepareされると何が起こるのかを仕様から読み取りましょう。これは28ステップもある長いアルゴリズムですが、ステップ27に次のような記述があります。
Fetch an inline module script graph, given source text, base URL, settings object, and options. When this asynchronously completes, set the script's script to the result. At that time, the script is ready.
the script's scriptというよくわからない概念がありますが、定義を見るといくつかの要素からなるstructであり、その要素の中にrecordというものがあります。
A record
Either a Script Record, for classic scripts; a Source Text Module Record, for module scripts; or null. In the former two cases, it represents a parsed script; null represents a failure parsing.
ここに「module scriptsの場合はSource Text Module Recordである」とあります。the script's scriptにはFetch an inline module script graphの結果がセットされるとありますから、このFetch an inline module script graphアルゴリズムがSource Text Module Recordを作成していることが伺えます。
スクリプトをネットワークから読み込む部分を省くと、最終的にこれはcreate a module scriptアルゴリズムに行き着きます。このアルゴリズムのステップ7でresultが定義されており、この中身こそがSource Text Module Recordです。
Let result be ParseModule(source, settings's Realm, script).
ParseModuleはECMAScript仕様書で定義されている抽象操作であり、ソースコードとRealmを受け取ってSource Text Module Recordを作成するものです。このように、HTML仕様書が担当するのはソースコードをテキストとして取得する部分であり、実際にSource Text Module Recordを作成する部分はECMAScript仕様書からエクスポートされたものを用いています。
Link()の実行
視点をFetch an inline module script graphアルゴリズムに戻すと、2ステップ後に fetch the descendants of and link a module scriptアルゴリズムが呼ばれています。このアルゴリズムのステップ5にこのように書かれています。
If parse error is null, then:
- Let record be result's record.
- Perform record.Link().
ここでLink()の呼び出しを見つけることができました。ただ、HTML仕様書の場合はLink()の中で(HostResolveImportedModule経由で)ネットワークアクセスを行うのではなく、同アルゴリズムのステップ1から呼び出されているfetch the descendants of a module scriptアルゴリズムの中でリクエストの発行が規定されており、どのようにHTTPリクエストが作られるのかといったことまで詳細に仕様化されています。これこそまさに、Link()よりも先に投機的に読み込みを開始する例です。
Evaluate()の実行
さて、元のprepare a scriptアルゴリズムでは、最後のステップでexecute a script blockを実行しています。これは内部でrun a module scriptアルゴリズムを呼び出しており、そのステップ7でEvalute()の呼び出しが規定されています。
Otherwise:
- Let record be script's record.
- Set evaluationStatus to record.Evaluate().
長くなりましたが、このようにしてHTML仕様書が「Source Text Module Recordの作成→Link()呼び出し→Evaluate()呼び出し」という一連の流れを規定していることが分かりました。<script type="module"></script>
と書くとモジュールが実行されるのは、このようなHTML仕様書とECMAScript仕様書の協業によって成されていることなのです。
環境依存の操作の定義
ECMAScript仕様では、HostResolveImportedModuleなどの操作は具体的には定義されず、制約のみが規定されていました。HTML仕様書ではこれらの具体的な内容も定義されています。つまり、HTML文書の処理の一環としてモジュールを実行する際にはこの特定の内容を用いなければなりません。これはHTML仕様書の以下の節で定義されています。
dynamic importの挙動
dynamic importはこの記事でも何度も出てきていますが、import("./a.mjs")
のように関数呼び出しに類似した構文でモジュールをインポートできるもので、大きな特徴が2つあります。一つはimport(`${name}.mjs`)
のようにインポート先を動的に決められるというもので、もう一つはインポートを実行するタイミングをプログラムで制御できる点です。
この2つの特徴から、dynamic importはこれまで説明したようなフローには乗りません。Module Recordのような概念は共通ですが、独自の処理がされているところがあります。
ということで、import()
構文の挙動を定義している12.3.10 Import Callsを見てみましょう。
ImportCall:
import
(
AssignmentExpression)
- Let referencingScriptOrModule be ! GetActiveScriptOrModule().
- Let argRef be the result of evaluating AssignmentExpression.
- Let specifier be ? GetValue(argRef).
- Let promiseCapability be ! NewPromiseCapability(%Promise%).
- Let specifierString be ToString(specifier).
- IfAbruptRejectPromise(specifierString, promiseCapability).
- Perform ! HostImportModuleDynamically(referencingScriptOrModule, specifierString, promiseCapability).
- Return promiseCapability.[[Promise]].
import()
呼び出しはPromiseを返しますが、そのPromiseをステップ4で初期化し、8で返していることが分かります。実際にモジュールを読み込む処理をしているのがステップ7のHostImportModuleDynamicallyです。Hostと名前に付いていることから分かる通り、これもまた環境依存の処理です。
HostImportModuleDynamically
具体的な挙動が環境依存の処理には大抵説明と制約が書かれていますから、チェックしてみましょう。まずは説明です。
HostImportModuleDynamically is an implementation-defined abstract operation that performs any necessary setup work in order to make available the module corresponding to the ModuleSpecifier String, specifier, occurring within the context of the script or module represented by the Script Record or Module Record referencingScriptOrModule. (referencingScriptOrModule may also be null, if there is no active script or module when the import() expression occurs.) It then performs FinishDynamicImport to finish the dynamic import process.
要約すると、「与えられたspecifierを頼りにモジュールを何らかの手段で読み込み、読み込み終わったらFinishDynamicImportを呼ぶような処理である」とされています。
HostImportModuleDynamicallyが持つ制約については長いので引用しませんが、こちらも要約すると以下のようなことが書かれています。
- HostImportModuleDynamicallyが呼ばれたなら、将来的に必ずFinishDynamicImportが呼ばれなければならない。
- 同じモジュールに対して複数回HostImportModuleDynamicallyが呼ばれたら、必ず同じ結果にならなければいけない。
- HostImportModuleDynamicallyが成功した場合、その後ここで読み込まれたモジュールに対してHostResolveImportedModuleを呼び出したなら、その結果はすでにEvaluate()を実行済みのModule Recordでなければならない。
特に最後のものが重要です。この制約から、HostImportModuleDynamicallyの処理に何が期待されているのか明らかになります。Evaluate()はLink()が成功してから呼び出さなければいけないことも踏まえると、HostImportModuleDynamicallyが実行されたら、何らかの方法で処理系は以下のことを行わなければいけないのです。
- 示されているモジュールを動的に読み込む。
- そのモジュールに対応するModule Recordを作成する。
- そのModule Recordに対応するLink()とEvaluate()を実行する。
ここまで終えて初めてFinishDynamicImportが呼び出されます。そのことを踏まえて次はFinishDynamicImportを調べましょう。
FinishDynamicImport
FinishDynamicImportについては具体的な挙動が定義されています。説明には次のように書かれています。
FinishDynamicImport completes the process of a dynamic import originally started by an import() call, resolving or rejecting the promise returned by that call as appropriate according to completion. It is performed by host environments as part of HostImportModuleDynamically.
つまり、import()
により読み込まれたモジュールの読み込みが完了し次第、import()
によって作られたPromiseを解決するのがFinishDynamicImportの役目です。
なお、import()
の返り値のPromiseは、モジュール読み込みが成功した場合はそのモジュールの名前空間オブジェクトです。これを取得するにはModule Recordが必要ですが、その取得のためにはすでに説明したHostResolveImportedModuleを使用します。このHostResolveImprotedModuleの結果がどうなるのかはすでに説明した通りで、HostImportModuleDynamicallyが持つ制約によりすでにEvaluate()が実行済みのModule Recordとなります。
ちなみに、HostResolveImprotedModule自体が「同じモジュールを読み込んだら同じModule Recordを返さなければならない」という制約を持ちますから、複数回同じモジュールをdynamic importしたり、すでに静的にimportされているものをdynamic importしてももう一度同じモジュールが実行されることはありません(すでに説明した通り、Module RecordのEvaluate()を複数回呼び出しても最初の1回しか実際に実行しないという制約があるため)。
dynamic importの挙動はどこまで予測可能か
dynamic importの定義を見ると、大部分がHostResolveImportedModuleの中に押し込められていて、ほとんど何も定義されていないように見えます。これは、そもそもモジュールという概念がECMAScriptの枠に完全には収まらないものであることから仕方ありません。dynamic importされたモジュールがSource Text Module Recordでは無かった場合にECMAScript仕様書でできることはほとんどありません。
最も抽象的なAbstract Module Recordの場合を考えると、ECMAScript仕様書に見えているインターフェースはLink()とEvaluate()だけです。よって、モジュールを実行するというのは、何らかの方法でModule Recordを得たのちにこの2つのメソッドを実行することを指します。
Module Recordを得る部分は完全に環境によりけりですから、その部分が環境依存となっているのは問題ありません。しかし、import()
構文の意味は「モジュールを読み込んで実行する」(そして実行結果をモジュール名前空間オブジェクトとして返す)というものですから、ただ読み込むだけでなく「実行する」ことも必要です。
実は、この「実行する」という要求を表すのがHostImportModuleDynamicallyの制約にある「得られたModule RecordはすでにEvaluate()を実行された状態でなければならない」という制約と、「将来的に必ずFinishDynamicImportを呼ばなければいけない」という制約なのです。永遠にFinishDynamicImportを呼ばないわけには行きませんが、FinishDynamicImportを呼ぶ前には必ずModule Recordを作ってEvaluate()を実行しておかなければいけません。よって、HostImportModuleDynamicallyは「モジュールを読み込み、Module Recordを作り、Link()とEvaluate()を実行する」という一連の操作を暗に要求しているのです。具体的な実装を与えずとも、制約によってこれくらいの制御は可能であるという例になっていますね。
ただし、モジュールの読み込みは外的要因で失敗することもあり得るので、処理系には失敗するという選択肢も残されています。よって、実際には「dynamic importが発生したら何もせずに常に失敗を返す」というのもECMAScript処理系としては妥当なものになってしまいます。他にも、将来的に呼び出すことが求められているとはいえいつとは指定されていないので、10年や20年後でも許されるでしょう。ECMAScriptが具体的なモジュール読み込みプロセスに関わらないためこれらは仕方がありませんし、これはdynamic importに限った話でもないのですが。
top-level awaitがES Modulesに与える影響
このおまけではここまでECMAScriptのモジュールの仕様を眺めてきましたが、実はこれらはtop-level awaitが導入される以前のものです。この記事はtop-level awaitが主題なので、top-level awaitが仕様にどのように影響を及ぼすのか解説します。top-level awaitのプロポーザルには(というより全てのStage 3プロポーザルには)仕様テキストが用意されており、典型的にはECMAScript本体との差分という形で表示されます。この記事をここまで読んだ方ならば差分を読むだけで理解できるかもしれませんね。
いくつか重要なところをピックアップします。まずはEvaluate()の定義に対する変更です。従来Evaluateは「モジュールの実行に成功したらundefinedを返し、失敗したらエラーをthrowする」として定義されていましたが、「Promiseを返す」に変更されています。これは、この記事で最初の方に述べた「トップレベルの実行が非同期的になる」という点が反映されています。モジュールの実行は今やawait
で中断される可能性があり、Evaluate()が返すPromiseはそのような中断がもう無くなって完全に実行が終了した段階で成功裏に解決されます。
また、ExecuteModule()の定義にも非同期に対応するための変更が加えられています。ただし、こちらはPromiseを返すのではなく、「実行が終わったら与えられたPromiseを解決する」という形になっています。このためにPromise Capability(Promise本体としてに対するresolve・reject関数がセットになったもの)を受け取れるように変更されました。
特筆に値するのは、Link()の定義が何も変わっていないことです。Linkはあくまで依存関係グラフを構築することが主眼のフェーズであり実際に実行するフェーズではありませんから、top-level awaitの影響を受けないということですね。一方で、Evaluate()については返り値をPromiseにするために少なくない量の変更が入っています。
また、ParseModuleの定義を見ると興味深いことが書かれています。ステップ12です。
Let async be body Contains
await
.
そして、このasyncフラグは結果のSource Text Module Recordの[[Async]]フラグに格納されます。つまり、あるモジュールが非同期かどうかはそれがトップレベルにawait
を含むかどうかによって決められるのです。これは、top-level awaitがない時代のモジュールにおける後方互換性を守るためです。逆に言えばこれは、トップレベルにawait
を含んでいれば(実際にawait
が実行されないとしても)挙動に影響があるということです。
同期モジュールと非同期モジュールの違い
ちょっと唐突ですが、同期モジュールと非同期モジュールの違いがわかる例を用意しました。
console.log("I am a.mjs");
import("./a.mjs").then(() => console.log("a.mjs is loaded"));
Promise.resolve().then(async () => {
await null;
await null;
console.log("I am root.mjs");
});
とても人工的な例ですが、このroot.mjs
を実行すると、筆者の手元のMacとv8では次の順に表示されます。
I am a.mjs
a.mjs is loaded
I am root.mjs
dynamic importでa.mjs
を読み込んだ時点で即座にI am a.mjs
と表示されます。その後import("./a.mjs")
が解決され、とa.mjs is loaded
と表示されます。
一方のI am root.mjs
は、Promise.resolve()
とawait
を用いて、root.mjs
が実行されてから3 tick後(マイクロタスク実行ループが3巡した後)にI am root.mjs
と表示されます。
a.mjs is loaded
がI am root.mjs
よりも先に表示されることから、import("./a.mjs")
の解決が3 tick以内に行われていることがわかります。
ここで、a.mjs
を次のように変更してみましょう。
console.log("I am a.mjs");
if (false) await null;
こうすると、root.mjs
を実行したときの表示はこのように変化します。
I am a.mjs
I am root.mjs
a.mjs is loaded
公今回新しくawait
を追加したことで、a.mjs
は非同期モジュールになりました。ただし、await
はif (false)
でガードされているため実際にa.mjs
を実行したときの挙動は何も変わないはずです。
しかし、a.mjs
が非同期モジュールになったことで、a.mjs
が読み込まれるのが遅くなりa.mjs is loaded
はI am root.mjs
よりも後に表示されることになります。ただし、root.mjs
のawait null
を一つ増やすと結果が元に戻ります。
要するに、a.mjs
が非同期モジュールになったこと自体を原因として、import("./a.mjs")
が解決されるまでの時間が1 tick長くなったのです。この1 tickの違いがどこで生まれたのかは仕様書を追っていけば説明できますが、そろそろこの記事を書く体力が尽きてきたので省略します。暇な方はぜひ挑戦して記事を書きましょう。
モジュールのimport
とexport
の仕組み
ここまではモジュールがどのように実行されるのかについて焦点をあてて解説しましたが、この記事はES Modules完全理解と銘打っているので、ES Modulesのもうひとつの大きな特徴についても解説します。それはモジュールのimport
とexport
がどのような機構で行われるのかです。
モジュール名前空間オブジェクト
ES Modulesを扱っていると、モジュール名前空間オブジェクトが得られることがあります。これは、import *
構文やdynamic importを使うと得られます。例えば、次の例をnode.jsで実行してみましょう。
export const foo = "foo";
export const bar = "bar";
export default "default";
import * as m from "./a.mjs";
console.log(m);
そうすると、console.log
によって次のように表示されます。
[Module] { bar: 'bar', default: 'default', foo: 'foo' }
[Module]
という表示が、{ bar: 'bar', default: 'default', foo: 'foo' }
がモジュール名前空間オブジェクトであることを示しています。
モジュール名前空間オブジェクトは仕様書上でModule Namespace Exotic Objectと呼ばれています。Exotic Objectというのは、通常の言葉とは異なる特殊な挙動を示すオブジェクトの総称です(他にはProxyや配列といったオブジェクトがexotic objectです)。
上の例ではm
がModule Namespace Exotic Objectですが、それに対してm.foo
などのアクセスをするとa.mjs
からエクスポートされているfoo
の値である"foo"
が得られます。このように、Module Namespace Exotic Objectは「モジュールからエクスポートされている値の集合」を表すようなオブジェクトと見なせます。
このようなプロパティアクセスにおいて、Module Namespace Exotic Objectの[[Get]]内部メソッドが呼ばれます。この[[Get]]内部メソッドの定義を画像で引用します。
この実装は、普通のオブジェクトの挙動を模倣しつつも実態は全然別物という様相です。この実装を読むと察せられることは、Module Namespace Exotic Objectはエクスポート元モジュールのトップレベルスコープに直結しているということです。詳しい説明は後に回して、とりあえず定義を読みましょう。
ステップ1〜2はプロパティ名がシンボルの場合(後述)、ステップ3〜4はプロパティ名が存在しなかった場合にundefinedを返す処理です。存在しないプロパティを取得しようとした場合にundefinedが返るというのはJavaScriptにおいて普通のことですが、Module Namespace Exotic Objectの場合はその判断はプロパティ名がO.[[Exports]]に含まれるかどうかによって行われます。O.[[Exports]]というのはあとで解説しますが、恐らく当該モジュールからエクスポートされている名前の一覧であるという想像がつきます。
ステップ5にあるmというのはModule Recordです。ステップ6ではmのResolveExportモジュールを用いてbindingを得ています。あとで説明しますが、このbindingというのはモジュールにおいて非常に重要な概念で、モジュールがエクスポートする名前ひとつひとつに対して対応するbindingが存在します。
ステップ8ではtargetModuleを得ており、察するにこれはbindingが属するモジュールを表すModule Recordです。それはmと同じではないかと思われる方がいるでしょうが、export * from "module"
構文をはじめとする「再エクスポート」の構文によって、他のモジュールがエクスポートしたbindingをそのままエクスポートしている可能性が存在します。bindingがmが他のモジュールから再エクスポートしたものだった場合、targetModuleはmとは異なり、オリジナルのモジュールを指すことになります。ステップ10はそのbindingがES2020で追加されたexport * as ns
構文でエクスポートされていた場合に対応するものです。この場合エクスポートされているのは別のModule Namespace Exotic Objectとなります。
ステップ11〜14が通常の場合です。ステップ11〜13ではモジュールの[[Environment]]内部プロパティを通してEnvironmentRecordを取得しており、ステップ14でそのGetBindingValueメソッドを用いて値を取得しています。
EnvironmentRecordとは、変数のスコープに対応する仕様書上の概念です。GetBindingValueメソッドはそこから変数の値を取得するメソッドです。これらはモジュールに特有の概念ではなく、単純に「変数の値を取得する」という処理にも使われます。つまり、ステップ11〜14をまとめると「エクスポート元モジュールの(トップレベル)変数スコープからから変数の値を取得する」ということになるのです。
このことは、このような例を通して確かめられます。
export let foo = "foo";
export const setFoo = value => {
foo = value;
};
import * as m from "./a.mjs";
console.log(m.foo); // "foo"
m.setFoo("hello, world!");
console.log(m.foo); // "hello, world!"
a.mjs
はfoo
とsetFoo
がエクスポートされており、setFoo
は呼び出されるとfoo
を書き換えます。root.mjs
では、m.setFoo
を呼び出すことでa.mjs
のfoo
の値を書き換えています。
すると、何ということでしょう。m.setFoo
を呼び出すとm.foo
の値が書き換えられてしまいました。これが、Module Namespace Exotic Object m
がa.mjs
のスコープに直結していることを示しています。
Module Namespace Exotic Object はいつ作られるのか
上の例ではm
がModule Namespace Exotic Objectでした。では、このm
はいつどのように作られるのでしょうか。明らかに、m
はimport
文の作用によって作られます。m
がいつ作られるのかをより厳密に調べるには、次の実験が有用です。すなわち、root.mjs
をこのように変えてみます。
console.log(m.foo); // "foo"
m.setFoo("hello, world!");
console.log(m.foo); // "hello, world!"
import * as m from "./a.mjs";
このようにimport
文を末尾に動かしても、この例は動作します。このことは、モジュールが実行される(Execute()が呼び出される)よりも前にm
がすでに用意されていることを示唆しています。ということは、m
がLink()の段階で用意されているという説が非常に有力です。ということでLink()の処理の中から当該処理を探し出したいところですが、もう少し考えてみましょう。この処理はSource Text Module Recordに特有であることを考えると、Link()の処理中で呼び出されるInitializeEnvironment()メソッド中に記述されているというあたりを付けることができますね。ということで見るべきはSource Text Module RecordのInitializeEnvironment()メソッドの定義です。これは非常に長いので引用はせずに進みます。
このメソッドのステップ6〜8を見ると、このモジュール用の新しいEnvironmentRecord(≒このモジュールのトップレベル変数スコープ)が作られていることが読み取れます。そして、ステップ9でこのモジュールが含む全てのimport
文に対してループしているようです。import * as m from "..."
構文の場合はステップ9-cが実行されます。
i. Let namespace be ? GetModuleNamespace(importedModule).
ii. Perform ! envRec.CreateImmutableBinding(in.[[LocalName]], true).
iii. Call envRec.InitializeBinding(in.[[LocalName]], namespace).
ステップiでGetModuleNamespaceを用いてインポート元モジュールに対するModule Namespace Exotic Objectを作成しています。ステップii, iiiではEnvironmentRecordのCreateImmutableBinding・InitializeBindingを用いてこのモジュールのトップレベルスコープにインポートされた名前の変数(この場合はm
)を生やしています。
ステップ9-dでそれ以外の場合も扱っていますので、合わせて見てみましょう。
また、「Module Namespace Exotic Objectがいつ作られるのか」という問いに答えるにはGetModuleNamespaceの中身を見に行く必要があります。ざっと読むと、インポート元のモジュールからexportされている名前を全部取得してそれをModuleNamespaceCreateに渡しているように見えます。このModuleNamespaceCreateは、オブジェクトを作ってModule Namespace Exotic Object用の内部スロットを定義するだけです。先ほど[[Exports]]内部プロパティが出てきましたが、これはエクスポートされている名前の一覧をArray.prototype.sort
でソートしたものであると書いてあります。
Environment Recordとbinding
ここまでの説明では、EnvironmentRecordというものが何度も出てきました。これは変数のスコープを表す概念であり、そこに属する変数はbindingと呼ばれます。
仕様書の8.1.1 Environment Recordsで定義されている通り、スコープに属するそれぞれの変数はbindingとしてEnvironment Recordに格納されています12。bindingはImmutable BindingとMutable Bindingの2種類があり、Immutable Bindingはその名の通り書き換え不可のbindingで、const
宣言により作られます。また、上で見たimport * as m
構文で作られるm
のbindingもこのImmutable Bindingです。
例えば、次のプログラムを見てみましょう。
// Environment Record 1
const foo = "foo";
let bar = "bar";
if (true) {
// Environment Record 2
let foo = "123";
}
このプログラムには2つのEnvironment Recordが関わっています。トップレベルのEnvironment RecordであるEnvironment Record 1と、if
文のブロックの中のスコープを表すEnvironment Record 2です。前者はfoo
という名前のImmutable Bindingとbar
という名前のMutable Bindingをもち、後者はfoo
という名前のMutable Bindingを持っています。
変数の操作は仕様上ではこれらのEnvironment Recordからbindingの値を取得したり、bindingの値を書き換えたりという操作として定義されています。
また、モジュールのトップレベルスコープを表すModule Environment Recordにおいては、第3の種類のbindingであるImmutable Imported Bindingが存在します。これは他のモジュールからインポートされたbindingを表すImmutable Bindingであり、その値を取得しようとした場合は元のモジュールのEnvironment Recordから再帰的に値を取得します。Immutable Imported Bindingは通常のimport
文により作られます。
少し前に出てきた例を少し変えることでこれを確かめましょう。
export let foo = "foo";
export const setFoo = value => {
foo = value;
};
import { foo, setFoo } from "./a.mjs";
console.log(foo); // "foo"
setFoo("hello, world!");
console.log(foo); // "hello, world!"
a.mjs
は元々の例と全く変わりませんが、root.mjs
が変わっています。今度はimport * as m
構文を用いず、import { foo, setFoo }
としてfoo
とsetFoo
にインポートされます。これはroot.mjs
のEnvironment Record内にfoo
とsetFoo
というImmutable Imported Bingingを作ります。これが意味するところは、root.mjs
の中でfoo
の値を取得すると常にa.mjs
のfoo
の値が取得されるということです。よって、先ほどと同様にsetFoo
を呼び出すとfoo
の値が変わります。
このように、Immutable Imported Bindingである変数においては、インポート先でその値を全く操作しなくてもインポート元により値が変わることがあります。これはImported Bindingに特有の事象であり、慣れていないと困惑しますから注意が必要です。特に、「import
文は値をインポートしているのではなくbindingを(間接的に)インポートしている」という理解が重要です。
モジュールからエクスポートされる名前はどう決定されるのか
モジュールが名前をエクスポートするのは非常に基本的な事項であり、Abstract Module Recordですら名前をエクスポートすることができます。これに関連して、Abstract Module Recordが持つ2つのメソッドが定義されています。すなわち、GetExportedNamesとResolveExportです。
GetExportedNames([exportStarSet])
Return a list of all names that are either directly or indirectly exported from this module.
ResolveExport(exportName [, resolveSet ])
Return the binding of a name exported by this module. Bindings are represented by a ResolvedBinding Record, of the form { [[Module]]: Module Record, [[BindingName]]: String }. If the export is a Module Namespace Object without a direct binding in any module, [[BindingName]] will be set to "namespace". Return null if the name cannot be resolved, or "ambiguous" if multiple bindings were found.
Each time this operation is called with a specific exportName, resolveSet pair as arguments it must return the same result if it completes normally.
GetExportedNamesはそのモジュールがエクスポートする名前の一覧を取得するものです。exportStarSetという引数がありますが、これはSource Text Module Recordにおいてexport *
により形成される無限ループの対策として用いられるものです。Source Text Module Recordの場合はあらかじめParseModule時に自身が持つ
export
文の一覧を[[LocalExportEntries]], [[IndirectExportEntries]], [[StarExportEntries]]の3つに分類して保持しており、GetExportedNamesはそれらの名前を全部列挙して返す処理を行います。[[LocalExportEntries]]はローカル変数をexport
文でエクスポートした場合、[[IndirectExportEntries]]はimport
文でインポートした名前をそのままexport
文で再エクスポートした場合、そして[[StarExportEntries]]はexport * from "module"
構文で他のモジュールがエクスポートする名前を全てい再エクスポートする場合です。[[StarExportEntries]]に対しては、そこで参照されているモジュールに再帰的にGetExportedNamesを呼び出すことでエクスポートされた名前の一覧を完成させます。
余談ですが、このことから「import
された名前をそのまま再エクスポートする」という行為は特別な扱いを受けることが分かります。
import { foo } from "./a.mjs";
export { foo };
export const foo2 = foo;
このようにした場合、b.js
はfoo
とfoo2
の2つの名前をエクスポートしていることになりますが、前者は[[IndirectExportEntries]]に分類され、後者は[[LocalExportEntries]]に分類されます。また、b.mjs
のEnvironment Recordにおいては前者はImmutable Imported Bindingである一方、後者はImmutable Bindingとなります。
これが意味することは、a.mjs
のfoo
の値が変更された場合、b.mjs
のfoo
はその影響を受ける一方でfoo2
は影響を受けないということです。foo2
に対するfoo2 = foo
という代入はb.mjs
が実行された瞬間に行われるため、foo2
の値はその瞬間のfoo
で固定され、その後a.mjs
のfoo
の値が変更されてもfoo2
の値には変更されません。foo2
がfoo
を参照するImported Bindingではなくただのローカル変数であることから考えれば、これは自然な振る舞いですね。
一方のResolveExportは、そのモジュールからエクスポートされている1つの名前に対するResolved Binding(これはbindingの所属モジュールとそのモジュールにおける名前のペアです)を返すメソッドです(名前が見つからなかった場合はnullを返すと言った挙動もあります)。特に名前の再エクスポートがある場合、オリジナルのモジュールまで再帰的に辿っていくことになります。
ResolvedExportは、Source Text Module Recordにおけるimport
文の処理(InitializeEnvironment()時)に用いられます。例えば、b.mjs
がimport { foo } from "./a.mjs";
というimport
文を持っていた場合、このimport
文の初期化時にはa.mjs
を表すModule Recordに対してResolveExport("foo")という問い合わせが実行されることになります。すると、ResolveExportはfoo
の大元のbindingを探して返してくれるのです。
a.mjs
がexport const foo = ...
のようにしていればfoo
の大元のモジュールはa.mjs
ですが、もしこれはexport { foo } from "./other.mjs"
のような再エクスポートだった場合は大元のモジュールはother.mjs
(またはさらに別のモジュール)であることになります。
大元のモジュールを知ることは、import
文によるImmutable Imported Bindingの向き先を決めるために必要になります。
また、少し前にModule Namespace Exotic Objectのプロパティが取得されたときの処理を見ましたが、このときもResolveExportを用いてbindingを解決してからそのbindingの値を読むという処理を行なっています。
おまけのまとめ
おまけとして、ES Moduleに関する仕様を一通り解説しました。
基本的な事項として、モジュールというものは仕様書の中ではModule Recordとして表されるということをまず説明しました。Abstract Module Record, Cyclic Module Record, Source Text Module Recordという3階層に区分されたインターフェース定義や、HostResolveImportedModuleとかHostImportModuleDynamicallyといったインターフェースを通して、外部環境による拡張可能性が提供されています。
次に、モジュールが実行されるまでにはModule Recordを作る、Link()を作る、Evaluate()を実行するという3ステップがあることも解説しました。HTML仕様書ではscript
要素の解釈に際して実際のこの3ステップを実行していることも解説しました。
ECMAScript仕様においてモジュールの周りは仕様書リーディングの難易度が高い部分ですが、この記事の解説があれば問題なく読みこなせるでしょう。もし自分でJavaScriptを実行する何らかの仕様を書きたくなっても書くことができますね。Module Recordを作成し、Link()とEvaluate()を実行すればOKです。
最後に、モジュールのインポート・エクスポートの仕組みを解説しました。インポートされたものはモジュールのトップレベルに位置するModule Environment Recordにbindingとして登録されることで、インポート先で使用できるようになるのでした。また、再エクスポートという概念がインポート・エクスポートの解決を多少ややこしくしていることが分かりました。再エクスポートの概念を理解するためには、モジュール間でやり取りされるのは値ではなくbindingであるということをしっかりと分かっている必要があります。再エクスポートでは、概念上はまさに同じbindingをエクスポートしているのです。
まとめ(再掲)
実はこの記事のまとめは途中にあったのですが、途中で記事を読むのに飽きて最後までスクロールしてきた方のためにまとめを再掲します。
この記事では、Stage 3となりTypeScript 3.8によるサポートが追加されたtop-level awaitについて解説しました。top-level awaitが本質的にはES Modulesの意味を変えるものであることを説明し、併せてES Modulesについても必要に応じて解説しました。
TypeScript 3.8によりサポートされたといっても構文を理解できるようになったという話で、実際にtop-level awaitが使えるようになるまでには各種実行環境やバンドラによるサポートが必要となりますから、まだ先の話でしょう。
top-level awaitは非同期処理を行なった結果をエクスポートしたい場合などにたいへん便利ですから、実用化されたらぜひ使っていきたいですね。
-
HTMLから呼び出されるJavaScriptの場合は、
<script type="module">
によって読み込まれたものがモジュールと見なされ、それ以外はスクリプトです。TypeScriptでコンパイルする場合には、import
文かexport
文を含むファイルがモジュールと見なされます。 ↩ -
本当に同時に実行されるのか、それとも同時に実行するのかは1つだけなのかという違いを並列 (parallel)・並行 (concurrent)という用語で区別することもあるようですが、この定義に関しては信頼できる情報源がいまいち見当たらないので個人的には使用を避けています。信頼できる(学術的な)ソースをお持ちの方はぜひご連絡ください。 ↩
-
なお、node.jsでは(
--harmony-top-level-await
オプションを有効にしても)dynamic importとtop-level awaitの組み合わせをまだサポートしていないようです。この例はv8 (d8) を直接動作させることで動作を確認できます。 ↩ -
実際にはECMAScriptのモジュールではJavaScriptファイル以外をモジュールとして読み込むことも可能(もちろんプラットフォームのサポートが必要)な仕様になっていますが、静的
import
の場合はその場合も「何がexportされているか」は実行する前に判明していなければいけません。node.jsではESモジュールからCommonJSモジュールを読み込む場合は必ずdynamic importを使用しなければいけませんが、その理由はこの点にあります。 ↩ -
仕様書ではroot of the cycleと呼ばれています。これはその強連結成分の入口となったモジュールであり、より厳密に言えば依存関係グラフを深さ優先探索するときに行きがけ順につけたID(0, 1, 2……と振られていく)が強連結成分の中で一番小さいモジュールとして定義されます。 ↩
-
デッドロックの定義は2つ以上のプロセス(など)が互いを待っている状態と定義されるらしいので、この状態をデッドロックと呼ぶのが妥当なのかは少し怪しいかもしれません。一応、
a.mjs
とb.mjs
が互いを待ち続けていると見ることもできますが。 ↩ -
ファイルを取得しないと次の依存先がわからないので、愚直に読み込むと依存関係グラフの深さの数だけサーバーと往復しないといけないという問題があります。この問題を解決すると思われていたHTTP/2 Server Pushもうまくいっていません。 ↩
-
記事タイトルに「TypeScript 3.8でサポートされた」とありますが、TypeScriptも当然ながらtop-level awaitがあっても何かトランスパイルするわけではありません。出力にもそのままtop-level awaitが残ります。TypeScriptはあくまでtop-level awaitを従来文法エラーとしていたのを許すようにしただけです。 ↩
-
Webpackは今の所JavaScriptソースコードであるようなモジュール(またはwasmモジュール)を対象として扱うため、CSSファイルは典型的には
style-loader
によってJavaScriptに変換されます。 ↩ -
node.jsでモジュールからdynamic importでCommonJSモジュールを読み込む場合はCommonJSモジュールはSource Text Module RecordではなくCyclic Module RecordかAbstract Module Recordとして扱われそうです(CommonJSモジュールのソースコードはES ModulesでいうModuleに適合するものではないため)。node.jsの挙動は仕様化されていないので証左はありませんが。 ↩
-
尤も、循環する依存関係がある場合にはこの制約は完全には満たされていないのですが。実際のところ、Cyclic Module Recordではその場合の処理が明示されており、Abstract Module Recordの場合は循環参照を作らないので大きな問題ではありません。 ↩
-
JavaScriptのスコープはネストしていますが、Environment Record自体にはその対応は含まれておらず、あくまで一つのスコープを司る比較的単純な概念として存在しています。ネストしたスコープの探索はGetIdentifierReferenceが担当しています。 ↩