Reactで、複数のファイルをZIPにまとめてダウンロードする方法

​​React​AbortController,fetch,JSZip,Playwright,TypeScript

選んだ複数のファイルを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 の展開は試していません。

6件のファイルにチェックが付いた一覧の下で「6件を ZIP でダウンロード」を押すと、「ファイルを取得中」から「ZIP を作成中(50%)」と進み、「ダウンロード.zip を保存しました(3842 KB)」と出る。下の枠には、直前に保存した ZIP の中身として「資料/表紙 (2).png」を含む6つのファイル名が並ぶ。続けて「資料/売上.csv」のチェックを外すとボタンが「5件を ZIP でダウンロード」に変わり、押すと 3724 KB の ZIP が保存される
ボタンを押して ZIP を保存する動き。取得は1件ずつ遅らせ、CPU を6倍遅くして撮った(カーソル、輪、回数、下の枠は GIF 用に重ねた表示)

JSZip と一緒に @types/node も入れる

JSZip は npm からインストールします。型定義はパッケージに含まれています。

bash
npm i jszip
npm i -D @types/node

@types/node も入れているのは、JSZip の型定義が先頭で Node.js の型を参照しているからです。検証環境で、skipLibCheck を false にした tsconfig で、@types/node を入れずに tsc で型チェックすると、JSZip の型定義の中でエラーが6件出て、終了コードは 1 になりました。最初の2件は次のとおりです。

text
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 をファイルとして保存させます。

ts
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 に持ち、ボタンの下に表示します。

tsx
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 に渡します。

tsx
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 になっています。どのファイルも、元のファイルと中身が一致しました。

text
写真/            フォルダ
写真/海.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 を使って結果を少しずつ受け取る方法も書かれています。

​Posted by 86