330
191

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

top-level awaitがどのようにES Modulesに影響するのか完全に理解する

Last updated at Posted at 2020-02-16

先日、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ではソースコード(ファイル単位)はスクリプトかモジュールかのいずれかに分類され、外的要因や内的要因によってどちらなのかが決められるのです1import文やexport文もモジュールでのみ利用できる構文です。

top-level awaitの嬉しい点 (2) 非同期処理の結果をエクスポートできる

書き捨てのスクリプトだけでなく、他のファイルから依存されるモジュールの場合もよい点があります。それは、非同期処理の結果をエクスポートできる点です。例えば、設定ファイルを読み込んだ結果をエクスポートしたければ次のようにすることができるでしょう(実際は読んだあとに追加処理があるでしょうが省略します)。

export const content = await readFile("./setting", "utf8");

export文は必ずトップレベルに存在することから、top-level awaitが無いとこのような書き方は不可能です。例えば、次のようにしてはいけません。これだと、モジュールが読み込まれた瞬間はcontentundefinedとなってしまうからです。

だめな例
// だめな例
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.mjsroot.mjsの2つのファイルがあるとします。foo.mjsはさっきの例のもので、root.mjsfoo.mjsを読み込みます。

foo.mjs
export const content = await readFile("./setting", "utf8");
root.mjs
import { content } from "./foo.mjs";
console.log(content);

ここでroot.mjsを実行するとどうなるのか見てみます。root.mjsfoo.mjsに依存している(foo.mjsからインポートしている)ため、root.mjsよりも先にfoo.mjsが実行されます。これはtop-level awaitが無い場合でも同様です。

ここで実行されるfoo.mjsはtop-level awaitを含んでいます。つまり、foo.mjsの実行が完了するまでには時間がかかるということです。特に、root.mjsfoo.mjsからcontentをインポートして使用していますが、foo.mjscontentの中身を計算し終えるまでには時間がかかります。

top-level awaitがある場合、foo.mjsの実行が完了してからroot.mjsが実行されます。これにより、root.mjsfoo.mjsがエクスポートするcontentを計算し終えてから実行を開始するため、contentの内容をちゃんと得ることができます。

このように、top-level awaitを使用する場合はそれに依存するモジュールの実行をブロックすることになります。これはtop-level awaitの重要な特徴ですから理解しておきましょう。

並列な依存関係がある場合

ここまでは、さもありなんという内容でした。次は登場人物をひとり増やしましょう。

例えば、次のように3つのモジュールがあるとします。root.mjsを実行した場合にどのような挙動をするか説明できるでしょうか。説明を簡単にするため、sleep関数はいい感じにもともと用意されているものとします(例えばsleep(5000)は5秒後に解決するPromiseを返します)。

a.mjs
await sleep(5000);
console.log("I am a.mjs");
b.mjs
await sleep(3000);
console.log("I am b.mjs");
root.mjs
import "./a.mjs";
import "./b.mjs";
console.log("I am root.mjs");

root.jsは2つの依存関係a.mjsb.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.mjsb.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.mjsI am a.mjsI am root.mjsとなります。

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の順番をより細かく制御できるようになります。

root.mjs
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から一旦離れて同期的なモジュールの循環参照について理解しましょう。

a.mjs
import "./b.mjs";
console.log("I am a.mjs");
b.mjs
import "./a.mjs";
console.log("I am b.mjs");
root.mjs
import "./a.mjs";
console.log("I am root.mjs");

このサンプルでは、root.mjsa.mjsb.mjsa.mjsという循環参照が発生しています。なお、実際に実行した結果はこうです。

I am b.mjs
I am a.mjs
I am root.mjs

一見すると「a.mjsを実行すると先にb.mjsを実行する必要があり、b.mjsを実行すると先にa.mjsを実行する必要がある」という無限ループに陥るように思えますが、そうはなりません。この場合は依存先よりも先に自身が実行されるという現象が発生します。

具体的な実行順は、依存関係グラフを深さ優先探索することで決められます。依存関係グラフに属する各モジュールは葉から順に(帰りがけ順で)実行されます。ただし、同じモジュールを訪れるのは1回だけです。

今回の例の場合は、root.mjsa.mjsb.mjsという順で依存関係が探索されますが、その次のb.mjsa.mjsという依存関係は実行順を決める際に無視されます。これは、a.mjsは探索の途中ですでに一度訪れているからです。よって、実行順は葉から順に辿るのでb.mjsa.mjsroot.mjsとなります。

