Back

モダンJavaScriptにおけるTop-Level Awaitの使用

モダンJavaScriptにおけるTop-Level Awaitの使用

はじめに

モジュールレベルでawaitを使用するために、非同期コードを即座に実行される関数式(IIFE)でラップした経験があるなら、あなただけではありません。ES2022以前、JavaScript開発者はモジュール初期化時の非同期操作を処理するために複雑な手法を使う必要がありました。Top-level await JavaScriptは、async関数ラッパーなしでESモジュール内で直接awaitを使用できるようにすることで、この状況を変えました。

この記事では、top-level awaitがモジュール実行をどのように変革するか、設定ロードや動的インポートでの実用的な応用、そして実行ブロッキングや循環依存の落とし穴を含む重要なトレードオフについて説明します。この強力な機能をいつ使用すべきか、そして同様に重要な、いつ避けるべきかを学びます。

重要なポイント

  • Top-level awaitにより、async関数でラップすることなくESモジュール内で直接awaitが使用可能
  • モジュール実行が非同期になり、完了まで依存モジュールをブロック
  • 一回限りの初期化、設定ロード、条件付きインポートに最適
  • ライブラリやユーティリティでは、下流の利用者をブロックしないよう避ける
  • ESモジュールとモダンランタイムサポートが必要(Node.js 14.8+、ES2022)

Top-Level Awaitとは何か、なぜ導入されたのか?

解決する問題

top-level await以前、非同期データでモジュールを初期化するには回避策が必要でした:

// IIFEを使った古いアプローチ
let config;
(async () => {
  config = await fetch('/api/config').then(r => r.json());
})();

// configはアクセス時にundefinedの可能性がある!

このパターンはタイミングの問題を引き起こし、コードの理解を困難にしました。モジュールは値をエクスポートする前に、非同期依存関係の準備が完了していることを保証できませんでした。

ES2022のソリューション

Top-level awaitにより、モジュールスコープで直接await式が使用可能になります:

// モダンなアプローチ
const config = await fetch('/api/config').then(r => r.json());
export { config }; // インポート時には常に定義済み

この機能はESモジュール専用で動作します—.mjs拡張子のファイル、またはpackage.jsonで"type": "module"が設定されたプロジェクトの.jsファイルです。ブラウザでは、スクリプトで<script type="module">を使用する必要があります。

Top-Level Awaitがモジュール実行を変える方法

モジュールロードが非同期になる

JavaScriptがawait outside async functionに遭遇すると、そのモジュールのロード方法が根本的に変わります:

  1. パース段階: エンジンが構文を検証し、インポート/エクスポートを識別
  2. インスタンス化段階: モジュールバインディングが作成されるが評価されない
  3. 評価段階: コードが実行され、各awaitで一時停止
// database.js
console.log('1. Starting connection');
export const db = await connectDB();
console.log('2. Connection ready');

// app.js
console.log('3. App starting');
import { db } from './database.js';
console.log('4. Using database');

// 出力順序:
// 1. Starting connection
// 3. App starting
// 2. Connection ready
// 4. Using database

カスケード効果

モジュール依存関係は連鎖反応を作り出します。モジュールがtop-level awaitを使用すると、それを直接的または間接的にインポートするすべてのモジュールが完了を待ちます:

// config.js
export const settings = await loadSettings();

// auth.js
import { settings } from './config.js';
export const apiKey = settings.apiKey;

// main.js
import { apiKey } from './auth.js'; // 全体のチェーンを待機

一般的な使用例とパターン

動的モジュールロード

Top-level await JavaScriptは、ランタイム条件に基づく条件付きインポートに優れています:

// 環境に基づいてデータベースドライバーをロード
const dbModule = await import(
  process.env.DB_TYPE === 'postgres' 
    ? './drivers/postgres.js' 
    : './drivers/mysql.js'
);

export const db = new dbModule.Database();

設定とリソース初期化

モジュール実行前の設定ロードやリソース初期化に最適:

// i18n.js
const locale = await detectUserLocale();
const translations = await import(`./locales/${locale}.js`);

export function t(key) {
  return translations.default[key] || key;
}

WebAssemblyモジュールロード

