2.

Ajax で CSV をダウンロードする方法|Blob と BOM 付き UTF-8

編集
この記事の要点
  • Ajax で受け取ったデータは Blob にして <a download> をクリックさせると保存できる
  • $.ajax() は既定でレスポンスを文字列として扱う。バイナリには xhrFields の指定が要る
  • Excel で開いて文字化けするのは UTF-8 の BOM が無いため。先頭に BOM(\uFEFF)を付ける
  • 単純なダウンロードなら Ajax を使わず location.href<a> のリンクで十分
  • URL.createObjectURL() で作った URL は revokeObjectURL() で解放する

Ajax を使わない場合

<a href="/api/export.csv?from=2026-09-01">CSV をダウンロード</a>
// JavaScript から遷移させる場合
location.href = "/api/export.csv?from=2026-09-01";

サーバーが Content-Disposition: attachment を返せば、それだけでダウンロードになります。認証が Cookie で、条件が URL に載せられるなら、この方法が最も簡単で確実です。

Ajax が必要になるのは次の場合です。

  • POST で大量の条件を送る必要がある
  • Authorization ヘッダーなど、リンクでは付けられない情報が要る
  • 生成中にローディング表示を出したい
  • 失敗したときに画面遷移せずエラーを表示したい

jQuery Ajax で受け取って保存する

function downloadCsv(params) {
  $.ajax({
    url: "/api/export",
    type: "POST",
    data: JSON.stringify(params),
    contentType: "application/json",
    // 既定では文字列として扱われ、バイナリが壊れる
    xhrFields: { responseType: "blob" },
  })
    .done(function (blob, status, xhr) {
      // ファイル名はサーバーのヘッダーから取る
      const disposition = xhr.getResponseHeader("Content-Disposition") || "";
      const m = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition);
      const filename = m ? decodeURIComponent(m[1]) : "export.csv";

      saveBlob(blob, filename);
    })
    .fail(function (xhr) {
      alert("ダウンロードに失敗しました: " + xhr.status);
    });
}

function saveBlob(blob, filename) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = filename;
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  URL.revokeObjectURL(url);       // 解放しないとメモリに残り続ける
}

xhrFields: { responseType: "blob" } を忘れると、jQuery が受信データを文字列として解釈します。ZIP や Excel のようなバイナリは、この時点で壊れて開けなくなります。

fetch で書く場合

async function downloadCsv(params) {
  const res = await fetch("/api/export", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(params),
  });

  if (!res.ok) {
    throw new Error(`HTTP ${res.status}`);
  }

  const blob = await res.blob();
  saveBlob(blob, "export.csv");
}

新規に書くなら fetch のほうが簡潔です。jQuery を使っているプロジェクトでも、この部分だけ fetch にして構いません。

ブラウザ側で CSV を作る

function toCsv(rows, headers) {
  const escape = (v) => {
    const s = v == null ? "" : String(v);
    // ダブルクォート・カンマ・改行を含むなら囲む。中の " は "" にする
    return /["\r\n,]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s;
  };

  const lines = [
    headers.map(escape).join(","),
    ...rows.map((row) => headers.map((h) => escape(row[h])).join(",")),
  ];
  return lines.join("\r\n");        // Excel 向けは CRLF
}

const rows = [
  { id: 1, name: "田中, 太郎", note: '改行\nあり' },
  { id: 2, name: "鈴木", note: '"引用" あり' },
];

const csv = toCsv(rows, ["id", "name", "note"]);
const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" });
saveBlob(blob, "users.csv");

エスケープの規則(RFC 4180)

値に含まれるもの処理
カンマ・改行・ダブルクォート値全体を " で囲む
ダブルクォートさらに "" に二重化する
先頭が = + - @Excel が数式として実行する。先頭に ' を付けるなどして無害化する

最後の項目はCSV インジェクションと呼ばれる問題です。利用者が入力した値をそのまま CSV に出すと、開いた人の環境で任意のコマンドが動く恐れがあります。

Excel で文字化けさせない

// BOM 無し: Excel が Shift_JIS と誤認して日本語が化ける
new Blob([csv], { type: "text/csv" });

// BOM 付き: UTF-8 として認識される
new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" });

\uFEFF1 文字の BOMです。Blob が UTF-8 で符号化するときに EF BB BF の 3 バイトになります。

サーバー側で BOM を付けている場合、ここでさらに付けると二重になり、1 列目のヘッダー名がずれます。どちらで付けるかを決めてください。

ファイル名の指定

Content-Disposition: attachment; filename="export.csv"; filename*=UTF-8''%E5%A3%B2%E4%B8%8A.csv

日本語のファイル名は filename*=UTF-8'' の形(RFC 5987)で送ります。Ajax で受け取る場合、このヘッダーを JavaScript から読むには CORS の設定が要ります。

Access-Control-Expose-Headers: Content-Disposition

同一オリジンなら不要ですが、API を別ドメインに置いている場合はこれが無いと getResponseHeader()null を返します。a.download にクライアント側で決めた名前を入れる方法でも構いません。

うまくいかないとき

症状原因
ファイルが壊れて開けないresponseType: "blob" の指定漏れ
日本語が化けるBOM が無い/二重に付いている
ダウンロードが始まらないa.click()ユーザー操作の延長で呼ばれていない
ファイル名が download になるa.download が空。別オリジンだと無視される
ヘッダーが読めないAccess-Control-Expose-Headers が無い
大きいファイルでタブが落ちるBlob をすべてメモリに載せている。サーバー直リンクに切り替える
Excel で列がずれる値の中のカンマや改行をエスケープしていない

非同期処理の中で a.click() を呼ぶと、ブラウザが「利用者の操作ではない」と判断してポップアップブロックの対象になることがあります。クリックから await を挟んで時間が空く場合は、進捗表示と再試行ボタンを用意しておくと確実です。

関連

編集
Post Share
子ページ

子ページはありません

同階層のページ
  1. $.ajax()
  2. CSVファイルのダウンロード