JavaScript モジュール

公開日 · タグ: ECMAScript ES2015

JavaScript モジュールは、現在主要ブラウザ全てでサポートされています!

この記事では、JSモジュールの使用方法、責任あるデプロイ方法、そしてChromeチームが将来モジュールをさらに改善するためにどのように取り組んでいるかを説明します。

JSモジュールとは? #

JSモジュール(「ESモジュール」または「ECMAScriptモジュール」とも呼ばれます)は、主要な新機能、あるいはむしろ新機能の集合体です。過去にユーザーランドJavaScriptモジュールシステムを使用したことがあるかもしれません。 Node.jsのようなCommonJSAMD、あるいはその他のモジュールシステムを使用したかもしれません。 これらすべてのモジュールシステムには共通点があります。それは、要素をインポートおよびエクスポートできることです。

JavaScriptには、まさにそれを実現するための標準化された構文が追加されました。 モジュール内では、exportキーワードを使用して、ほぼすべてのものをエクスポートできます。 constfunction、またはその他の変数バインディングや宣言をエクスポートできます。 変数ステートメントまたは宣言の前にexportを付けるだけで、準備完了です。

// 📁 lib.mjs
export const repeat = (string) => `${string} ${string}`;
export function shout(string) {
return `${string.toUpperCase()}!`;
}

その後、importキーワードを使用して、別のモジュールからモジュールをインポートできます。ここでは、libモジュールからrepeatshout機能をインポートし、mainモジュールで使用しています。

// 📁 main.mjs
import {repeat, shout} from './lib.mjs';
repeat('hello');
// → 'hello hello'
shout('Modules in action');
// → 'MODULES IN ACTION!'

モジュールから*デフォルト*値をエクスポートすることもできます。

// 📁 lib.mjs
export default function(string) {
return `${string.toUpperCase()}!`;
}

このようなdefaultエクスポートは、任意の名前を使用してインポートできます。

// 📁 main.mjs
import shout from './lib.mjs';
// ^^^^^

モジュールは従来のスクリプトとは少し異なります。

  • モジュールでは、strict modeがデフォルトで有効になっています。

  • HTMLスタイルのコメント構文は、従来のスクリプトでは機能しますが、モジュールではサポートされていません。

    // Don’t use HTML-style comment syntax in JavaScript!
    const x = 42; <!-- TODO: Rename x to y.
    // Use a regular single-line comment instead:
    const x = 42; // TODO: Rename x to y.
  • モジュールには、レキシカルなトップレベルスコープがあります。これは、たとえば、モジュール内でvar foo = 42;を実行しても、従来のスクリプトのようにブラウザでwindow.fooを介してアクセスできるグローバル変数fooは作成*されない*ことを意味します。

  • 同様に、モジュール内のthisはグローバルなthisを参照せず、代わりにundefinedになります。(グローバルなthisにアクセスする必要がある場合は、globalThisを使用してください。)

  • 新しい静的importおよびexport構文は、モジュール内でのみ使用できます。従来のスクリプトでは機能しません。

  • トップレベルawaitはモジュールでは使用できますが、従来のスクリプトでは使用できません。 関連して、awaitはモジュール内のどこでも変数名として使用できませんが、従来のスクリプトの変数は非同期関数の外部でawaitという名前を付けることができます。

これらの違いにより、*同じJavaScriptコードがモジュールとして扱われる場合と従来のスクリプトとして扱われる場合で動作が異なる*可能性があります。そのため、JavaScriptランタイムはどのスクリプトがモジュールであるかを知る必要があります。

ブラウザでのJSモジュールの使用 #

Web上では、type属性をmoduleに設定することにより、ブラウザに<script>要素をモジュールとして扱うように指示できます。

<script type="module" src="main.mjs"></script>
<script nomodule src="fallback.js"></script>

type="module"を理解するブラウザは、nomodule属性を持つスクリプトを無視します。これは、モジュールをサポートするブラウザにモジュールベースのペイロードを提供しながら、他のブラウザにフォールバックを提供できることを意味します。この区別をつけられることは、パフォーマンスの面だけでも素晴らしいことです!考えてみてください。モジュールをサポートしているのは最新のブラウザだけです。ブラウザがモジュールコードを理解している場合、アロー関数やasync-awaitなど、モジュールより前に存在していた機能もサポートしています。モジュールバンドルでこれらの機能をトランスパイルする必要はもうありません! より小さく、ほとんどトランスパイルされていないモジュールベースのペイロードを最新のブラウザに提供できます。 レガシーブラウザのみがnomoduleペイロードを取得します。

