JavaScript で複数ファイルを無圧縮 zip にまとめてダウンロードする

JavaScript で複数ファイルを zip にまとめてダウンロードする方法になります。

サーバーから取得したファイルでも、JavaScript で動的に生成したコンテンツでも、どちらも同様に保存できます。

既に類似記事はあるかと思いますが、自分にとって使いやすいコードを載せようと思います。

# 1. はじめに

画像ファイルやなんらかのバイナリファイルなど、既に圧縮されているファイルをさらに zip に圧縮しようとすると、圧縮・解凍にとても時間がかかったり、ファイルサイズもほぼ減らないことがあるため、どんなファイルでも扱いやすいようにするためここでは「無圧縮」にしています。

テキストファイルしか扱わないような場合には圧縮した方が良くなります。

# 2. ブラウザ上の場合

クライアントサイドの JavaScript での例です。

ここでは JSZip という外部ライブラリを使用して zip にします。

参考「[JSZip](https://stuk.github.io/jszip/)」

## 2.1. コード

サーバーから取得したファイルを zip にまとめます。

ここではファイルの元のディレクトリは考えず、全て同じフォルダに入れて zip にします。

※モダンな JavaScript の書き方をしているため、IE などではトランスコンパイルや Polyfill が必要です。

```js
(async () => {

	// 動的 import が使用できない前提
	const importInNoModule = src => new Promise(resolve => {
		const s = document.createElement('script');
		s.onload = () => { resolve(); };
		s.src = src;
		document.head.append(s);
	});

	await importInNoModule('https://cdnjs.cloudflare.com/ajax/libs/jszip/3.5.0/jszip.min.js');

	// 
	const getNameContentPairsFrom = async urls => {

		const promises = urls.map(async url => {

			// メモ: クロスオリジンで通信したい場合、サーバー側で許可の設定がされていないなければ、
			//       クライアントサイドの JavaScript ではどう記述してもアクセス不可
			const response = await fetch(url);

			const content = await response.blob(); // new Uint8Array(await response.arrayBuffer()) も可
			const name = getLocalFileName(url);

			return { name, content };

		});

		// ここではサーバーの負荷を考えて、直列実行
		// 並列実行したい場合は const blobs = await Promise.all(promises);
		const pairs = [];

		// await を使いたいため、forEach を使わない (他に直列実行の実装法あり)
		for (const promise of promises) {
			pairs.push(await promise);
		}

		return pairs;

	};

	const generateZipBlob = (nameContentPairs, name) => {

		const zip = new JSZip();

		const folder = zip.folder(restrictFileName(name));

		nameContentPairs.forEach(nameContentPair => {

			const name = restrictFileName(nameContentPair.name);
			const content = nameContentPair.content;

			folder.file(name, content);

		});

		return zip.generateAsync({ type: 'blob' }); // デフォルトで無圧縮

	};

	const saveBlob = (blob, name) => {

		const a = document.createElement('a');
		a.href = URL.createObjectURL(blob);
		a.download = restrictFileName(name) + '.zip';

		a.style.display = 'none';
		document.body.appendChild(a);
		a.click();
		document.body.removeChild(a);

	};

	const getLocalFileName = url => {

		const matchedFileName = url.match(/^(?:[^:\/?#]+:)?(?:\/\/[^\/?#]*)?(?:[^?#]*\/([^\/?#]*))?(\?[^#]*)?(?:#.*)?$/) ?? [];
		const [, fileName, query] = matchedFileName.map(match => match ?? '');

		const matchedExt = fileName.match(/^(.+?)(\.[^.]+)?$/) ?? [];
		const [, name, ext] = matchedExt .map(match => match ?? '');

		return name + (query !== '' ? ext + query + ext : ext);

	};

	/**
	 * Windows のファイル名に使用できない文字をエスケープ
	 * Mac や Linux より Windows の方がファイル名の制限が厳しいため、Windows に合わせる
	 */
	const restrictFileName = name => name.replace(/[\\\/:*?"<>|]/g, c => '%' + c.charCodeAt(0).toString(16));

	// 
	const name = 'test';

	const urls = [/* ... URL 文字列配列 */];

	const zipBlob = await generateZipBlob(await getNameContentPairsFrom(urls), name);

	saveBlob(zipBlob, name);

})();
```

- `getNameContentPairsFrom()`
    - URL の一覧からファイルを取得し、ファイル名とファイル内容 (Blob 形式) を配列で返します
    - (Uint8Array も可)
- `generateZipBlob()`
    - JSZip を用いて、複数ファイルの Blob を zip の Blob にします
- `saveBlob()`
    - Blob からファイルに保存します

## 2.2. 注意点

JavaScript が実行されるページのドメインと、取得しようとしているファイルの URL のドメインが異なると、ブラウザの Cross-Origin Resource Sharing のセキリティの制限ではじかれるため、サーバー側の設定で許可していない限り Blob を取得することはできません。

もし、取得しようとしているファイルが画像ファイルの場合、WEB ページ上で `<img>` を使った表示はできますが、そこから `<canvas>` を介して Blob を取得しようとしても、セキリティではじかれます。

参考「[画像とキャンバスをオリジン間で利用できるようにする - HTML: HyperText Markup Language | MDN](https://developer.mozilla.org/ja/docs/Web/HTML/CORS_enabled_image)」

# 3. Deno の場合

サーバーサイドやローカルで実行可能な Deno の JavaScript での例です。

Deno 向けのラッパーがあるのでそれを利用します。

参考「[GitHub - hayd/deno-zip: A JSZip wrapper for handling zipfiles in deno](https://github.com/hayd/deno-zip)」

## 3.1. コード

```js:Deno&nbsp;版
import { JSZip } from 'https://deno.land/x/jszip/mod.ts';

// 
const getNameContentPairsFrom = async urls => {

	const promises = urls.map(async url => {

		const response = await fetch(url);

		const content = new Uint8Array(await response.arrayBuffer());
		const name = getLocalFileName(url);

		return { name, content };

	});

	// ここではサーバーの負荷を考えて、直列実行
	// 並列実行したい場合は const blobs = await Promise.all(promises);
	const pairs = [];

	// await を使いたいため、forEach を使わない (他に直列実行の実装法あり)
	for (const promise of promises) {
		pairs.push(await promise);
	}

	return pairs;

};

const generateZipUint8Array = (nameContentPairs, name) => {

	const zip = new JSZip();

	const folder = zip.folder(restrictFileName(name));

	nameContentPairs.forEach(nameContentPair => {

		const name = restrictFileName(nameContentPair.name);
		const content = nameContentPair.content;

		// メモ: Deno 版 JSZip では、file() でなく addFile()
		// メモ: Deno 版 JSZip では、Blob を使用できない (ver.0.6.0 現在)
		folder.addFile(name, content);

	});

	return zip.generateAsync({ type: 'uint8Array' }); // デフォルトで無圧縮

};

const saveUint8Array = (uint8Array, name) => Deno.writeFile(restrictFileName(name) + '.zip', uint8Array);

const getLocalFileName = url => {

	const matchedFileName = url.match(/^(?:[^:\/?#]+:)?(?:\/\/[^\/?#]*)?(?:[^?#]*\/([^\/?#]*))?(\?[^#]*)?(?:#.*)?$/) ?? [];
	const [, fileName, query] = matchedFileName.map(match => match ?? '');

	const matchedExt = fileName.match(/^(.+?)(\.[^.]+)?$/) ?? [];
	const [, name, ext] = matchedExt .map(match => match ?? '');

	return name + (query !== '' ? ext + query + ext : ext);

};

/**
 * Windows のファイル名に使用できない文字をエスケープ
 * Mac や Linux より Windows の方がファイル名の制限が厳しいため、Windows に合わせる
 */
const restrictFileName = name => name.replace(/[\\\/:*?"<>|]/g, c => '%' + c.charCodeAt(0).toString(16));

// 
const name = 'test';

const urls = [/* ... URL 文字列配列 */];

const zipUint8array = await generateZipUint8Array(await getNameContentPairsFrom(urls), name);

await saveUint8Array(zipUint8array, name);
```

- `getNameContentPairsFrom()`
    - URL の一覧からファイルを取得し、ファイル名とファイル内容 (Uint8Array 形式) を配列で返します
- `generateZipUint8array()`
    - JSZip を用いて、複数ファイルの Uint8Array を zip の Uint8Array にします
- `saveUint8Array()`
    - Uint8Array からファイルに保存します

## 3.2. 注意点

Deno 版の JSZip では (ver.0.6.0 現在) Blob を使うことができないため、Uint8Array を使用します。

# 4. 保存ファイル名の扱い

`getLocalFileName()` で URL からディレクトリを除いたファイル名とクエリ文字列を取り出し、クエリ文字列がある場合には拡張子を補います。

`restrictFileName()` で安全なファイル名にエスケープします。

参考「[[JS] もう少し厳密に URL からファイル名等を取得する正規表現 - Qiita](https://qiita.com/kerupani129/items/adc0fba4ab248330e443)」


# 5. その他

実用的には、ダウンロード元の URL からディレクトリ名を取得して作成するなど、パスの管理をしっかりする方が良いと思います。

本記事のサンプルコードでは 1 つのディレクトリに複数ファイルをまとめているため、異なるディレクトリの同一ファイル名が扱えない問題があります。

JavaScript で動的に生成したコンテンツを保存したい場合は、`getNameBlobPairsFrom()` の代わりに自前で Blob を生成してください。