ここで、b.mjsimport "./a.mjs"という依存を持っているにもかからわず、a.mjsよりもb.mjsが先に実行されるという現象が発生しました。このように、循環参照が発生した場合は探索順に依存して一部の依存関係を無視することで実行順を決定します。

ここで少し気になるのは、「a.mjsよりも先にb.mjsの内容が実行されるのなら、b.mjsからa.mjsがエクスポートするものを使用したらどうなるのか」ということです。これを確かめるようには例を次のように書き換えます。

a.mjs
import "./b.mjs";
console.log("I am a.mjs");

export const a = "aaaaaa";
b.mjs
import { a } from "./a.mjs";
console.log("I am b.mjs", a);
root.mjs
import "./a.mjs";
console.log("I am root.mjs");

root.mjsa.mjsb.mjsa.mjsという循環参照があるのは変わりませんが、b.mjsa.mjsがエクスポートするaを使用しています。

問題は、先ほど説明した理屈によりa.mjsよりも先にb.mjsが実行されることです。つまり、a.mjsexport 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を次のように変えることで、エラーを発生させずに次のような出力を得ることができます。

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の内容が勝手に書き換わっているようにも見えます。実際には、importexportでやりとりされているのが値ではなくbindingであることからこのような挙動が実現されています。実際、webpackなどのバンドラを通すと、a__importedModule.aみたいに変換され、aは変数ではなくオブジェクト(モジュール名前空間)のプロパティとなります。これにより「import後に値が変わる」という挙動を再現しているのです。また、バンドラを用いる場合、未初期化のbindingを用いた場合はエラーが発生するのではなくundefinedとなることがあります。

また、そもそもexportされていないものをimportするのは、自分より先にa.mjsが実行されるかどうかとは無関係にエラーとなります。これは、何がそのモジュールからexportされているかはコードを実際に実行しなくてもパースするだけで分かるからです4

b.mjs
// こうするとエラーが発生
import { abcdefg } from "./a.mjs";

循環参照がある場合 (2) top-level awaitとの関係

以上で説明した循環参照のハンドリングは、top-level awaitがある場合でも基本的には同様に成り立ちます。

a.mjs
import "./b.mjs";

await sleep(3000);
console.log("I am a.mjs");
b.mjs
import "./a.mjs";

await sleep(5000);
console.log("I am b.mjs");
root.mjs
import "./a.mjs";
console.log("I am root.mjs");

この例は先ほどと似ていますが、a.mjsb.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は全て同時に解決されるというものです。強連結成分というのは要するに互いに循環参照しているモジュールたちのことです。このことは次のような例で確かめられます。

a.mjs
import "./b.mjs";

await sleep(5000);
console.log("I am a.mjs");
b.mjs
import "./a.mjs";

await sleep(3000);
console.log("I am b.mjs");
root.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.mjsb.mjsは普通に循環参照を形成しており、root.mjsはそれぞれに対してdynamic importを行います。これによって、a.mjsb.mjsがそれぞれ読み込まれた瞬間にconsole.logが実行されます。このroot.mjsを実行したらどのような挙動になるかはお分かりでしょうか。ただし、ファイルシステムからファイルを読み込んでそのJavaScriptファイルを実行するのにかかる時間は1秒より十分短いものとします。

答えは次の通りです。

  • 実行から3秒後にI am b.mjsと表示される。
  • そのさらに5秒後(実行から合計8秒後)にI am a.mjsと表示される。
  • その直後にa.mjs is laodedb.mjs is loadedの順で表示される。