ラッパー関数なしでWASM初期化を簡素化:

// crypto.js
const wasmModule = await WebAssembly.instantiateStreaming(
  fetch('/crypto.wasm')
);

export const { encrypt, decrypt } = wasmModule.instance.exports;

重要な制限とトレードオフ

ESモジュール限定

Top-level awaitには厳格なコンテキスト要件があります:

// ❌ CommonJS - SyntaxError
const data = await fetchData();

// ❌ クラシックスクリプト - SyntaxError
<script>
  const data = await fetchData();
</script>

// ✅ ESモジュール
<script type="module">
  const data = await fetchData();
</script>

実行ブロッキング

すべてのawaitは、アプリケーション起動に影響を与える可能性のある同期ポイントを作成します:

// slow-module.js
export const data = await fetch('/slow-endpoint'); // 5秒の遅延

// app.js
import { data } from './slow-module.js';
// この行が実行される前にアプリ全体が5秒待機

循環依存のデッドロック

Top-level awaitは循環依存をより危険にします:

// user.js
import { getPermissions } from './permissions.js';
export const user = await fetchUser();

// permissions.js
import { user } from './user.js';
export const permissions = await getPermissions(user.id);

// 結果: デッドロック - モジュールが互いを無限に待機

プロダクション使用のベストプラクティス

Top-Level Awaitを使用すべき場合

  • 一回限りの初期化: データベース接続、APIクライアント
  • 設定ロード: 環境固有の設定
  • 機能検出: 条件付きポリフィルロード

避けるべき場合

  • ライブラリモジュール: 下流の利用者をブロックしない
  • 頻繁にインポートされるユーティリティ: パフォーマンスのため同期を維持
  • 循環依存のリスクがあるモジュール: 代わりにasync関数を使用

エラーハンドリング戦略

モジュールロードのクラッシュを防ぐため、常に失敗を処理:

// フォールバック付きの安全なパターン
export const config = await loadConfig().catch(err => {
  console.error('Config load failed:', err);
  return { defaultSettings: true };
});

// 代替案: 利用者にエラーハンドリングを委ねる
export async function getConfig() {
  return await loadConfig();
}

ビルドツールとランタイムサポート

モダンなツールはtop-level await JavaScriptを様々なアプローチで処理します:

  • Webpack 5+: experiments.topLevelAwaitでサポート
  • Vite: 開発・本番環境でネイティブサポート
  • Node.js 14.8+: ESモジュールで完全サポート
  • TypeScript 3.8+: module: "es2022"以上が必要

レガシー環境では、top-level awaitを使用するのではなく、非同期ロジックをエクスポートされた関数でラップすることを検討してください。

結論

Top-level awaitは、JavaScriptにおける非同期モジュール初期化の書き方を変革し、IIFEの回避策を排除してコードをより読みやすくします。しかし、その力には責任が伴います—モジュール実行のブロッキングと潜在的な循環依存の問題には慎重な検討が必要です。

アプリケーション固有の初期化と設定ロードにはtop-level awaitを使用しますが、共有ライブラリやユーティリティからは除外してください。その能力と制約の両方を理解することで、モジュール実行の一時停止に伴う落とし穴を避けながら、この機能を活用してより清潔で保守しやすいJavaScriptモジュールを書くことができます。

よくある質問

いいえ、top-level awaitはESモジュールでのみ動作します。Node.jsでは、.mjsファイルを使用するか、package.jsonでtype moduleを設定してください。CommonJSモジュールは非同期操作にasync関数またはIIFEを使い続ける必要があります。

Top-level await自体はtree shakingを妨げませんが、バンドル分割に影響する可能性があります。バンドラーは実行順序を維持するために、top-level awaitを持つモジュールを異なる方法でグループ化し、より大きなチャンクを作成する可能性があります。

ほとんどのモダンなテストランナーは、top-level awaitを持つESモジュールをサポートしています。Jestの場合、実験的なESMサポートを有効にしてください。非同期依存関係をモックするか、より簡単なテストのために初期化を関数でラップすることを検討してください。

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Check our GitHub repo and join the thousands of developers in our community.

OpenReplay