ブラウザでダウンロード可能なファイルを作成する方法
ブラウザでダウンロード可能なファイルを作成するには、4つのブラウザAPIを組み合わせます。データを格納するBlob、インメモリ参照を作成するURL.createObjectURL()、保存をトリガーするdownload属性付きアンカー要素、そして参照を解放するURL.revokeObjectURL()です。この一連のパターンは10行以内に収まります。
function downloadBlob(data, filename, type) {
const blob = new Blob([data], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
downloadBlob(JSON.stringify({ ok: true }), 'config.json', 'application/json');
これは標準的なパターンであり、生成したCSV、JSON設定ファイル、画像の保存といった一般的なユースケースに対応しています。しかし、このハッピーパスの裏には実際の落とし穴が潜んでいます。クロスオリジンURLでdownload属性が暗黙的に無効化される問題、iOS Safariがダウンロードを新しいタブで開く問題、コンポーネントの再レンダリングをまたいでオブジェクトURLがリークする問題、そしてExcelがUTF-8のCSVを文字化けさせる問題などです。本記事では、動作するパターン、モダンなFile System Access API、メモリに収まらない大容量ファイルのストリーミング、そして本番環境でダウンロードを壊すプラットフォーム固有の注意点について解説します。
重要なポイント
- クライアントサイドダウンロードの標準パターンは、
new Blob([data], { type })→URL.createObjectURL()→download属性付きアンカー →click()→URL.revokeObjectURL()です。 - オブジェクトURLのライフタイムはそれを作成したドキュメントに紐づいているため、
a.click()との未検証のタイミング関係に頼るのではなく、フレームワークのクリーンアップ関数(ReactのuseEffectのreturn、VueのonUnmounted)内で解放してください。 showSaveFilePicker()はユーザーが保存先を選択できる唯一のブラウザネイティブな手段ですが、実験的機能でありChromiumのみのサポートです。そのため、機能検出を行い、アンカーパターンへのフォールバックを必ず実装してください。- ExcelがUTF-8のCSVを正しく開くのは、ファイルにBOMが含まれている場合のみです。文字列の先頭に
\uFEFFを付加してください。 - iOS Safariは
download属性によるダウンロードのサポートが歴史的に不安定であり、そのためiOSではダウンロードが保存されずに新しいタブで開かれることがよくあります。
標準パターン:JavaScriptでブラウザからファイルをダウンロードする
Discover how at OpenReplay.com.
JavaScriptで生成したファイルを確実にダウンロードするには、Blobを構築し、URL.createObjectURL()でオブジェクトURLを作成し、それをアンカーのhrefに割り当て、download属性にファイル名を設定し、アンカーをプログラム的にクリックしてから、URL.revokeObjectURL()でURLを解放します。BlobコンストラクタではMIMEタイプをデータとは独立して設定でき、オブジェクトURLはアンカーがナビゲートできる短い参照(blob:https://…)です。
データURIをデフォルトとして使用するのは避けてください。データURIはクライアントサイドのファイル生成においてデフォルトとして適切ではありません。Base64は3バイトごとに4文字にエンコードするため、パディングを考慮する前からペイロードサイズが約3分の1増加し(RFC 4648参照)、エンコードされた文字列全体がDOM属性値としてメモリに収まる必要があります。現在のdata: URLのサイズ制限はChromiumとFirefoxで512 MB、Safari/WebKitで2048 MBです(MDN data: URL reference)。しかし、エンコードのオーバーヘッドとインメモリ文字列のコストを考えると、これらの上限に達する前からBlobを使うほうが適切です。
現在のブラウザでは、アンカーをDOMに追加しなくてもclick()は動作するため、ヘルパー関数を自己完結した形に保てます。
// Chrome 14+、Firefox 20+(download属性、HTML5)で動作します。
// caniuse: https://caniuse.com/download
function downloadBlob(data, filename, type = 'application/octet-stream') {
const blob = new Blob([data], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a); // 最大限の互換性のために追加
a.click();
a.remove();
URL.revokeObjectURL(url);
}
const csv = 'name,role\nAda,Engineer\nGrace,Architect';
downloadBlob(csv, 'team.csv', 'text/csv;charset=utf-8;');
URL.revokeObjectURL(url)の呼び出しは、多くの例が示す以上に重要です。オブジェクトURLのライフタイムはそれを作成したドキュメントに紐づいているため、解放するか、ドキュメントがアンロードされるまでメモリに残り続けます。使い捨てのスクリプトであれば問題ありませんが、コンポーネントベースのアプリでボタンクリックのたびにヘルパーが実行される場合、解放されないURLが蓄積されていきます。上記の同期的な解放は、この関数内では呼び出し後に何も残らないため安全です。ただし、後述するフレームワークのセクションで示すように、コンポーネント内ではその配置が誤りになります。
HTMLのみのdownload属性とクロスオリジンでの暗黙の失敗
自分のオリジンにすでにホストされているファイルに対しては、JavaScriptは不要です。プレーンなアンカーにdownload属性を追加するだけです。
<a href="/reports/q3.pdf" download="q3-report.pdf">レポートをダウンロード</a>
download属性は、リンク先のリソースにナビゲートするのではなく保存するようブラウザに指示します。HTML5で追加され、Chrome 14+およびFirefox 20+でサポートされています(caniuse: download attribute)。また、blob:およびdata: URLも受け付けるため、前述の標準パターンと組み合わせて使用できます。
失敗するケースはクロスオリジンURLです。クロスオリジンのダウンロードでは、レスポンスにContent-Disposition: attachmentが含まれている場合のみdownload属性が有効になります。これがない場合、ブラウザは属性を無視し、リンクは確実な強制ダウンロードとして機能しません(WHATWG HTML Standard、リソースのダウンロード参照)。これは混乱の原因になりやすい点です。同一オリジンのファイルをダウンロードできる同じマークアップが、別オリジンのCDNから配信されたファイルに対してはナビゲートまたはインライン表示になります。サーバーを制御できる場合はそこでヘッダーを設定してください。制御できない場合は、ファイルをfetchして自分のオリジンからblobとして再配信します。
// クロスオリジンのファイルを同一オリジンのblobとして再配信し、`download`を有効にします。
async function downloadCrossOrigin(remoteUrl, filename) {
const res = await fetch(remoteUrl); // fetchを許可するCORSが必要
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
File System Access API:ユーザーが保存先を選択できるようにする
File System Access APIのshowSaveFilePicker()は、書き込み前にユーザーが保存先とファイル名を選択できる唯一のブラウザネイティブな手段です。OSの保存ダイアログを開き、ファイルハンドルを返し、FileSystemWritableFileStreamを通じて書き込みを行えます。MDNはこれを「Limited availability(限定的な可用性)」および「Experimental(実験的)」と分類しており、Chromiumファミリーのブラウザではバージョン86から実装されています(Chrome Status)が、FirefoxとSafariではサポートされていません。そのため、本番環境での使用には機能検出とアンカーパターンへのフォールバックが必須です。
showSaveFilePicker()は、ユーザーがピッカーを閉じた場合にAbortErrorという名前のDOMExceptionをスローします。これを失敗として扱わず、明示的に処理してください。
async function saveFile(data, filename, type = 'application/octet-stream') {
// 機能検出。注意:一部のサンドボックス化されたiframeコンテキストでは
// プロパティが存在してもスローする場合があるため、呼び出し自体もガードします。
if ('showSaveFilePicker' in window) {
try {
const handle = await window.showSaveFilePicker({
suggestedName: filename,
types: [{ description: 'File', accept: { [type]: ['.' + filename.split('.').pop()] } }],
});
const writable = await handle.createWritable();
await writable.write(new Blob([data], { type }));
await writable.close();
return;
} catch (err) {
if (err.name === 'AbortError') return; // ユーザーがキャンセル、エラーではない
// その他のエラーはアンカーフォールバックに処理を委ねる
}
}
// フォールバック:アンカー + オブジェクトURL(保存先の選択なし、ブラウザのデフォルト)
downloadBlob(data, filename, type);
}
'showSaveFilePicker' in windowチェックは適切なゲートですが、完全ではありません。一部のサンドボックス化されたiframeコンテキストではプロパティが存在しても呼び出し時にスローするため、try/catchは検出部分だけでなく実際の呼び出しを包む必要があります。
利用可能なメモリを超える大容量ファイルのストリーミング
ストリーミングは、ペイロード全体をRAMにバッファリングする代わりにチャンクを直接ディスクに書き込むことで、Blobのメモリ上限を回避します。上限の具体的なバイト数は固定されておらず、デバイス、OS、ブラウザの利用可能なヒープに依存します。ファイル全体をバッファリングしないストリーミングアプローチが2つあります。
Chromiumでは、showSaveFilePicker()が返すFileSystemWritableFileStreamを通じてインクリメンタルに書き込みます。各write()呼び出しがチャンクを書き込みストリームに追加するため、アプリケーションはファイル全体をメモリに保持する必要がありません。
// Chromium 86+のみ。チャンク単位で書き込み、ファイル全体をメモリに保持しません。
async function streamToDisk(filename, chunks) {
const handle = await window.showSaveFilePicker({ suggestedName: filename });
const writable = await handle.createWritable();
for await (const chunk of chunks) {
await writable.write(chunk); // chunk: string | ArrayBuffer | Blob
}
await writable.close();
}
File System Access APIをサポートしていないブラウザには、StreamSaver.jsがクロスブラウザ対応の手段です。そのREADMEによると、StreamSaver.jsは書き込みストリームを作成し、レスポンスヘッダーとService Workerを使ってサーバー駆動のダウンロードをエミュレートすることで、大容量のクライアント生成ファイルを保存します。これにより、1つのBlobに組み立てるのではなく、ストリーミングしながらデータがディスクに書き込まれます。WHATWG Streams Standardをベースにしています。
import streamSaver from 'streamsaver';
// Service Workerを介してサーバーダウンロードをエミュレート。データはディスクにストリーミングされます。
function streamWithStreamSaver(filename, readableStream) {
const fileStream = streamSaver.createWriteStream(filename);
// readableStream: Uint8ArrayチャンクのWHATWG ReadableStream
return readableStream.pipeTo(fileStream);
}
ストリーミングが必要なのは、出力が本当に大容量の場合です。数百メガバイトのエクスポート、生成された動画、長時間のデータフィードなどが該当します。数千行程度のCSVであれば、標準的なBlobパターンのほうがシンプルで十分です。
iOS Safariの注意点と回避策
iOS Safariはdownload属性を使ったファイルのダウンロードサポートが歴史的に不安定であり(WebKit bug 167341)、生成されたファイルが保存されずに新しいタブで開かれたりインライン表示されたりすることがよくあります。そのため、iOSはダウンロードコードが本番環境で最も頻繁に壊れるプラットフォームです。FileSaver.jsのREADMEには実際の影響が記載されています。iOSではsaveAs()はonClickなどのユーザーインタラクション内で実行する必要があり、setTimeoutを使うと動作しません。またiOSの制限により、saveAs()はダウンロードではなく新しいウィンドウを開く場合があります(FileSaver.js README)。
具体的な回避策を以下に示します。
- ダウンロードをユーザージェスチャーと同期させる。
awaitやsetTimeoutでジェスチャーから切り離さず、イベントハンドラー内で直接クリックをトリガーしてください。事前に非同期処理が必要な場合は、ユーザーがクリックする前にデータをフェッチして準備しておき、保存は同期的にトリガーします。 - ユーザーへの明示的なフォールバックを提供する。 ファイルがインライン表示された新しいタブで開かれた場合、「共有 → ファイルに保存」のヒントを表示してください。ユーザーが手動で保存を完了する必要があります。
- iOSのテキストエクスポートには
text/plainや表示可能なタイプを優先する。 プラットフォームが確実に提供できない強制ダウンロードに固執するより、ファイルがビューアで開かれてユーザーが保存するという流れを受け入れてください。
これらの失敗はサーバーログやアナリティクスには現れません。バックエンドへのリクエストは発生せず、例外もスローされないためです。ダウンロードフローの本番セッションリプレイでは、視覚的なフィードバックがないためユーザーがトリガーを繰り返しクリックしている様子がよく見られます。iOSでは新しいタブが開いてユーザーがすぐに閉じ、混乱して戻ってくるというパターンが典型的です。セッションリプレイは、このようなサイレントなジェスチャー・プラットフォーム依存の失敗を明らかにする数少ない手法の1つです。
// iOS対応:先に非同期処理を行い、ジェスチャー内で同期的に保存します。
async function prepareThenDownload(button, getData, filename, type) {
const data = await getData(); // ネットワーク/CPU処理はクリックパスの前に実行
button.onclick = () => {
// ユーザージェスチャーと同期 — setTimeoutもawaitも使わない
downloadBlob(data, filename, type);
};
}
MIMEタイプの落とし穴:インライン表示、ExcelのCSV、改行コード
Blobコンストラクタに渡すMIMEタイプは、ブラウザがファイルをどのように解釈するかを決定するのに役立ちます。誤ったMIMEタイプを指定すると、ダウンロードしたコンテンツが予期せず開かれたり、正しく処理されなかったりする一般的な原因になります。データに合ったタイプを設定し、download属性と組み合わせてブラウザがそれを添付ファイルとして扱うようにしてください。
最もよく報告されるCSVの問題は、ExcelでファイルをExcelで開いたときにアクセント付き文字が文字化けすることです。解決策はバイトオーダーマーク(BOM)です。ExcelはBOMが付加されたUTF-8のCSVファイルを正しく開きます。文字列の先頭にBOMを付加してください(Microsoft サポート:ExcelでUTF-8のCSVを正しく開く)。
// BOMなし:Excelはアクセント付きテキスト(é、ü、ñ)を文字化けさせる場合があります。
// BOMあり:ExcelはUTF-8ファイルを正しく開きます。
function downloadCSV(csvString, filename = 'export.csv') {
downloadBlob('\uFEFF' + csvString, filename, 'text/csv;charset=utf-8;');
}
text/csvをCSVのMIMEタイプとして登録しているRFC 4180からのさらなる注意点として、仕様ではレコードの区切り文字としてCRLF(\r\n)を定義しています。スプレッドシートとの最大限の互換性のために、行間の区切りには\nより\r\nを優先してください。また、カンマ、引用符、または改行を含むフィールドはダブルクォートで囲み、内部の引用符はダブルクォートで二重化する必要があります。
application/octet-streamを汎用の強制ダウンロードスイッチとして使用するのは避けてください。これはIANAが任意のバイナリデータ用として登録したタイプですが、確実な強制ダウンロードのメカニズムではありません。FileSaver.jsのREADMEでは、application/octet-streamを強制ダウンロードに使用するとSafariで問題が発生する可能性があると指摘しています。正しい具体的なタイプを使用し、保存を強制するにはdownload属性(またはContent-Disposition)に頼ってください。
フレームワークの注意点:SSR、オブジェクトURLのライフサイクル、メモリリーク
React、Vue、Svelteで標準的なダウンロードパターンを壊すコンポーネントライフサイクルのバグが2つあります。サーバーサイドレンダリング(SSR)中(documentが未定義の環境)にDOM APIを呼び出すことと、コンポーネントのライフサイクルの誤ったタイミングでオブジェクトURLを解放することです。どちらも同じ根本原因から生じています。コンポーネントはサーバーでレンダリングされ、クライアントで再レンダリングされますが、バニラのパターンは1つのドキュメントと1つの実行を前提としています。
SSRに対するガードを実装する。 Next.js、Nuxt、またはSvelteKitでは、コンポーネントのコードがdocumentが未定義の環境で実行される可能性があります。そこでdocument.createElementを呼び出すとエラーがスローされます。ダウンロードヘルパーは必ずランタイムチェックでガードしてください。
function triggerDownload(data, filename, type) {
if (typeof document === 'undefined') return; // SSRガード
downloadBlob(data, filename, type);
}
オブジェクトURLはすぐに解放せず、クリーンアップ時に解放する。 標準パターンの同期的なURL.revokeObjectURL(url)は、他の何かが実行される前に完了する一度きりのヘルパーには適しています。しかし、hrefやsrcとして使用するためにblob URLをstateに保存する場合、早すぎる解放はダウンロードを壊し、解放しないと再レンダリングをまたいでメモリがリークします。確実なルールは次のとおりです。オブジェクトURLのライフタイムはそれを作成したドキュメントに紐づいているため、不要になったら解放してください。コンポーネントベースのフレームワークでは、a.click()との未検証のタイミング関係に頼るのではなく、blob URLをstateに保存してクリーンアップ関数内で解放してください。
import { useEffect, useState } from 'react';
function DownloadLink({ data, filename, type = 'application/json' }) {
const [url, setUrl] = useState(null);
useEffect(() => {
const blob = new Blob([data], { type });
const objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
// クリーンアップはアンマウント時とエフェクトの再実行前に実行されます:
return () => URL.revokeObjectURL(objectUrl);
}, [data, type]);
// アンチパターン — ここでは解放しないでください:
// <a href={url} download={filename} onClick={() => URL.revokeObjectURL(url)}>
// クリック内で解放すると、ダウンロードが開始される前にURLが解放される可能性があります。
return url ? <a href={url} download={filename}>ダウンロード</a> : null;
}
Vueの場合はonMounted(またはwatch)でURLを作成し、onUnmountedで解放します。SvelteはonDestroyを使用します。いずれの場合も、対応する解放なしに再レンダリングやボタンクリックのたびに新しいオブジェクトURLが作成されると、ドキュメントのライフタイム全体にわたってメモリが増加し続けます。
download属性を全くサポートしていないレガシーブラウザには、FileSaver.jsが1行で使えるポリフィルとして残っています。ただし、モダンなターゲット環境では上記のプラットフォームパターンで代替できます。
まとめ
Blob → オブジェクトURL → アンカー → download → 解放という一連の流れは、クライアントサイドのダウンロード処理の大部分をカバーします。ユーザーが保存先を選択する必要がある場合はChromiumのみのshowSaveFilePicker()が、ファイルがメモリに収まらない場合はストリーミングが補完的な手段となります。本番環境で発生するバグは、ハッピーパスにあることはほとんどありません。暗黙的にナビゲートしてしまうクロスオリジン属性、BOMが必要なCSV、保存されずに開くiOSのタブ、誤ったタイミングで解放されるオブジェクトURLなどが典型的な問題です。クリーンアップをコンポーネントのライフサイクルに組み込み、実験的なAPIを呼び出す前に機能検出を行い、リリース前に実際のiOSデバイスでフローをテストしてください。
よくある質問
Blobは生データを格納し、URL.createObjectURL()で作成された短いオブジェクトURLで参照されます。一方、データURIはペイロード全体をBase64文字列としてURLに直接埋め込みます。Base64エンコードはパディング前からペイロードを約3分の1増加させ、エンコードされた文字列全体が属性値としてメモリに収まる必要があります。BlobはMIMEタイプをデータとは独立して設定でき、これらのコストを両方回避できるため、クライアントサイドのファイル生成においてより適切なデフォルトです。
download属性は同一オリジンのURLには有効ですが、WHATWG HTML Standardに従い、レスポンスにContent-Disposition: attachmentヘッダーが含まれていない限り、クロスオリジンのURLでは無視されます。別オリジンのCDNから配信されたファイルは、ダウンロードされずにナビゲートまたはインライン表示されます。解決策は、制御できるサーバーでヘッダーを設定するか、クロスオリジンのファイルをfetchしてURL.createObjectURL()を使って同一オリジンのblobとして再配信することです。
いいえ。File System Access APIのshowSaveFilePicker()はChromiumファミリーのブラウザのバージョン86以降でのみサポートされており、FirefoxとSafariには実装されていません。MDNはこれを「Limited availability(限定的な可用性)」および「Experimental(実験的)」と分類しています。そのため、本番コードでは'showSaveFilePicker' in windowで機能検出を行い、アンカーとオブジェクトURLのパターンにフォールバックする必要があります。一部のサンドボックス化されたiframeコンテキストではプロパティが存在してもスローする場合があり、ユーザーがダイアログを閉じるとAbortErrorがスローされるため、呼び出し自体をtry/catchで囲んでください。
ダウンロードが開始される前にオブジェクトURLを解放すると、ブラウザがアンカーの参照するblobを解決できなくなるため、ダウンロードが失敗します。完了前に同期的に終わる一度きりのヘルパーでは、click()の直後に解放しても安全です。hrefやsrcにblob URLをstateとして保存するコンポーネントでは、useEffectの戻り値、VueのonUnmounted、SvelteのonDestroyなどのクリーンアップ関数内で解放してください。解放しない場合は、ドキュメントのライフタイム全体にわたって再レンダリングをまたいでメモリがリークします。
Understand every bug
Uncover frustrations, understand bugs and fix slowdowns like never before 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.