この挙動になる理由を順に説明します。

  1. まず、root.mjsを実行するとdynamic importによりimport("./a.mjs")が実行されます。直後のawait sleep(1000)によりroot.mjsの実行は中断します。import("./a.mjs")の返り値であるPromiseを$P_a$とします。
  2. この間に、import("./a.mjs")によりa.mjsが読み込まれます。これはb.mjsimportしているので、さらにb.mjsが読み込まれます。これはa.mjsへの循環参照を持ちますが、先ほど説明したメカニズムにより、b.mjsが先に実行されます。b.mjsawait sleep(3000)にさしかかって待機します。
  3. 開始から1秒後にroot.mjsawaitが終了し、import("./b.mjs")が実行されます。b.mjsはすでにa.mjs経由で実行されているため、ここでは何も起こりません。import("./b.mjs")の返り値であるPromiseを$P_b$とします。
  4. 開始から3秒後に./b.mjsawaitが終了し、console.logによりI am b.mjsが出力されます。ここが一番のポイントですが、b.mjsの実行が終了しても**$P_b$はまだ解決されない**点に注意してください。
  5. b.mjsの実行が終了したので、それに依存していたa.mjsの実行が開始されます。これはawait sleep(5000);に差し掛かって停止します。
  6. 開始から8秒後にa.mjsawaitが終了し、console.logによりI am a.mjsが出力されます。
  7. この直後、$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.mjsimportしてできたPromiseですが、これが(a.mjsの完了を意味する)$P$に繋がっているというのが特徴的です。これは、import("./b.mjs")の時点ですでにb.mjsが実行済であり、かつb.mjsを含む強連結成分の“親”5a.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の挙動を使ってデッドロックを発生させることができます。具体例を見てみましょう。

a.mjs
import "./b.mjs";

await import("./b.mjs");
console.log("I am a.mjs");
b.mjs
import "./a.mjs";
console.log("I am b.mjs");
root.mjs
import "./a.mjs";
console.log("I am root.mjs");

この状態でroot.mjsを実行すると、I am b.mjsだけが表示された状態で停止します。プロセス自体は終了せずに停止することから、デッドロックに陥ったことが伺えます。では、なぜこれはデッドロックとなるのか考えてみましょう。

静的なimport文によるroot.mjsa.mjsb.mjsa.mjsという依存関係がありますから、これまでの例と同様にまずb.mjsが実行されます。何の問題もなくI am b.mjsが表示されます。次に実行されるのはa.mjsですが、ここでawait import("./b.mjs") に差し掛かります。

ここで重要な点は、a.mjsb.mjsは静的なimport文によって循環参照を作っており、同じ強連結成分に属するという点です。つまり、import("./b.mjs")a.mjsb.mjsの実行が完了するまで解決されません。ここで、今a.mjsを実行中なのに、a.mjsの実行が完了するまでawaitで待ち続けてしまうという状況が発生しました。これがデッドロックです6。よって、このプログラムはこのawaitで永遠に待ち続けるため終了できずI am a.mjsI 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.mjsb.mjsroot.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は)、LinkEvaluateという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と表示されるのはこの段階です。

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:

  1. Let record be result's record.
  2. 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:

  1. Let record be script's record.
  2. 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 )

  1. Let referencingScriptOrModule be ! GetActiveScriptOrModule().
  1. Let argRef be the result of evaluating AssignmentExpression.
  2. Let specifier be ? GetValue(argRef).
  3. Let promiseCapability be ! NewPromiseCapability(%Promise%).
  4. Let specifierString be ToString(specifier).
  5. IfAbruptRejectPromise(specifierString, promiseCapability).
  6. Perform ! HostImportModuleDynamically(referencingScriptOrModule, specifierString, promiseCapability).
  7. 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が実行されないとしても)挙動に影響があるということです。

同期モジュールと非同期モジュールの違い

ちょっと唐突ですが、同期モジュールと非同期モジュールの違いがわかる例を用意しました。

a.mjs
console.log("I am a.mjs");
root.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 loadedI am root.mjsよりも先に表示されることから、import("./a.mjs")の解決が3 tick以内に行われていることがわかります。

ここで、a.mjsを次のように変更してみましょう。

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は非同期モジュールになりました。ただし、awaitif (false)でガードされているため実際にa.mjsを実行したときの挙動は何も変わないはずです。

しかし、a.mjsが非同期モジュールになったことで、a.mjsが読み込まれるのが遅くなりa.mjs is loadedI am root.mjsよりも後に表示されることになります。ただし、root.mjsawait nullを一つ増やすと結果が元に戻ります。

要するに、a.mjsが非同期モジュールになったこと自体を原因として、import("./a.mjs")が解決されるまでの時間が1 tick長くなったのです。この1 tickの違いがどこで生まれたのかは仕様書を追っていけば説明できますが、そろそろこの記事を書く体力が尽きてきたので省略します。暇な方はぜひ挑戦して記事を書きましょう。

モジュールのimportexportの仕組み

