Reactで、複数のファイルをZIPにまとめてダウンロードする方法
選んだ複数のファイルを1つの ZIP にまとめてダウンロードさせる処理は、サーバーに ZIP を作る仕組みがなくても、ブラウザの中で書けます。各ファイルを fetch で Blob として受け取り、JSZip で ZIP の Blob にして、download 属性を付けた a 要素で保存させます。
ここでは、チェックを付けたファイルをフォルダ分けして ZIP にするボタンを作ります。何も指定しないと圧縮されないこと、同じ名前のファイルが上書きされること、1件の取得に失敗したときに残りの取得を止める方法も、検証の結果とあわせて書きます。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、JSZip 3.10.2、esbuild 0.28.2、Playwright 1.63.0 です。ZIP に入れたのは、ffmpeg で作った画像(JPEG 2枚と PNG 3枚)と、20,000行の CSV です(合わせて約 4.5MB)。Playwright でボタンを押し、保存された ZIP を Python の zipfile と unzip で調べました。Firefox と Safari、スマートフォンのブラウザ、検証に使ったものより大きなファイル、Windows や macOS での ZIP の展開は試していません。

JSZip と一緒に @types/node も入れる
JSZip は npm からインストールします。型定義はパッケージに含まれています。
npm i jszip
npm i -D @types/node
@types/node も入れているのは、JSZip の型定義が先頭で Node.js の型を参照しているからです。検証環境で、skipLibCheck を false にした tsconfig で、@types/node を入れずに tsc で型チェックすると、JSZip の型定義の中でエラーが6件出て、終了コードは 1 になりました。最初の2件は次のとおりです。
node_modules/jszip/index.d.ts(7,23): error TS2688: Cannot find type definition file for 'node'.
node_modules/jszip/index.d.ts(35,13): error TS2503: Cannot find namespace 'NodeJS'.
@types/node を入れると、エラーはなくなりました。
ZIP を作る関数と保存する関数
ZIP にまとめる処理は、React に依存しない関数にして zip.ts に置きました。createZip() はファイルの名前と中身の配列を受け取り、ZIP の Blob を返します。saveBlob() は、その Blob をファイルとして保存させます。
export type ZipEntry = { path: string; data: Blob | string };
// すでに圧縮された形式は、DEFLATE をかけてもほとんど小さくならない
const STORED = /\.(jpe?g|png|gif|webp|mp4|zip|pdf)$/i;
// 同じ名前のファイルを入れると上書きされるので、「名前 (2).拡張子」のようにずらす
export function uniquePath(path: string, used: Set<string>): string {
let candidate = path;
const dot = path.lastIndexOf('.');
const base = dot > path.lastIndexOf('/') ? path.slice(0, dot) : path;
const ext = path.slice(base.length);
for (let n = 2; used.has(candidate); n++) candidate = `${base} (${n})${ext}`;
used.add(candidate);
return candidate;
}
export async function createZip(entries: ZipEntry[], onProgress?: (percent: number) => void): Promise<Blob> {
// JSZip は小さくないので、ZIP を作るときに初めて読み込む
const { default: JSZip } = await import('jszip');
const zip = new JSZip();
const used = new Set<string>();
for (const { path, data } of entries) {
zip.file(uniquePath(path, used), data, { compression: STORED.test(path) ? 'STORE' : 'DEFLATE' });
}
return zip.generateAsync({ type: 'blob' }, (meta) => onProgress?.(meta.percent));
}
export function saveBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
file() には、ZIP の中での名前と中身を渡します。名前を / で区切ると、フォルダの中に入ります。中身には文字列のほか、Blob や File をそのまま渡せます。<input type="file"> で選ばれたファイルも、FileReader で読まずに入れられます。
generateAsync() で type: 'blob' を指定すると、ZIP が Blob で返ってきます。2つ目の引数に渡した関数(ドキュメントでは onUpdate)は、処理が進むたびに呼ばれます。受け取る meta.percent は 0〜100 の数値です。
saveBlob() は、URL.createObjectURL() で Blob を指す URL を作り、download 属性を付けた a 要素をクリックして保存させます。クリックの直後に URL.revokeObjectURL() で URL を解放しています。すぐに解放しても保存が途中で止まらないかを確かめたところ、検証環境の Chromium では、3842 KB の ZIP が最後まで保存されました。Firefox と Safari では試していません。
何も指定しないと、ZIP は圧縮されない
generateAsync() の compression の既定値は STORE(圧縮しない)です。圧縮するには DEFLATE を指定します。ファイルごとに変えたいときは、file() の3つ目の引数に compression を渡します。
検証用のファイル(JPEG 2枚、PNG 1枚、CSV 1つで合わせて 4,509,802 バイト)で、指定のしかたによる違いを比べました。時間は5回の中央値です。
| 指定のしかた | ZIP の大きさ | 時間 |
|---|---|---|
| 何も指定しない(すべて STORE) | 4,510,680 バイト | 45ms |
| すべて DEFLATE | 3,930,410 バイト | 306ms |
画像は STORE、ほかは DEFLATE(createZip()) |
3,932,037 バイト | 106ms |
ファイルごとに見ると、CSV は DEFLATE で 699,411 バイトから約 12 万バイトになりました。一方、JPEG は 0.1% も小さくならず、PNG は STORE より大きくなりました。JPEG や PNG はもともと圧縮された形式なので、もう一度 DEFLATE をかけても、時間がかかるわりに小さくなりません。そこで createZip() では、拡張子で画像などを見分けて STORE にしています。すべて DEFLATE にした場合との差は 1,627 バイトで、時間は約3分の1でした。
同じ名前のファイルは、あとから入れたものに置き換わる
JSZip の file() は、ファイルを「追加する(または更新する)」関数です。同じ名前で2回呼ぶと、2つ目の中身で上書きされます。検証環境で 資料/表紙.png という名前で 1,159 バイトと 1,086 バイトの2つの画像を続けて入れると、ZIP の中のファイルは1つだけで、中身は 1,086 バイトのほうでした。エラーや警告は出ません。
ユーザーが選んだファイルや、データベースに登録された名前を使うと、名前が重なることがあります。uniquePath() は、使った名前を Set に覚えておき、重なったら 資料/表紙 (2).png、資料/表紙 (3).png のように番号を付けます。拡張子の前の . は、最後の / より後ろにあるものだけを見ています。そのため、v1.2/notes を2回入れると、2つ目は v1.2/notes (2) になり、フォルダ名の途中には番号が入りません。
ボタンのコンポーネントで、取得から保存までをつなぐ
ボタンを押すと、ファイルを取得し、ZIP を作って保存するコンポーネントです。いまどこまで進んだかを status に持ち、ボタンの下に表示します。
import { useState } from 'react';
import { createZip, saveBlob, type ZipEntry } from './zip';
export type DownloadFile = { name: string; url: string };
type Status =
| { kind: 'idle' }
| { kind: 'fetching'; done: number; total: number }
| { kind: 'zipping'; percent: number }
| { kind: 'done'; size: number }
| { kind: 'error'; message: string };
async function fetchBlob(file: DownloadFile, signal: AbortSignal): Promise<Blob> {
const res = await fetch(file.url, { signal });
if (!res.ok) throw new Error(`${file.name} を取得できませんでした(${res.status})`);
return res.blob();
}
export function ZipDownloadButton({ files, zipName }: { files: DownloadFile[]; zipName: string }) {
const [status, setStatus] = useState<Status>({ kind: 'idle' });
const busy = status.kind === 'fetching' || status.kind === 'zipping';
async function handleClick() {
// 1つでも取得に失敗したら、残りの取得を止める
const controller = new AbortController();
let done = 0;
setStatus({ kind: 'fetching', done, total: files.length });
try {
const entries: ZipEntry[] = await Promise.all(
files.map(async (file) => {
const data = await fetchBlob(file, controller.signal).catch((e) => {
controller.abort();
throw e;
});
done++;
setStatus({ kind: 'fetching', done, total: files.length });
return { path: file.name, data };
}),
);
const blob = await createZip(entries, (percent) => setStatus({ kind: 'zipping', percent: Math.floor(percent) }));
saveBlob(blob, zipName);
setStatus({ kind: 'done', size: blob.size });
} catch (e) {
setStatus({ kind: 'error', message: e instanceof Error ? e.message : String(e) });
}
}
return (
<div>
<button onClick={handleClick} disabled={busy || files.length === 0}>
{files.length}件を ZIP でダウンロード
</button>
<p role="status">
{status.kind === 'fetching' && `ファイルを取得中(${status.done}/${status.total})`}
{status.kind === 'zipping' && `ZIP を作成中(${status.percent}%)`}
{status.kind === 'done' && `${zipName} を保存しました(${Math.round(status.size / 1024)} KB)`}
{status.kind === 'error' && status.message}
</p>
{status.kind === 'zipping' && <progress max={100} value={status.percent} />}
</div>
);
}
fetch は、サーバーが 404 や 500 を返しても失敗になりません(MDN の fetch() の説明)。そのため fetchBlob() で res.ok を確かめ、取得できなかったファイルの名前を入れたエラーにしています。取得や ZIP の作成の途中はボタンを押せないようにして、同じ処理が重ならないようにしました。
使う側では、チェックを付けたファイルだけを files に渡します。
import { useState } from 'react';
import { ZipDownloadButton, type DownloadFile } from './ZipDownloadButton';
const FILES: DownloadFile[] = [
{ name: '写真/海.jpg', url: '/files/sea.jpg' },
{ name: '写真/山.jpg', url: '/files/mountain.jpg' },
{ name: '写真/花.png', url: '/files/flower.png' },
{ name: '資料/売上.csv', url: '/files/sales.csv' },
{ name: '資料/表紙.png', url: '/files/cover-a.png' },
{ name: '資料/表紙.png', url: '/files/cover-b.png' },
];
export function App() {
const [checked, setChecked] = useState<boolean[]>(() => FILES.map(() => true));
const selected = FILES.filter((_, i) => checked[i]);
return (
<>
<ul>
{FILES.map((file, i) => (
<li key={file.url}>
<label>
<input
type="checkbox"
checked={checked[i]}
onChange={(e) => setChecked((c) => c.map((v, j) => (j === i ? e.target.checked : v)))}
/>
{file.name}
</label>
</li>
))}
</ul>
<ZipDownloadButton files={selected} zipName="ダウンロード.zip" />
</>
);
}
6件すべてを選んで押すと、ダウンロード.zip が保存されました。中身を Python の zipfile で調べた結果です。資料/表紙.png が2つあったので、2つ目は 資料/表紙 (2).png になっています。どのファイルも、元のファイルと中身が一致しました。
写真/ フォルダ
写真/海.jpg STORE UTF-8フラグ=True 1239821 → 1239821 バイト 元のファイルと同じ=True
写真/山.jpg STORE UTF-8フラグ=True 1606333 → 1606333 バイト 元のファイルと同じ=True
写真/花.png STORE UTF-8フラグ=True 964237 → 964237 バイト 元のファイルと同じ=True
資料/ フォルダ
資料/売上.csv DEFLATE UTF-8フラグ=True 699411 → 120768 バイト 元のファイルと同じ=True
資料/表紙.png STORE UTF-8フラグ=True 1159 → 1159 バイト 元のファイルと同じ=True
資料/表紙 (2).png STORE UTF-8フラグ=True 1086 → 1086 バイト 元のファイルと同じ=True
写真/ と 資料/ のフォルダの項目は、JSZip が自動で作ったものです。unzip -t で確かめると、壊れたデータはありませんでした。チェックをすべて外すと、ボタンは「0件を ZIP でダウンロード」になり、押せなくなりました。
1件の取得に失敗したら、残りの取得を止める
Promise.all() は、どれか1つが失敗した時点で失敗になります。ただし、MDN の Promise.all() の説明にあるとおり、残りの処理を止めるわけではありません。
AbortController を使わない版で、このことを確かめました。写真/花.png だけをすぐに 404 にし、ほかのファイルは 500ms 遅らせて返すと、画面には一度「写真/花.png を取得できませんでした(404)」と出ました。ところが1.5秒後には、表示が「ファイルを取得中(5/6)」に戻っていました。遅れて取得が終わったファイルが、setStatus で表示を上書きしたためです。status が fetching のままなので、ボタンも押せないままでした。
そこで、取得の失敗を受けたら AbortController の abort() を呼び、残りの fetch を止めています。止められた fetch は AbortError で失敗するので、done++ と setStatus まで進みません。同じ条件で試すと、1.5秒後もエラーの表示のままで、ボタンも押せる状態に戻りました。Playwright で見ると、6件すべてのリクエストが net::ERR_ABORTED で止まっていました。404 を返した 写真/花.png も、レスポンスの本文を読む前に abort() したので、Playwright では同じく net::ERR_ABORTED と記録されました。
進み具合を onUpdate のたびに更新しても、コミットは6回だった
約 4.5MB のファイルで ZIP を作ったとき、generateAsync() の onUpdate は 255 回呼ばれました。Math.floor() で整数にした値は 59 通りでした。
呼ばれるたびに setStatus しているので、再レンダリングが多くなるかを確かめました。開発用のビルド(StrictMode あり)で React の Profiler を使い、ZIP を作っている間のコミットの回数を数えると、3回試してどれも 6 回でした。本番用ビルドでも、画面の表示は「ZIP を作成中(0%)」から「ZIP を作成中(87%)」までの6通りでした。この大きさのファイルでは、呼ばれる回数を間引く処理を書かなくても、255 回の呼び出しが 6 回のコミットにまとまりました。
JSZip は、ボタンを押したときに読み込む
createZip() の中で await import('jszip') と書き、JSZip をボタンを押したときに読み込むようにしています。MDN の import() の説明にも、ボタンのクリックなどの操作に応じてモジュールを読み込む例があります。
esbuild でコードを分けてビルドすると(--splitting --format=esm)、JSZip は 97,952 バイトの別ファイルになりました。ページを開いたときに読み込む main.js は 225,885 バイトでした(ほかに 833 バイトの共通部分のファイルがあります)。zip.ts の先頭で import JSZip from 'jszip' と書いた場合は 1つのファイルにまとまり、325,331 バイトでした。Playwright でリクエストを記録すると、JSZip のファイルはボタンを押したあとに初めて読み込まれていました。JSZip は既定のエクスポートとして読めるので、const { default: JSZip } = await import('jszip') と書いています。esbuild 以外のバンドラーでは、この書き方を試していません。
ZIP の中の日本語の名前と、保存するときのファイル名
JSZip は、ZIP の中のファイル名を UTF-8 で書きます(generateAsync() の説明の encodeFileName)。検証環境で作った ZIP では、日本語の名前のファイルに、名前が UTF-8 であることを示すフラグが付いていました。Python の zipfile でも、LANG=C.UTF-8 で動かした unzip -l でも、写真/海.jpg のように正しく表示されました。
保存するときのファイル名(download 属性の値)にも日本語を使えます。ただし、検証環境の Linux で Chromium を LANG=C のまま起動すると、download="ダウンロード.zip" としても、保存されるファイル名は download になりました。files.zip のような英数字の名前はそのまま保存されました。LANG=C.UTF-8 で起動すると、ダウンロード.zip のまま保存されました。Linux の CI などで、日本語のファイル名で保存されることをテストするときは、ブラウザをロケールが UTF-8 の環境で起動してください。
大きなファイルは、まとめてメモリーに載る
JSZip の制限の説明によると、generateAsync() はブラウザを固まらせずに処理しますが、作った ZIP の全体をメモリーに持ちます。この記事のコードは、取得したファイルもすべて Blob として持ったまま ZIP を作ります。メモリーに収まらないほど大きなファイルをまとめるなら、サーバーで ZIP を作る方法も検討してください。同じ説明には、StreamHelper を使って結果を少しずつ受け取る方法も書かれています。





