3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

外部ライブラリに頼らずFEでCSVをダウンロードする

3
Posted at

はじめに

外部ライブラリに頼らずFEでCSVでダウンロードする機能を実装しました。私はバックエンドが主な業務で、フロントエンドはほぼ未経験の状態からの実装です。調べながら書いたので備忘録として残します。FEでCSV生成をどうやるか迷っている人に届けばと思います。

ちなみに「FEかBEどちらでCSVを生成するか」という意思決定の話は別の記事で書いています。今回はFEで実装することに決めたあとの話です。

技術スタック

  • React + TypeScript
  • CSV生成ライブラリ:なし(Blob + URL.createObjectURL のみ)

ライブラリを使わなかった理由は、今回のCSV生成の要件がシンプルで、導入コストに見合わないと判断したからです。

実装の全体像

大まかな流れはこうです。

1. データを string[][] に整形する
2. 各フィールドをRFC 4180に沿ってエスケープする
3. BOM付きのBlobを作成する
4. <a> タグを動的に作成してクリックさせてダウンロードする

コード解説

1. CSVフィールドのエスケープ

最初に、フィールドのエスケープ処理を書きました。

// csv.ts

export function escapeCsvField(value: string): string {
  // カンマ・ダブルクォート・改行が含まれている場合だけ特殊処理が必要
  if (value.includes(",") || value.includes('"') || value.includes("\n")) {
    // RFC 4180: ダブルクォートはダブルクォートでエスケープ("" に変換)
    // その後、フィールド全体をダブルクォートで囲む
    return `"${value.replace(/"/g, '""')}"`;
  }
  // 特殊文字がなければそのまま返す
  return value;
}

RFC 4180というCSVの仕様があり、カンマ・ダブルクォート・改行を含む値はダブルクォートで囲む必要があります。知らなかったので調べました。

2. ダウンロードボタンコンポーネント

CSVダウンロードのロジックをボタンコンポーネントに閉じ込めました。

// DiffCsvButton/index.tsx

type Props = {
  filename: string;          // ダウンロードするファイル名
  headers: string[];         // CSVのヘッダー行
  getRows: () => string[][]; // CSVの行データを返す関数(遅延評価)
};

export const DiffCsvButton = ({ filename, headers, getRows }: Props) => {
  const { t } = useTranslation();

  const handleButtonClick = () => {
    // ボタンクリック時にデータを取得し、各フィールドをエスケープ
    const dataRows = getRows().map((row) => row.map(escapeCsvField));

    // ヘッダーとデータ行を結合してCSV文字列を組み立てる
    const csvContent = [headers.map(escapeCsvField), ...dataRows]
      .map((r) => r.join(","))
      .join("\n");

    // BOM(Byte Order Mark)を先頭に付与する
    // これがないとExcelで日本語を開いたとき文字化けする
    const bom = "\uFEFF";

    // Blobオブジェクトを作成(type指定でCSVとして認識させる)
    const blob = new Blob([bom + csvContent], { type: "text/csv;charset=utf-8;" });

    // BlobのURLを生成
    const url = URL.createObjectURL(blob);

    // <a> タグを動的に作成してクリックでダウンロードをトリガー
    const a = document.createElement("a");
    a.href = url;
    a.download = filename;
    a.click();

    // 使い終わったURLは即座に解放する(メモリリーク防止)
    URL.revokeObjectURL(url);
  };

  return (
    <Button variant="text" onClick={handleButtonClick} svg={DownloadIcon}>
      {t("confirm.downloadCsv")}
    </Button>
  );
};

3. ドメイン層でのデータ整形

CSVに出す行データの組み立てはドメイン層で行っています。

// teamDiff.ts

// ネストしたツリー構造の差分データを再帰的にフラット化する
export function flattenDiff(teams: TeamDiff[]): TeamDiff[] {
  return teams.flatMap((team) => [team, ...flattenDiff(team.children)]);
}

// 1行分のCSVデータを組み立てる
export function getTeamDiffCsvRow(row: TeamDiff, t: TFunction): string[] {
  const typeLabel = (type: TeamDiff["type"]): string => {
    if (type === "added") return t("diff.badges.added");
    if (type === "removed") return t("diff.badges.removed");
    if (type === "modified") return t("diff.badges.modified");
    return type;
  };

  const nameBefore = row.nameBefore ?? "";
  const nameAfter = row.nameAfter ?? "";
  const parentBefore = row.parentNameBefore ?? "";
  const parentAfter = row.parentNameAfter ?? "";

  return [typeLabel(row.type), nameBefore, nameAfter, parentBefore, parentAfter, row.memoAfter || row.memoBefore];
}

4. 使い方

呼び出し側ではこのように使います。

<DiffCsvButton
  filename={`${projectName}_${sheetName}${t("diffCsvName.team")}`}
  headers={getTeamDiffCsvHeaders(t)}
  getRows={() =>
    sortTeams(flattenDiff(diff).filter((item) => item.type !== "unchanged")).map((row) =>
      getTeamDiffCsvRow(row, t),
    )
  }
/>

getRows の中で「unchangedを除外してフラット化→ソート→行データに変換」を行っています。

まとめ

FEでのCSV生成は、ライブラリなしでも BlobURL.createObjectURL を使えば実装できます。ポイントをまとめると

  • エスケープ処理は自前で書く(RFC 4180に沿ってカンマ・クォート・改行をエスケープ)
  • BOMを付ける(Excelで日本語を開く場合は必須)
  • URLは使い終わったら即解放URL.revokeObjectURL

シンプルな要件であればライブラリは不要です。複雑なフォーマット対応や大量データが必要になってきたら、その時点でライブラリの導入を検討すればいいと思います。

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?