ここまではモジュールがどのように実行されるのかについて焦点をあてて解説しましたが、この記事はES Modules完全理解と銘打っているので、ES Modulesのもうひとつの大きな特徴についても解説します。それはモジュールのimportexportがどのような機構で行われるのかです。

モジュール名前空間オブジェクト

ES Modulesを扱っていると、モジュール名前空間オブジェクトが得られることがあります。これは、import *構文やdynamic importを使うと得られます。例えば、次の例をnode.jsで実行してみましょう。

a.mjs
export const foo = "foo";
export const bar = "bar";
export default "default";
root.mjs
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]]内部メソッドの定義を画像で引用します。

スクリーンショット 2020-02-16 0.24.23.png

この実装は、普通のオブジェクトの挙動を模倣しつつも実態は全然別物という様相です。この実装を読むと察せられることは、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をそのままエクスポートしている可能性が存在します。bindingmが他のモジュールから再エクスポートしたものだった場合、targetModulemとは異なり、オリジナルのモジュールを指すことになります。ステップ10はそのbindingがES2020で追加されたexport * as ns構文でエクスポートされていた場合に対応するものです。この場合エクスポートされているのは別のModule Namespace Exotic Objectとなります。

ステップ11〜14が通常の場合です。ステップ11〜13ではモジュールの[[Environment]]内部プロパティを通してEnvironmentRecordを取得しており、ステップ14でそのGetBindingValueメソッドを用いて値を取得しています。

EnvironmentRecordとは、変数のスコープに対応する仕様書上の概念です。GetBindingValueメソッドはそこから変数の値を取得するメソッドです。これらはモジュールに特有の概念ではなく、単純に「変数の値を取得する」という処理にも使われます。つまり、ステップ11〜14をまとめると「エクスポート元モジュールの(トップレベル)変数スコープからから変数の値を取得する」ということになるのです。

このことは、このような例を通して確かめられます。

a.mjs
export let foo = "foo";

export const setFoo = value => {
  foo = value;
};
root.mjs
import * as m from "./a.mjs";

console.log(m.foo); // "foo"
m.setFoo("hello, world!");
console.log(m.foo); // "hello, world!"

a.mjsfoosetFooがエクスポートされており、setFooは呼び出されるとfooを書き換えます。root.mjsでは、m.setFooを呼び出すことでa.mjsfooの値を書き換えています。

すると、何ということでしょう。m.setFooを呼び出すとm.fooの値が書き換えられてしまいました。これが、Module Namespace Exotic Object ma.mjsのスコープに直結していることを示しています。

Module Namespace Exotic Object はいつ作られるのか

上の例ではmがModule Namespace Exotic Objectでした。では、このmはいつどのように作られるのでしょうか。明らかに、mimport文の作用によって作られます。mがいつ作られるのかをより厳密に調べるには、次の実験が有用です。すなわち、root.mjsをこのように変えてみます。

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文により作られます。

少し前に出てきた例を少し変えることでこれを確かめましょう。

a.mjs
export let foo = "foo";

export const setFoo = value => {
  foo = value;
};
root.mjs
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 }としてfoosetFooにインポートされます。これはroot.mjsのEnvironment Record内にfoosetFooというImmutable Imported Bingingを作ります。これが意味するところは、root.mjsの中でfooの値を取得すると常にa.mjsfooの値が取得されるということです。よって、先ほどと同様に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された名前をそのまま再エクスポートする」という行為は特別な扱いを受けることが分かります。

b.js
import { foo } from "./a.mjs";

export { foo };
export const foo2 = foo;

このようにした場合、b.jsfoofoo2の2つの名前をエクスポートしていることになりますが、前者は[[IndirectExportEntries]]に分類され、後者は[[LocalExportEntries]]に分類されます。また、b.mjsのEnvironment Recordにおいては前者はImmutable Imported Bindingである一方、後者はImmutable Bindingとなります。

これが意味することは、a.mjsfooの値が変更された場合、b.mjsfooはその影響を受ける一方でfoo2は影響を受けないということです。foo2に対するfoo2 = fooという代入はb.mjsが実行された瞬間に行われるため、foo2の値はその瞬間のfooで固定され、その後a.mjsfooの値が変更されてもfoo2の値には変更されません。foo2fooを参照するImported Bindingではなくただのローカル変数であることから考えれば、これは自然な振る舞いですね。

