TypeScriptにて、lightweightなfflateライブラリを使ってzipファイルの圧縮/解凍をする話

18518 ワード

fflateライブラリを使うことで、JSZipライブラリ使用時よりもバンドルサイズを80kB近く削減できました。本記事では、fflateライブラリによるzipファイルの圧縮/解凍の仕方について記載します。

背景

TypeScriptで実装したWebアプリ上でzipファイルを圧縮/非圧縮するとき、JSZipライブラリが候補に上がります。一方で、JSZipライブラリはサイズが700kB近くと大きく、なおかつTree-Shakingに未対応です[1]。そのため、一部機能を使うだけでもアプリのバンドルサイズが大きくなってしまう懸念があります。同ライブラリのforkとしてtelerik/jszip-esmライブラリも使うことができますが、v3.2.2時点のforkにとどまっています。

fflateライブラリについて

fflateライブラリは比較的後発(初回コミットが2020年8月)で、さらなる高速化やバンドルサイズの削減が図られたライブラリです。README.md上で以下の利点が強調されています。

  • pakoライブラリと比較して解凍は40%、圧縮は50%高速
  • バンドルサイズが8kBに収まる
  • gzip, zlibアルゴリズムに対応している
  • スレッドセーフである
  • ES Modulesに対応している

以上の特徴から、バンドルサイズの削減やzipファイルの圧縮/解凍を高速にできます。

そのほか、スター数も1.2kとなっており多くの支持を得ています。

fflateライブラリの使い方

注記

本ライブラリでは、ファイルのコンテンツをUint8Arrayで取り扱うことが想定されています。そのため、ファイルのコンテンツを関数に渡すときはUint8Arrayに変換してから、関数からファイルのコンテンツを関数から受け取るときはUint8Arrayから適宜別の型に変換が必要です。

ライブラリのインストール

npmの場合はnpm i -S fflate、yarnの場合はyarn add fflateでOKです。

zipファイルを解凍する

以下のコード例により、指定されたzipファイルを解凍できます。結果はFile型のArrayで返します。

unzip関数において、結果は { ["ファイル名1"]: ファイル名1のコンテンツ(Uint8Array), ["ファイル名2"]: ファイル名2のコンテンツ(Uint8Array), ... }の形式で得られます。そのため、Object.entriesでファイル名とファイルの中身の一覧を得ることができます。結果はコールバックで得られますが、なるべく直列に処理をしたいのでPromiseでwrapしています。

import { unzip, UnzipFileFilter } from "fflate";

const uncompress = async (file: File, howToFilterFiles: UnzipFileFilter | undefined = undefined): Promise<File[]> => {
  try {
    const buffer = await file.arrayBuffer();
    const filter = { filter: howToFilterFiles || (() => true) };
    return await new Promise((resolve, reject) => {
      unzip(new Uint8Array(buffer), filter, (err, unzipped) => {
        if (err) {
          reject(err);
        }
        const promises = Object.entries(unzipped).map(async ([filename, data]) => {
          return new File([data], filename);
        });
        resolve(Promise.all(promises));
      });
    });
  } catch (err) {
    return Promise.reject(new Error(`uncompress failed: ${err}`));
  }
};

また、unzip関数ではzipファイル中のどのファイルを解凍するかを指定できます。その場合、第2引数にフィルタ関数を定義して渡します。本フィルタは、

  • 特定のファイル以外を解凍したい
  • 10MiB以下のファイルのみ解凍したい

という使い方をするときに有効です。以下、上記コード例を踏まえたフィルタ例です。

const files = await uncompress(zipFile, (fileInfo) => {
  return fileInfo.name !== 'excludedFile.txt' && fileInfo.originalSize <= 10_000_000;
});

zipファイルを圧縮する

以下のコード例により、指定されたzipファイルを圧縮できます。結果はFile型で返します。

zip関数において、圧縮したいファイルは { ["ファイル名1"]: ファイル名1のコンテンツ(Uint8Array), ["ファイル名2"]: ファイル名2のコンテンツ(Uint8Array), ... }の形で渡す必要があります。そのため、全ファイルを所定の形に変換した上で、zipファイルに圧縮しています。

import { AsyncZipOptions, zip } from "fflate";

const compress = async (
  files: File[],
  filename: string,
  compressOptions: AsyncZipOptions | undefined = undefined
): Promise<File> => {
  try {
    const options = compressOptions || {};
    const fileContents: Record<string, Uint8Array> = {};
    const promises = files.map(async (f) => {
      const arrayBuffer = await f.arrayBuffer();
      fileContents[f.name] = new Uint8Array(arrayBuffer);
    });
    await Promise.all(promises);
    const zippedContent: Uint8Array = await new Promise((resolve, reject) => {
      zip(fileContents, options, (err, data) => {
        if (err) {
          reject(err);
        }
        resolve(data);
      });
    });
    return new File([zippedContent], filename);
  } catch (err) {
    return Promise.reject(new Error(`compress failed: ${err}`));
  }
};

また、zip関数では圧縮に関するオプションを指定できます[2]。設定できるパラメータは以下のとおりです。

  • level: 圧縮レベル。0~9のいずれか。デフォルトは6。
  • mem: 圧縮にどれくらいメモリを使うか。2^(2+mem) [kB]がメモリ上で使われる。0~12のいずれか。通常は4~8が推奨される。未指定の場合、ファイルサイズを元に自動決定される。

上記コード例を踏まえて圧縮レベル9のみ指定する場合は、以下の使い方になります。

const file = await compress(filesToCompress, "compressed.zip", { level: 9 });
脚注
  1. 2022/04/17 v3.9.1時点。 ↩︎

  2. 他にも設定できるパラメータはありますが、ここでは割愛します。 ↩︎