モジュールはデフォルトで遅延されるため、nomoduleスクリプトも遅延してロードすることをお勧めします。

<script type="module" src="main.mjs"></script>
<script nomodule defer src="fallback.js"></script>

モジュールと従来のスクリプトのブラウザ固有の違い #

ご存知のとおり、モジュールは従来のスクリプトとは異なります。 上記で概説したプラットフォームに依存しない違いに加えて、ブラウザに固有の違いがいくつかあります。

たとえば、モジュールは一度だけ評価されますが、従来のスクリプトはDOMに追加する回数だけ評価されます。

<script src="classic.js"></script>
<script src="classic.js"></script>
<!-- classic.js executes multiple times. -->

<script type="module" src="module.mjs"></script>
<script type="module" src="module.mjs"></script>
<script type="module">import './module.mjs';</script>
<!-- module.mjs executes only once. -->

また、モジュールスクリプトとその依存関係はCORSでフェッチされます。これは、オリジンが異なるモジュールスクリプトは、Access-Control-Allow-Origin: *などの適切なヘッダーで提供される必要があることを意味します。これは、従来のスクリプトには当てはまりません。

もう1つの違いは、async属性に関連しています。これは、スクリプトがHTMLパーサーをブロックせずにダウンロードされるようにしますが(deferのように)、保証された順序はなく、HTMLの解析が完了するのを待たずに、できるだけ早くスクリプトを実行します。 async属性はインラインの従来のスクリプトでは機能しませんが、インラインの<script type="module">では機能します。

ファイル拡張子に関する注意 #

モジュールに.mjsファイル拡張子を使用していることに気付いたかもしれません。 Web上では、ファイルがJavaScript MIMEタイプtext/javascriptで提供されている限り、ファイル拡張子は実際には重要ではありません。 ブラウザは、スクリプト要素のtype属性のためにそれがモジュールであることを認識しています。

それでも、2つの理由から、モジュールに.mjs拡張子を使用することをお勧めします。

  1. 開発中は、.mjs拡張子を使用することで、あなたとあなたのプロジェクトを見ている他の誰にとっても、ファイルが従来のスクリプトではなくモジュールであることが明確になります。(コードを見るだけでは常にわかるわけではありません。) 前述のように、モジュールは従来のスクリプトとは異なる扱いを受けるため、この違いは非常に重要です!
  2. Node.jsd8などのランタイム、およびBabelなどのビルドツールによって、ファイルがモジュールとして解析されるようにします。 これらの環境とツールはそれぞれ、他の拡張子を持つファイルをモジュールとして解釈するための独自の方法を構成によって備えていますが、.mjs拡張子は、ファイルがモジュールとして扱われることを保証するためのクロスコンパチブルな方法です。

**注意:** Web上に.mjsをデプロイするには、上記のように、適切なContent-Type: text/javascriptヘッダーを使用してこの拡張子を持つファイルを提供するようにWebサーバーを構成する必要があります。 さらに、構文の強調表示を行うために、エディターで.mjsファイルを.jsファイルとして扱うように構成することもできます。 ほとんどの最新のエディターは、デフォルトですでにこれを行っています。

モジュール指定子 #

モジュールをimportする際、モジュールの場所を指定する文字列は「モジュール指定子」または「インポート指定子」と呼ばれます。 前の例では、モジュール指定子は'./lib.mjs'です。

import {shout} from './lib.mjs';
// ^^^^^^^^^^^

ブラウザでは、モジュール指定子にいくつかの制限が適用されます。 いわゆる「bare」モジュール指定子は現在サポートされていません。 この制限は、仕様により、将来ブラウザがカスタムモジュールローダーに次のようなbareモジュール指定子に特別な意味を与えることができるようにするために設けられています。

// Not supported (yet):
import {shout} from 'jquery';
import {shout} from 'lib.mjs';
import {shout} from 'modules/lib.mjs';

一方、次の例はすべてサポートされています。

