🧯

Native ESM時代とはなにか

commits8 min read

最近の日本のフロントエンド界隈では「Native ESM時代」という言葉が聞こえてきます。Native ESM時代におけるビルドツールがどうなるかといったことが主な話題です。

個人的には面白い概念なので流行ってほしいと思い、Native ESM時代とは何かを解説する基礎的な資料を用意しました。

「Native ESM時代」が何を指すのかは人によって異なる可能性があります。この記事で説明するのは筆者の考えです。ご了承ください。

そもそもNative ESMとは

Native ESMとは、ES Modulesのことです。つまり、ECMAScript仕様の一部として定義されたモジュールシステムを指します。現在、モダンな部類のフロントエンド開発において広く用いられている、import宣言でインポートしexport宣言でエクスポートするのがES Modulesです。

特に、ES Modulesはブラウザによって直接理解されるモジュールシステムです。Nativeという単語はこのことを強調しています。

以降、この記事では開発にES Modulesを使うようなモダンな部類のフロントエンド開発を指して単に「フロントエンド開発」という言葉を用います。

Native ESM時代とは

Native ESM時代というのは、ビルド後にもES Modulesが利用されるようになった時代です。

というのも、現在のフロントエンド開発ではソースコードをそのまま配布するのではなく、ビルドが必要です。ブラウザはビルドによって得られた成果物を読み込みます。ビルドのステップを担当するツールとして、Babel, esbuild, Webpackなどに代表されるツールたちが存在します。

現在主流の方法では、ビルド時に、バンドラと呼ばれるツールによってimportexportで繋がった複数のJavaScriptファイルたちが一つのJavaScriptにまとめられます[1]。これが成果物であり、ブラウザは1つにまとめられたJavaScriptファイルを読み込みます。ファイルが1つであるということは、もはや他のファイルを読み込む必要がないということです。つまり、ビルド前にES Modulesを利用していたとしても、ビルド後のJavaScriptではもはやES Modulesが使われません[2]

この状況を破り、ビルド後の成果物にもES Modulesの利用が含まれる(言い換えれば、ビルド後の成果物が複数ファイル(モジュール)から成る)ようにしようというのがNative ESM時代の考え方です。

Native ESM時代には二段階があると考えられます。すなわち、開発ビルド(自分の手元でツールをwatchモードで動かすときのビルド)でのみES Modulesが用いられて、プロダクションビルド(デプロイする用の成果物を作るためのビルド)では従来通りという段階と、開発ビルドでもプロダクションビルドでもES Modulesが用いられるという段階です。もちろん、前者のほうが先に到来するでしょう。また、前者については関連ツールの発展により既に来ているとも考えられます。以下の記事が参考になります。

https://zenn.dev/mizchi/articles/native-esm-age

Native ESM時代に何が解決されるのか

次に、なぜNative ESM時代に移行する必要があるのか、言い換えれば旧来のやり方にどのような問題があり、Native ESM時代ではそれがどう解決されるのかを説明します。

問題は2つに大別されます。すなわち、ビルドパフォーマンスの問題実行時パフォーマンスの問題です。字面から察せられるように、前者は開発時のビルドにもプロダクションビルドにも関わる話で、後者はプロダクションビルドにのみ関わる話です。前者のみを解決した状態が開発環境にNative ESM時代が到来した状態で、後者も解決されることで完全なNative ESM時代となります。この記事では、前者を開発Native ESM時代、後者を完全Native ESM時代と呼びましょう(この記事の造語です)。

開発Native ESM時代: ビルドパフォーマンスの問題

まず、ビルドパフォーマンスの問題を見ます。今回特に関連するのは、ビルドのうち、バンドラ(複数JavaScriptファイル間のimportexportを解析して1つのJavaScriptにまとめるツール[3])が担当する部分です。

端的に言えば、バンドルという工程それ自体が遅いです。バンドルという工程では、(とてもざっくりと言えば)複数のJavaScriptコードを1つの大きなコードに合体させて、うまく動くように多少変形させたり辻褄を合わせるランタイムのコードを追加したりすることです。アプリ全体の内容を含む大きなコードを生成するということは、アプリが大きくなるほど必然的に遅くなります。

また、とくに開発環境においては、ファイルを変更して保存するたびにすぐに再バンドルする運用が主流です。開発中は、現在のソースをビルドした成果物をブラウザで開き、動作を確認しながら開発します。このとき、ファイルの変更時にブラウザを自動的にリロードさせることにより、変更の影響(新しいビルドの成果)を即座にブラウザ上に反映して高速なアプリ開発を助けます。また、Hot Module Replacementという機能により、全体リロードを避けてさらに効率化することができます。

