【JS応用】Fetch: ダウンロードの進行状況

Fetch APIにおけるダウンロード進捗管理の完全攻略

現代のフロントエンド開発において、ユーザー体験(UX)を向上させるためには、非同期通信の状況を可視化することが不可欠です。特に大容量ファイルのダウンロードを行う際、プログレスバーによる進捗表示は「アプリケーションがフリーズしていないこと」をユーザーに伝える最も重要な指標となります。

標準的なFetch APIは非常に強力で直感的なインターフェースを提供していますが、実は標準機能だけではダウンロードの進捗率を直接取得するメソッドは存在しません。本稿では、ReadableStream APIを活用し、Fetch APIでダウンロードの進捗を正確に追跡・可視化するための技術的アプローチを深掘りします。

Fetch APIで進捗が取得できない理由と代替手段

なぜFetch API単体では進捗が取れないのでしょうか。それは、Fetchが提供するResponseオブジェクトのbodyが「読み取り可能なストリーム(ReadableStream)」として提供されるためです。標準的なfetch呼び出しでは、レスポンス全体がメモリにバッファリングされるか、あるいはストリームとして逐次処理されますが、ブラウザ側がその「全体のサイズ」と「現在の受信サイズ」を自動的に計算してコールバックを投げてくれる仕組みが用意されていません。

これまでの開発現場では、XMLHttpRequestのonprogressイベントを利用するのが定石でしたが、モダンな開発環境ではFetch APIへの移行が推奨されています。Fetch APIでこれを実現するには、レスポンスヘッダーからContent-Lengthを取得し、ReadableStreamをイテレータとして消費する手法が最適解となります。

ReadableStreamを用いた進捗追跡の実装ロジック

実装の核となるのは、ResponseオブジェクトのbodyをgetReader()で取得し、read()メソッドを再帰的に呼び出すプロセスです。

1. ヘッダーからContent-Lengthを取得し、総容量を把握する。
2. ReadableStreamのリーダーを作成する。
3. チャンク(データの断片)を読み込むたびに、その長さを累積していく。
4. 累積値を総容量で割り、進捗率を算出する。
5. 読み込んだデータをUint8Arrayとして蓄積し、最終的にBlobやArrayBufferに変換する。

このアプローチの最大の利点は、メモリ効率が良いことです。データを一気にメモリに展開するのではなく、ストリームとして処理することで、ブラウザのメモリ負荷を最小限に抑えつつ、進行状況をミリ秒単位で更新することが可能になります。

実装サンプルコード:プログレスバー対応のダウンロード関数

以下に、実務でそのまま利用可能な、堅牢なダウンロード処理のサンプルコードを提示します。


async function downloadWithProgress(url, onProgress) {
  const response = await fetch(url);

  if (!response.ok) throw new Error('Network response was not ok');

  const contentLength = response.headers.get('content-length');
  const total = parseInt(contentLength, 10);
  let loaded = 0;

  const reader = response.body.getReader();
  const chunks = [];

  while (true) {
    const { done, value } = await reader.read();

    if (done) break;

    chunks.push(value);
    loaded += value.length;

    if (onProgress && total) {
      const percent = Math.round((loaded / total) * 100);
      onProgress(percent, loaded, total);
    }
  }

  // 蓄積したチャンクを結合してBlobに変換
  const blob = new Blob(chunks);
  return blob;
}

// 使用例
downloadWithProgress('/api/large-file', (percent, loaded, total) => {
  console.log(`進捗: ${percent}% (${loaded} / ${total} bytes)`);
  // ここでプログレスバーのDOMを更新する
}).then(blob => {
  console.log('ダウンロード完了', blob);
  // URL.createObjectURLなどでダウンロードリンクを生成
});

実務における注意点とパフォーマンス最適化

この実装を行う上で、いくつか注意すべき「落とし穴」が存在します。

第一に、サーバー側が「Content-Length」ヘッダーを正しく返しているか確認が必要です。圧縮(Gzipなど)が効いている場合、Content-Lengthが圧縮後のサイズを指すのか、元のサイズを指すのかによって計算が狂うことがあります。また、Transfer-Encoding: chunkedで送信されている場合、Content-Length自体が存在しないケースも多々あります。その場合は、進捗率の計算を諦め、ローディングスピナーなどを表示するフォールバック処理を実装しておくべきです。

第二に、メモリ管理です。上記のサンプルではchunks配列に全てのデータを保持していますが、非常に巨大なファイル(数GB単位)を扱う場合、ブラウザのタブがクラッシュする可能性があります。その場合は、Blobに変換するのではなく、FileSystemWritableFileStream API(Origin Private File System)を使用して、直接ディスクに書き込むアーキテクチャへの変更を検討してください。

第三に、CORS(Cross-Origin Resource Sharing)の設定です。Content-Lengthヘッダーをフロントエンドから読み取るためには、サーバー側でレスポンスヘッダー「Access-Control-Expose-Headers: Content-Length」を明示的に許可しておく必要があります。これがないと、JavaScript側からヘッダー情報にアクセスできず、進捗計算が不可能になります。

進捗管理をさらに進化させる:AbortControllerの統合

ユーザーはダウンロードを途中でキャンセルしたい場合が多いです。Fetch APIとAbortControllerを組み合わせることで、進捗状況を表示しながら、同時にキャンセル可能な堅牢なダウンロードロジックを構築できます。


const controller = new AbortController();
const signal = controller.signal;

// 呼び出し側でキャンセルしたい場合
// controller.abort();

const response = await fetch(url, { signal });

進捗管理関数にAbortControllerのシグナルを渡すことで、読み込み中にネットワークが切断された場合や、ユーザーが操作を中断した場合のクリーンアップ処理を確実に行えます。これは、ユーザー体験を損なわないためのプロフェッショナルな設計として必須の要素です。

まとめ

Fetch APIでのダウンロード進捗管理は、一見すると標準APIの不足に悩まされるように見えますが、ReadableStreamを正しく理解し活用することで、柔軟かつ高性能な実装が可能です。

1. ReadableStreamを用いた逐次読み込みを行う。
2. Content-Lengthヘッダーの存在確認とサーバー側のCORS設定を徹底する。
3. メモリ消費量を考慮し、巨大ファイルの場合は適切なストリーム保存戦略を選択する。
4. AbortControllerを併用し、キャンセル操作をサポートする。

これらの技術を組み合わせることで、ユーザーに対して信頼性の高い、プロフェッショナルなインターフェースを提供できます。フロントエンドエンジニアとして、単にデータを取得するだけでなく、その「過程」をいかに美しく、正確に管理するかという視点は、アプリケーションの品質を大きく左右する重要なスキルです。ぜひ、本稿の内容を実際のプロジェクトで活用し、より洗練されたデータ通信体験を実装してください。

コメント

タイトルとURLをコピーしました