// Supported:
import {shout} from './lib.mjs';
import {shout} from '../lib.mjs';
import {shout} from '/modules/lib.mjs';
import {shout} from 'https://simple.example/modules/lib.mjs';

今のところ、モジュール指定子は完全なURL、または/./、または../で始まる相対URLでなければなりません。

モジュールはデフォルトで遅延されます #

従来の<script>は、デフォルトでHTMLパーサーをブロックします。 defer属性を追加することで回避できます。これにより、スクリプトのダウンロードがHTMLの解析と並行して行われるようになります。

モジュールスクリプトはデフォルトで遅延されます。そのため、<script type="module">タグにdeferを追加する必要はありません! メインモジュールのダウンロードがHTMLの解析と並行して行われるだけでなく、すべての依存関係モジュールについても同様です!

その他のモジュール機能 #

動的`import()` #

これまでは静的`import`のみを使用してきました。静的`import`では、メインコードを実行する前に、モジュールグラフ全体をダウンロードして実行する必要があります。モジュールを事前にロードするのではなく、必要なときにのみオンデマンドでロードしたい場合があります。たとえば、ユーザーがリンクまたはボタンをクリックした場合などです。 これにより、初期ロード時のパフォーマンスが向上します。 動的`import()`はこれを可能にします!

<script type="module">
(async () => {
const moduleSpecifier = './lib.mjs';
const {repeat, shout} = await import(moduleSpecifier);
repeat('hello');
// → 'hello hello'
shout('Dynamic import in action');
// → 'DYNAMIC IMPORT IN ACTION!'
})();
</script>

静的`import`とは異なり、動的`import()`は通常のスクリプト内から使用できます。既存のコードベースでモジュールの使用を段階的に開始する簡単な方法です。 詳細については、動的`import()`に関する記事をご覧ください。

**注意:** webpackには独自の`import()`バージョンがあります。これは、インポートされたモジュールをメインバンドルとは別の独自のチャンクに巧みに分割します。

`import.meta` #

もう1つのモジュール関連の新しい機能は`import.meta`です。これは、現在のモジュールに関するメタデータを提供します。取得する正確なメタデータはECMAScriptの一部としては指定されていません。ホスト環境によって異なります。たとえば、ブラウザではNode.jsとは異なるメタデータを取得する場合があります。

Web上での`import.meta`の例を次に示します。デフォルトでは、画像はHTMLドキュメントの現在のURLを基準にしてロードされます。 `import.meta.url`を使用すると、代わりに現在のモジュールを基準にして画像をロードできます。

function loadThumbnail(relativePath) {
const url = new URL(relativePath, import.meta.url);
const image = new Image();
image.src = url;
return image;
}

const thumbnail = loadThumbnail('../img/thumbnail.png');
container.append(thumbnail);

パフォーマンスに関する推奨事項 #

バンドルの維持 #

モジュールを使用すると、webpack、Rollup、Parcelなどのバンドラーを使用せずにWebサイトを開発できます。次のシナリオでは、ネイティブJSモジュールを直接使用しても問題ありません。

  • ローカル開発中
  • モジュール数が合計100未満で、依存関係ツリーが比較的浅い(つまり、最大深度が5未満)小規模Webアプリの本番環境

ただし、約300個のモジュールで構成されるモジュール化されたライブラリをロードする際のChromeのロードパイプラインのボトルネック分析でわかったように、バンドルされたアプリケーションのロードパフォーマンスはバンドルされていないアプリケーションよりも優れています。

静的なimport/export構文は静的解析が可能であるため、バンドラーツールが未使用のエクスポートを削除することでコードを最適化できます。静的なimportexportは単なる構文ではなく、重要なツール機能です!

モジュールを本番環境にデプロイする前に、バンドラーを引き続き使用することをお勧めします。 ある意味で、バンドリングはコードの縮小と同様の最適化です。送信するコードが少なくなるため、パフォーマンスが向上します。バンドリングにも同じ効果があります!バンドリングを続けましょう。

いつものように、DevToolsのコードカバレッジ機能は、不要なコードをユーザーにプッシュしていないかどうかを特定するのに役立ちます。また、コード分割を使用してバンドルを分割し、First Meaningful Paintに重要でないスクリプトの読み込みを遅延させることをお勧めします。