一方のResolveExportは、そのモジュールからエクスポートされている1つの名前に対するResolved Binding(これはbindingの所属モジュールとそのモジュールにおける名前のペアです)を返すメソッドです(名前が見つからなかった場合はnullを返すと言った挙動もあります)。特に名前の再エクスポートがある場合、オリジナルのモジュールまで再帰的に辿っていくことになります。

ResolvedExportは、Source Text Module Recordにおけるimport文の処理(InitializeEnvironment()時)に用いられます。例えば、b.mjsimport { foo } from "./a.mjs";というimport文を持っていた場合、このimport文の初期化時にはa.mjsを表すModule Recordに対してResolveExport("foo")という問い合わせが実行されることになります。すると、ResolveExportはfooの大元のbindingを探して返してくれるのです。

a.mjsexport 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は非同期処理を行なった結果をエクスポートしたい場合などにたいへん便利ですから、実用化されたらぜひ使っていきたいですね。

  1. HTMLから呼び出されるJavaScriptの場合は、<script type="module">によって読み込まれたものがモジュールと見なされ、それ以外はスクリプトです。TypeScriptでコンパイルする場合には、import文かexport文を含むファイルがモジュールと見なされます。

  2. 本当に同時に実行されるのか、それとも同時に実行するのかは1つだけなのかという違いを並列 (parallel)・並行 (concurrent)という用語で区別することもあるようですが、この定義に関しては信頼できる情報源がいまいち見当たらないので個人的には使用を避けています。信頼できる(学術的な)ソースをお持ちの方はぜひご連絡ください。

  3. なお、node.jsでは(--harmony-top-level-awaitオプションを有効にしても)dynamic importとtop-level awaitの組み合わせをまだサポートしていないようです。この例はv8 (d8) を直接動作させることで動作を確認できます。

  4. 実際にはECMAScriptのモジュールではJavaScriptファイル以外をモジュールとして読み込むことも可能(もちろんプラットフォームのサポートが必要)な仕様になっていますが、静的importの場合はその場合も「何がexportされているか」は実行する前に判明していなければいけません。node.jsではESモジュールからCommonJSモジュールを読み込む場合は必ずdynamic importを使用しなければいけませんが、その理由はこの点にあります。

  5. 仕様書ではroot of the cycleと呼ばれています。これはその強連結成分の入口となったモジュールであり、より厳密に言えば依存関係グラフを深さ優先探索するときに行きがけ順につけたID(0, 1, 2……と振られていく)が強連結成分の中で一番小さいモジュールとして定義されます。

  6. デッドロックの定義は2つ以上のプロセス(など)が互いを待っている状態と定義されるらしいので、この状態をデッドロックと呼ぶのが妥当なのかは少し怪しいかもしれません。一応、a.mjsb.mjsが互いを待ち続けていると見ることもできますが。

  7. ファイルを取得しないと次の依存先がわからないので、愚直に読み込むと依存関係グラフの深さの数だけサーバーと往復しないといけないという問題があります。この問題を解決すると思われていたHTTP/2 Server Pushもうまくいっていません。

  8. 記事タイトルに「TypeScript 3.8でサポートされた」とありますが、TypeScriptも当然ながらtop-level awaitがあっても何かトランスパイルするわけではありません。出力にもそのままtop-level awaitが残ります。TypeScriptはあくまでtop-level awaitを従来文法エラーとしていたのを許すようにしただけです。

  9. Webpackは今の所JavaScriptソースコードであるようなモジュール(またはwasmモジュール)を対象として扱うため、CSSファイルは典型的にはstyle-loaderによってJavaScriptに変換されます。

  10. node.jsでモジュールからdynamic importでCommonJSモジュールを読み込む場合はCommonJSモジュールはSource Text Module RecordではなくCyclic Module RecordかAbstract Module Recordとして扱われそうです(CommonJSモジュールのソースコードはES ModulesでいうModuleに適合するものではないため)。node.jsの挙動は仕様化されていないので証左はありませんが。

  11. 尤も、循環する依存関係がある場合にはこの制約は完全には満たされていないのですが。実際のところ、Cyclic Module Recordではその場合の処理が明示されており、Abstract Module Recordの場合は循環参照を作らないので大きな問題ではありません。

  12. JavaScriptのスコープはネストしていますが、Environment Record自体にはその対応は含まれておらず、あくまで一つのスコープを司る比較的単純な概念として存在しています。ネストしたスコープの探索はGetIdentifierReferenceが担当しています。

330
191
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
330
191

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?