Reactで文章やファイルをgzipに圧縮してダウンロードする方法

​​React​Blob,CompressionStream,Playwright,Streams API,TypeScript

ブラウザに組み込まれている CompressionStream を使うと、ライブラリを足さずに、文章やファイルを gzip に圧縮できます。Blob を stream() で読み出して CompressionStream に通し、できた Blob を download 属性付きの a 要素で保存させます。

入力した文章と選んだファイルを .gz で保存する React のコンポーネントを作り、保存されたファイルを gzip コマンドで展開して、元と同じ中身に戻ることを確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、開発用ビルドで動かしました。保存は Playwright でボタンを押して受け取り、gzip 1.12 で調べました。Firefox と Safari、スマートフォンのブラウザ、本番用ビルド、50MB より大きなファイルは試していません。

「文章」の入力欄に CSV の行が入り、水色の「文章を gzip で保存」ボタンの下に、ファイルを選ぶ欄と灰色の「ファイルを gzip で保存」ボタンがある。いちばん下に「memo.txt.gz を保存しました(22.1 KB → 4.0 KB)」と出ている
約1,000行の文章を gzip で保存した直後の表示(表示の幅375px)

Blob を CompressionStream に通して、gzip の Blob を作る

圧縮する関数です。文章もファイルも Blob として受け取ります(MDN の File の説明のとおり、File は Blob の一種なので、そのまま渡せます)。

ts
// gzip.ts
export async function gzipBlob(
  input: Blob,
  onProgress?: (doneBytes: number) => void,
): Promise<Blob> {
  let done = 0;
  // 圧縮の前に通るバイト数を数えて、進み具合として知らせる
  const counter = new TransformStream<Uint8Array<ArrayBuffer>, Uint8Array<ArrayBuffer>>({
    transform(chunk, controller) {
      done += chunk.byteLength;
      onProgress?.(done);
      controller.enqueue(chunk);
    },
  });
  const stream = input
    .stream()
    .pipeThrough(counter)
    .pipeThrough(new CompressionStream('gzip'));
  const gz = await new Response(stream).blob();
  return new Blob([gz], { type: 'application/gzip' });
}

Blob.stream() は中身を少しずつ読み出す ReadableStream を返します。これを CompressionStream に pipeThrough() でつなぐと、圧縮されたバイト列が流れ出てきます。流れ出たものを1つの Blob にまとめるには、new Response(stream).blob() を使います。この書き方は、Compression Standard の仕様にある、Blob を展開する例と同じです。

Response.blob() で作った Blob の MIME タイプ(type)は、レスポンスの Content-Type の値になります。ストリームだけを渡した new Response(stream) では、Chromium 153 で type は空文字列でした。そこで最後に、application/gzip を付けた Blob に包み直しています。

途中の counter は、進み具合を出すためのものです。進み具合が要らなければ、input.stream().pipeThrough(new CompressionStream('gzip')) だけで足ります。

文章を圧縮するときは、new Blob([text]) で Blob にします。MDN の Blob() コンストラクターの説明のとおり、文字列は UTF-8 で書き込まれます。保存したファイルを展開すると、日本語の文章が元のとおりに戻りました。

型引数を Uint8Array だけにすると型エラーになる

型引数を <Uint8Array, Uint8Array> にした TransformStream を挟むと、TypeScript 7.0.2 の型チェックで、CompressionStream を渡す箇所がエラーになりました(エラーの出力は抜粋です)。

text
error TS2345: Argument of type 'CompressionStream' is not assignable to parameter of type 'ReadableWritablePair<Uint8Array<ArrayBuffer>, Uint8Array<ArrayBufferLike>>'.
  Types of property 'writable' are incompatible.
    Type 'WritableStream<BufferSource>' is not assignable to type 'WritableStream<Uint8Array<ArrayBufferLike>>'.

型引数を省いた Uint8Array は Uint8Array<ArrayBufferLike> のことで、CompressionStream が受け取る BufferSource と型が合いません。上のコードのように Uint8Array<ArrayBuffer> と書くと、エラーは出なくなりました。counter を挟まずに Blob.stream() から直接 CompressionStream につなぐ書き方も、エラーになりませんでした。

a 要素の download 属性で保存させる

できた Blob は、オブジェクト URL にして a 要素に渡し、クリックして保存させます。

ts
// gzip.ts(続き)
export function saveBlob(blob: Blob, filename: string): void {
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  a.click();
  URL.revokeObjectURL(url);
}

MDN の a 要素の説明によると、download 属性は同じオリジンの blob: URL で使えます。使い終わったオブジェクト URL は、URL.revokeObjectURL() で解放します。Chromium 153 では、click() の直後に解放しても、ファイルは最後まで保存されました。

ファイル名には、元の名前に .gz を付けています。保存された gzip の先頭10バイトは 1f 8b 08 00 00 00 00 00 00 03 でした。比べるために、gzip コマンドで同じ文章を圧縮すると 1f 8b 08 08 80 bb ef 6a 00 03 になり、file コマンドは元の名前(memo.txt)と更新日時を表示しました。Chromium で作った .gz では、file コマンドはどちらも表示しません。gzip の中に、元のファイル名も更新日時も入っていないということです。

そのため、renamed.txt.gz と名前を変えてから gunzip -N で展開すると、renamed.txt になりました。展開したときの名前は、名前を変えた後の .gz のファイル名から決まります。

React のコンポーネント