バンドルとバンドルされていないモジュールの送信のトレードオフ #

Web開発ではいつものことですが、すべてはトレードオフです。バンドルされていないモジュールを送信すると、初期ロードのパフォーマンス(コールドキャッシュ)が低下する可能性がありますが、コード分割なしで単一のバンドルを送信する場合と比較して、後続の訪問(ウォームキャッシュ)のロードのパフォーマンスが実際に改善される可能性があります。200 KBのコードベースの場合、単一の細粒度のモジュールを変更し、後続の訪問でサーバーから取得するものがそれだけである方が、バンドル全体を再取得するよりもはるかに優れています。

初回訪問のパフォーマンスよりもウォームキャッシュを持つ訪問者のエクスペリエンスを重視し、数百個未満の細粒度のモジュールを持つサイトをお持ちの場合は、バンドルされていないモジュールを送信して、コールドロードとウォームロードの両方のパフォーマンスへの影響を測定し、データに基づいた決定を下すことができます!

ブラウザエンジニアは、モジュールのすぐに使えるパフォーマンスを向上させるために懸命に取り組んでいます。時間の経過とともに、バンドルされていないモジュールの送信がより多くの状況で実現可能になると予想しています。

細粒度のモジュールを使用する #

小規模で細粒度のモジュールを使用してコードを記述することを習慣づけてください。開発中は、多くのエクスポートを手動で1つのファイルに結合するよりも、モジュールごとに少数のエクスポートを持つ方が一般的に優れています。

droppluck、およびzipという名前の3つの関をエクスポートする./util.mjsという名前のモジュールを考えてみましょう。

export function drop() { /* … */ }
export function pluck() { /* … */ }
export function zip() { /* … */ }

コードベースでpluck機能のみが必要な場合は、おそらく次のようにインポートします。

import {pluck} from './util.mjs';

この場合、(ビルド時のバンドリング手順なしで)ブラウザは、実際に必要なエクスポートが1つだけであるにもかかわらず、./util.mjsモジュール全体をダウンロード、解析、およびコンパイルする必要があります。それは無駄です!

pluckdropおよびzipとコードを共有していない場合は、独自の細粒度モジュール(例:./pluck.mjs)に移動することをお勧めします。

export function pluck() { /* … */ }

その後、dropzipを処理することなく、pluckをインポートできます。

import {pluck} from './pluck.mjs';

注:個人の好みに応じて、名前付きエクスポートの代わりにdefaultエクスポートを使用できます。

これはソースコードをきれいでシンプルに保つだけでなく、バンドラーによって実行されるデッドコードの削除の必要性も軽減します。ソースツリーのモジュールの1つが未使用の場合、インポートされることはなく、ブラウザはそれをダウンロードしません。使用*される*モジュールは、ブラウザによって個別にコードキャッシュできます。(これを実現するためのインフラストラクチャはすでにV8に導入されており、Chromeでも有効にするための作業が進行中です。)

小規模で細粒度のモジュールを使用すると、ネイティブバンドリングソリューションが利用可能な将来に向けてコードベースを準備できます。

モジュールのプリロード #

<link rel="modulepreload">を使用することで、モジュールの配信をさらに最適化できます。これにより、ブラウザはモジュールとその依存関係をプリロードし、さらに事前に解析およびプリコンパイルできます。

<link rel="modulepreload" href="lib.mjs">
<link rel="modulepreload" href="main.mjs">
<script type="module" src="main.mjs"></script>
<script nomodule src="fallback.js"></script>

これは、大規模な依存関係ツリーにとって特に重要です。rel="modulepreload"がないと、ブラウザは複数のHTTPリクエストを実行して、完全な依存関係ツリーを把握する必要があります。ただし、依存するモジュールスクリプトの完全なリストをrel="modulepreload"で宣言すると、ブラウザはこれらの依存関係を progressivement 発見する必要がなくなります。

HTTP/2を使用する #

可能な場合はHTTP/2を使用することは、多重化サポートのためだけでも、常に良好なパフォーマンスのアドバイスです。HTTP/2の多重化を使用すると、複数のリクエストとレスポンスメッセージを同時に処理できるため、モジュールツリーの読み込みに役立ちます。