しょっちゅうバンドルをするということは、バンドルが遅いことが開発効率の低下に直結するということです。これがビルドパフォーマンスの問題です。再バンドルに当たってなるべく無駄な仕事を減らす努力はバンドラとしても行なっていますが、バンドルに時間がかかってストレスを感じることが少なくないというのが現状でしょう。また、再バンドルに備えてメモリに多量のデータをキャッシュする必要が生じるなど別の問題も起こります。

(開発)Native ESM時代における解決策は、そもそもバンドルをしないことです。つまり、ソースファイルにimportexportがあれば、それをそのままブラウザに読み込ませるということです。ただし、Hot Module Replacementの対応のためにimport先をちょっと書き換える程度の変換はサーバーによって行われます。うまい仕組みによって、この変換はモジュール単位で行うことができます。モジュール単位で変換を行えるようにしたことで、バンドルというボトルネックの工程を省略することができ、並列性の向上や必要な変換処理の削減が達成できます。さらに、HMR(全体リロードではなく変更があったモジュールのみを差し替える最適化)を行う際も、部分バンドルの作成のような面倒な処理をする必要が無く効率的です。

ちなみに、余談ですが、HMRを有効化するためにはHMR境界を定義する必要があります(viteを例に説明します。他のツールでは用語が異なるかもしれません)。HMR境界とは、「このモジュール(およびその依存モジュール)が書き換えられたときはこのモジュールだけ再読み込みすればよくて、このモジュールに依存するファイルたちは再読み込みをする必要がない」という宣言です。HMR境界があれば、その内側のモジュールが更新されたときは境界の内側のみ再読み込みし、外側はそのままということが可能です。HMR境界は開発効率の向上のために重要です。

ただし、プロジェクトの一部のみを再読み込みするといっても、ES Modulesにはすでにimportされたものの中身を後から差し替えるという言語機能はありません。つまり、HMR境界を定義する場合、「再読み込みされたら自身を使用しているモジュール側から見えるものをうまく差し替える」ということを自前で行う必要があり、これには結構な腕力が必要です。Viteの場合、例えばReactプロジェクトに対しては@vite/plugin-react-refreshが腕力を提供してくれます。これにより、ReactコンポーネントのみをエクスポートするモジュールがHMR境界となり、ビルド・再読み込みのパフォーマンスが最適化されます。

このように、開発Native ESM時代においてはプロジェクトのソースファイルたちが形成するモジュールグラフ[4]をそのままの形でブラウザに配信します。モジュールグラフの解決(import文たちをたどってプログラムの必要なファイルを全て読み込むこと)はブラウザ側に行なってもらう事で、複数ファイルをひとつにまとめるバンドルという行程を省くことができ、開発時のビルドの高速化に繋がります。

ちなみに、開発Native ESM時代の到来に必要なWeb標準のうち新しいのはdynamic importとimport.metaであり、これらはどちらもES2020での正式採用ですが、ブラウザのサポートは2017〜2018年には揃っていました。ですから、開発Native ESM時代はWeb標準の進化によって可能になったというよりは、開発用ソフトウェアの研究開発によって可能になったものです。

完全Native ESM時代: 実行時パフォーマンスの問題

ES Modulesが使われたプロジェクトをバンドラによって1つの非ES Modulesなファイルにまとめることは、実行時パフォーマンスの問題もあります。現在のバンドラによる成果物には、元々のソースファイルにあったimportをエミュレートするためのランタイムが含まれています。つまり、モジュールグラフの情報がそのランタイムに隠されており、ブラウザは直接モジュールグラフを認識できないということです。これは、理想的な状態に比べてパフォーマンスが劣ると思われます。

実際、筆者が関わるあるプロジェクトのパフォーマンスを計測したところ、初期レンダリングにかかった時間のうち15%程度がこのランタイムのために費やされていました。Native ESMを採用し、ランタイムによるエミュレートではなくブラウザが直接importを解決するようにすることで、このオーバーヘッドをある程度削減する余地があるでしょう。

このために、プロダクションビルドにおいてもNative ESMの使用を目指すモチベーションがあります。しかし、では開発Native ESM時代と同じアプローチを使えるかというと、そうではありません。

開発Native ESM時代では、開発時はブラウザにimportexportを含むコードをそのまま、すなわちプロジェクトのモジュールグラフをそのままの形で配信していました。実は、これが許されるのは開発環境だからです。というのも、開発環境ではビルドの成果物を配信するサーバー(開発サーバー)は基本的に自分のPC上にあります。そのため、ブラウザとサーバーの間の通信にかかる時間を基本的に無視できます。実は、この事情があるからこそプロジェクトのモジュールグラフをそのまま配信できていました。