文章の入力欄とファイルを選ぶ欄に、ボタンを1つずつ付けました。圧縮の間は進み具合を出し、終わったら圧縮の前と後の大きさを出します。

tsx
// GzipDownload.tsx
import { useState } from 'react';
import { gzipBlob, saveBlob } from './gzip';

type Status =
  | { kind: 'idle' }
  | { kind: 'working'; done: number; total: number }
  | { kind: 'done'; filename: string; before: number; after: number }
  | { kind: 'error'; message: string };

const kb = (n: number) => `${(n / 1024).toFixed(1)} KB`;

export function GzipDownload() {
  const [text, setText] = useState('');
  const [file, setFile] = useState<File | null>(null);
  const [status, setStatus] = useState<Status>({ kind: 'idle' });

  async function run(source: Blob, filename: string) {
    setStatus({ kind: 'working', done: 0, total: source.size });
    try {
      const gz = await gzipBlob(source, (done) =>
        setStatus({ kind: 'working', done, total: source.size }),
      );
      saveBlob(gz, filename);
      setStatus({ kind: 'done', filename, before: source.size, after: gz.size });
    } catch (e) {
      setStatus({ kind: 'error', message: String(e) });
    }
  }

  const working = status.kind === 'working';

  return (
    <div className="demo">
      <label>
        文章
        <textarea value={text} onChange={(e) => setText(e.target.value)} rows={5} />
      </label>
      <button
        disabled={working || text === ''}
        onClick={() => run(new Blob([text], { type: 'text/plain' }), 'memo.txt.gz')}
      >
        文章を gzip で保存
      </button>

      <label>
        ファイル
        <input type="file" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
      </label>
      <button
        disabled={working || file === null}
        onClick={() => file && run(file, `${file.name}.gz`)}
      >
        ファイルを gzip で保存
      </button>

      <p role="status">
        {status.kind === 'working' &&
          `圧縮中(${Math.floor((status.done / Math.max(status.total, 1)) * 100)}%)`}
        {status.kind === 'done' &&
          `${status.filename} を保存しました(${kb(status.before)} → ${kb(status.after)})`}
        {status.kind === 'error' && `失敗しました:${status.message}`}
      </p>
    </div>
  );
}

50MB のログファイルを選んで保存すると、「圧縮中(0%)」「圧縮中(7%)」と進んで「圧縮中(100%)」になり、最後に「big.log.gz を保存しました(51200.0 KB → 5192.7 KB)」と出ました。「圧縮中」の表示は26回変わり、最初の表示から「保存しました」の表示まで 376 ミリ秒でした。

進み具合は、Blob.stream() が渡すチャンクを1つ読むたびに増えます。同じ50MB のファイルを読み出すと、チャンクは最初のうちは 64KB(65,536 バイト)ずつで、いちばん大きいものは 1,507,328 バイトで、全部で 601 個でした。

どれくらい小さくなるか

CompressionStream で作った .gz と、gzip コマンドの各レベル(-1、-6、-9)で作ったものの大きさを比べました。gzip コマンドには、名前と日時を入れない -n を付けています。単位はバイトです。

元のファイル 元の大きさ CompressionStream gzip -1 gzip -6 gzip -9
日本語を含む CSV の文章(約1,000行) 22,641 4,118 5,083 4,120 3,852
ログファイル(約50MB) 52,428,835 5,317,288 6,044,814 5,339,678 5,103,326
乱数で作ったファイル(1MB) 1,048,576 1,048,919 1,048,754 1,048,754 1,048,754

Chromium の結果は、CSV の文章とログのどちらも gzip -6 に近い大きさでした。CompressionStream のコンストラクターが受け取るのは形式の名前だけで(仕様の CompressionStream の定義)、圧縮レベルを選ぶ引数はありません。

乱数で作ったファイルは、圧縮すると 343 バイト大きくなりました。gzip コマンドでも、どのレベルでも元より大きくなっています。乱数のように繰り返しのないデータは、gzip にしても小さくなりませんでした。

Chromium 153 で同じ文字列を2回圧縮すると、バイト列はまったく同じでした。先に見たとおり、ヘッダーに日時が入らないためです。

gzip 以外に渡せる形式

CompressionStream に渡す形式の名前を変えて、Chromium 153 で試しました。

形式 結果
gzip 作れる
deflate 作れる
deflate-raw 作れる
brotli TypeError(Unsupported compression format)
zstd TypeError(Unsupported compression format)
zip TypeError(Unsupported compression format)

brotli は仕様に入っていて、MDN の CompressionStream() コンストラクターの説明には zstd も載っています。ただ、同じページの互換性の表では、執筆時点で Chrome は brotli と zstd のどちらにもまだ対応していません。brotli は Firefox 147 と Safari 18.4 から使えます。gzip のファイルを作るだけなら、gzip は Chrome 80、Firefox 113、Safari 16.4 から使えます。

zip は形式の名前にありません。複数のファイルを1つにまとめたいときは、別の記事「Reactで、複数のファイルをZIPにまとめてダウンロードする方法」で使った JSZip のようなライブラリが要ります。

ブラウザの中で元に戻す

逆向きの DecompressionStream を使うと、.gz をブラウザの中で展開できます。

ts
const text = await new Response(
  gz.stream().pipeThrough(new DecompressionStream('gzip')),
).text();

こんにちは、gzip。🐈 を100行つないだ 3,000 バイトの文章は 77 バイトになり、DecompressionStream で戻した文字列は元の文字列と一致しました。

​Posted by 86