Chromeチームは、別のHTTP/2機能、具体的にはHTTP/2サーバープッシュが、高度にモジュール化されたアプリをデプロイするための実用的なソリューションになるかどうかを調査しました。残念ながら、HTTP/2サーバープッシュを正しく行うのは難しいことであり、Webサーバーとブラウザの実装は、現在、高度にモジュール化されたWebアプリのユースケース向けに最適化されていません。たとえば、ユーザーがまだキャッシュしていないリソースのみをプッシュすることは難しく、オリジンのキャッシュ状態全体をサーバーに伝達することでそれを解決することはプライバシーリスクとなります。

ですから、ぜひHTTP/2を使用してください!HTTP/2サーバープッシュは(残念ながら)万能薬ではないことを覚えておいてください。

WebでのJSモジュールの採用 #

JSモジュールはWebで徐々に採用されつつあります。使用状況カウンターによると、現在、すべてのページ読み込みの0.08%が<script type="module">を使用しています。この数値には、動的なimport()workletsなどの他のエントリポイントは含まれていません。

JSモジュールの次は? #

Chromeチームは、JSモジュールを使用した開発時のエクスペリエンスをさまざまな方法で改善するために取り組んでいます。いくつか説明しましょう。

高速で決定論的なモジュール解決アルゴリズム #

速度と決定性の欠陥に対処するモジュール解決アルゴリズムの変更を提案しました。新しいアルゴリズムは、HTML仕様ECMAScript仕様の両方で有効になり、Chrome 63に実装されています。この改善がすぐに他のブラウザにも導入されることを期待しています!

新しいアルゴリズムははるかに効率的で高速です。古いアルゴリズムの計算の複雑さは、依存関係グラフのサイズで2次、つまり𝒪(n²)であり、当時のChromeの実装もそうでした。新しいアルゴリズムは線形、つまり𝒪(n)です。

さらに、新しいアルゴリズムは決定論的な方法で解決エラーを報告します。複数のエラーを含むグラフが与えられた場合、古いアルゴリズムの異なる実行では、解決の失敗の原因として異なるエラーが報告される可能性があります。これにより、デバッグが不必要に困難になりました。新しいアルゴリズムは、毎回同じエラーを報告することが保証されています。

WorkletsとWeb Workers #

Chromeは現在、workletsを実装しています。これにより、Web開発者はWebブラウザの「低レベル部分」のハードコードされたロジックをカスタマイズできます。 workletsを使用すると、Web開発者はJSモジュールをレンダリングパイプラインまたはオーディオ処理パイプライン(そしておそらく将来はより多くのパイプライン!)にフィードできます。

Chrome 65は、PaintWorklet(別名CSS Paint API)をサポートして、DOM要素のペイント方法を制御します。

const result = await css.paintWorklet.addModule('paint-worklet.mjs');

Chrome 66はAudioWorkletをサポートしています。これにより、独自のコードでオーディオ処理を制御できます。同じChromeバージョンで、スクロールにリンクされた高性能な手続き型アニメーションの作成を可能にするAnimationWorkletのOriginTrialが開始されました。

最後に、LayoutWorklet(別名CSS Layout API)がChrome 67に実装されました。

Chromeで専用のWebワーカーでJSモジュールを使用するためのサポートを追加するために取り組んでいます。chrome://flags/#enable-experimental-web-platform-featuresを有効にすると、この機能をすでに試すことができます。

const worker = new Worker('worker.mjs', { type: 'module' });

共有ワーカーとサービスワーカーのJSモジュールサポートはまもなく提供されます。

const worker = new SharedWorker('worker.mjs', { type: 'module' });
const registration = await navigator.serviceWorker.register('worker.mjs', { type: 'module' });

インポートマップ #

Node.js/npmでは、JSモジュールを「パッケージ名」でインポートするのが一般的です。例えば

import moment from 'moment';
import {pluck} from 'lodash-es';

現在、HTML仕様によると、このような「ベアリインポート指定子」は例外をスローします。インポートマップの提案により、本番アプリを含め、Webでこのようなコードを動作させることができます。インポートマップは、ブラウザがベアリインポート指定子を完全なURLに変換するのに役立つJSONリソースです。

