はじめに
外部ライブラリに頼らず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生成は、ライブラリなしでも Blob と URL.createObjectURL を使えば実装できます。ポイントをまとめると
- エスケープ処理は自前で書く(RFC 4180に沿ってカンマ・クォート・改行をエスケープ)
- BOMを付ける(Excelで日本語を開く場合は必須)
-
URLは使い終わったら即解放(
URL.revokeObjectURL)
シンプルな要件であればライブラリは不要です。複雑なフォーマット対応や大量データが必要になってきたら、その時点でライブラリの導入を検討すればいいと思います。