プロダクションビルドでもプロジェクトのモジュールグラフをそのまま配信するのは、やはりパフォーマンス上の大きな問題があります。それは、読み込みにとんでもなく時間がかかるということです。ES Modulesの仕様上、モジュールグラフを全て読み込んでからでないとプログラムの実行を開始できません。また、あるモジュールがどのモジュールをimportするかというのは、import元のモジュールを読み込まないと判断できません。つまり、モジュールグラフを完全に読み込むまでにサーバーとの間を何往復もする必要があります。サーバーとの間の往復に時間がかかるプロダクション環境では、無駄な往復をしてしまうことは大きな問題となります。

ウェブサイトのパフォーマンス最適化においては、2往復で全て済ませるのが基本的な原則です。具体的には、最初の1往復でHTML文書が帰ってきて、それにURLが載っているJavaScriptファイルを2往復目で取得します[5]

もしプロジェクトのモジュールグラフをそのまま配信するならば、必要となるモジュールグラフ全体を2往復目に全部まとめて送る必要があります[6]が、それにもまだ問題があることが知られています。というのも、元のソースコードそのままのモジュールグラフを先読みしてもらおうとすると、一度に何百何千というファイルを読み込んでもらうことになります。これはパフォーマンス上良くないのです[7]

そのため、パフォーマンスが必要なプロダクション環境においては、複数ファイルを合体させて1つ(または少ないファイル数)にすることが不可欠です。実はこれは、まさにバンドラが従来従来行なってきたことですね。筆者の考えでは、「ファイル数を減らさなければいけない」という前提がある以上バンドラの仕事は無くならないと考えています。しかし、完全Native ESM時代というのは可能です。現在、将来の完全Native ESM時代に向けて関連仕様が整備されている状態なのです。

そして、そのために動いているものとして次の2つが注目に値します。

  • Module Fragments: 一言で言うと「モジュール内モジュール」を可能にするためのECMAScriptプロポーザルです。Module Fragmentsにより、「同じファイル内の別のモジュールからimportする」のようなことが可能になります。つまり、元々のモジュールグラフを1つのファイルの中の複数のモジュールたちとして埋め込むことができ、「1つのファイルである」ことと「たくさんのモジュールがまとまっている」ことが両立されます。
  • Resource Bundles(Bundle Preloadingに改称予定?): JavaScriptファイルだけでなく、画像など他の種類のリソースも1つのファイル(Web Bundle)にまとめて配信するためのWICGプロポーザルです。さらに、ブラウザはWeb Bundleに含まれる個々のリソースを個別にキャッシュすることができます。

これらのようなものが発展し確立すれば、「ES Moduleをブラウザに直接解釈してもらうことでパフォーマンスを向上する」ことと「たくさんのファイルを同時に読み込むことを防ぐ」ことを両立し、Native ESMのプロダクション利用が可能になるでしょう。

まとめ

この記事では、Native ESM時代とは何かについて、開発環境でのES Modules利用とプロダクション環境でのES Modules利用に分けて解説しました。

開発環境では、もともと多数のモジュールとして開発されているアプリにおいて、モジュールグラフを直接ブラウザに読み込ませること、また多少の腕力によって開発体験を向上できることを解説しました。これが開発環境におけるNative ESMの時代です。

また、本番環境でもパフォーマンスの観点からはNative ESMの方が理想的ですが、そのためには、つまり完全なNative ESM時代が到来するにはまだ仕様を成熟させる必要があります。

脚注
  1. Code splittingと呼ばれる技法によって成果物が一つではなく複数になることもありますが、この記事では簡単のために説明を省略しています。 ↩︎

  2. module-nomoduleパターンなどを介して使われていることもありますが、それは本記事のトピックとはあまり関係ありません。 ↩︎

  3. esbuildのように、バンドルだけに留まらないいくつかのビルドステップをまとめて行うことで高速化を図るツールもあります。むしろ、速さが求められるこれからの時代はそれが主流なのかもしれません。 ↩︎

  4. ソースファイルたちをノードとし、インポートの関係を辺とするグラフのことです。 ↩︎

  5. Server-Side Rendering (SSR) を使用した場合は、JavaScriptを動かしたら得られるであろう状態(初期レンダリングの結果)がすでに1往復目のHTMLに載っていることになります。2往復目でJavaScriptを読み込むことで、コンテンツがインタラクティブになります(いわゆるhydration))。また、初期表示に必要なCSS(いわゆるクリティカルCSS)もHTML内に載せるのも最適化としてよく行われます。原理的にはJavaScriptもインライン化してHTMLに載せられますが、キャッシュ効率の問題などからあまり行われていないと思われます。 ↩︎

  6. 現在の技術では、<link rel="modulepreload">を使うことでこれは一応実現可能です。 ↩︎

  7. Resource Bundlesのexplainerのexplainerいわく: “Modern Web sites are composed of hundreds or thousands of resources. Fetching them one by one has poor performance” ↩︎

GitHubで編集を提案

この記事に贈られたバッジ

Discussion

ログインするとコメントできます