インポートマップはまだ提案段階です。さまざまなユースケースに対処する方法について多くのことを考えてきましたが、まだコミュニティと協力しており、完全な仕様を作成していません. フィードバックは大歓迎です!

Webパッケージング:ネイティブバンドル #

Chromeの読み込みチームは現在、Webアプリを配布する新しい方法としてネイティブWebパッケージング形式を検討しています。Webパッケージングのコア機能は次のとおりです。

署名付きHTTP交換により、ブラウザは単一のHTTPリクエスト/レスポンスペアがそれが主張するオリジンによって生成されたことを信頼できます。バンドルされたHTTP交換、つまり、それぞれが署名されているか署名されていない交換のコレクションであり、バンドル全体を解釈する方法を説明するメタデータがいくつかあります。

これらを組み合わせることで、このようなWebパッケージング形式により、*複数の同一オリジンリソース*を*単一の* HTTP GETレスポンスに*安全に埋め込む*ことができます。

webpack、Rollup、Parcelなどの既存のバンドリングツールは現在、元の個別のモジュールとアセットのセマンティクスが失われた単一のJavaScriptバンドルを出力します。ネイティブバンドルを使用すると、ブラウザはリソースを元の形式にアンバンドルできます。簡単に言えば、バンドルされたHTTP交換は、目次(マニフェスト)を介して任意の順序でアクセスできるリソースのバンドルとして想像できます。含まれているリソースは、個々のファイルの概念を維持しながら、相対的な重要度に従って効率的に保存およびラベル付けできます。このため、ネイティブバンドルはデバッグエクスペリエンスを向上させる可能性があります。DevToolsでアセットを表示する場合、ブラウザは複雑なソースマップを使用せずに元のモジュールを特定できます。

ネイティブバンドル形式の透過性は、さまざまな最適化の可能性を開きます。例えば、ブラウザがネイティブバンドルの一部をローカルにキャッシュしている場合、Webサーバーにそれを伝え、不足している部分のみをダウンロードできます。

Chromeはすでに提案の一部(SignedExchanges)をサポートしていますが、バンドル形式自体と高度にモジュール化されたアプリへの適用はまだ模索段階です。リポジトリまたはloading-dev@chromium.orgへのメールでフィードバックをお寄せください!

階層化API #

新しい機能とWeb APIを提供するには、継続的なメンテナンスとランタイムコストが発生します。すべての新しい機能はブラウザの名前空間を汚染し、起動コストを増加させ、コードベース全体にバグを発生させる新しい表面となります。階層化APIは、よりスケーラブルな方法でWebブラウザを使用して、より高レベルのAPIを実装および提供するための取り組みです。JSモジュールは、階層化APIを実現するための重要な技術です。

  • モジュールは明示的にインポートされるため、階層化APIをモジュール経由で公開する必要があるため、開発者は使用する階層化APIの分だけコストを支払うことになります。
  • モジュールの読み込みは設定可能であるため、階層化APIは、階層化APIをサポートしていないブラウザにポリフィルを自動的に読み込むための組み込みメカニズムを持つことができます。

モジュールと階層化APIがどのように連携するかの詳細はまだ検討中ですが、現在の提案は次のようになります。

<script
type="module"
src="std:virtual-scroller|https://example.com/virtual-scroller.mjs"
>
</script>

<script>要素は、ブラウザの組み込みの階層化APIセット(std:virtual-scroller)またはポリフィルを指すフォールバックURLからvirtual-scroller APIを読み込みます。このAPIは、WebブラウザでJSモジュールが実行できるすべてのことを実行できます。1つの例は、カスタム<virtual-scroller>要素を定義することです。これにより、次のHTMLは必要に応じて段階的に拡張されます。

<virtual-scroller>
<!-- Content goes here. -->
</virtual-scroller>

クレジット #

Domenic Denicola、Georg Neis、中川弘基、林崎弘成、Jakob Gruber、上野康平、坂本邦彦、Yang Guoの各氏に、JavaScriptモジュールを高速化してくれたことに感謝します!

また、Eric Bidelman、Jake Archibald、Jason Miller、Jeffrey Posnick、Philip Walton、Rob Dodson、Sam Dutton、Sam Thorogood、Thomas Steinerの各氏には、このガイドのドラフト版を読んでフィードバックをいただいたことに感謝いたします。