Web Worker を使って web ページ内の画像を zip してダウンロードする

クロスドメイン制約がない状況で、Web ページ内に表示されている画像を一括で zip してダウンロードしたいみたいな欲求ありませんか。私にはありました。

ありがたいことに jsZip というライブラリがあり、これを使えば JavaScript で zip ファイルを作成することができます。手順としては以下のような感じでしょうか

  1. ダウンロードしたい画像の url 一覧を作る
  2. 画像をすべてダウンロード
  3. 完了したら画像データを jsZip で zip する
  4. zip したものを blob にして、ダウンロード用のリンクを作る

ご存知のようにブラウザの JavaScript はシングルスレッドで動作しており、JavaScript で時間のかかる処理を行うとユーザーの操作がブロックされます。jsZip による zip 処理もファイル数が少ないうちはいいのですが、ファイル数が増えてくると結構時間がかかってしまうようです。この際なので画像をダウンロードする処理もあわせて WebWorker にやってもらうことにしましょう。以下では 2, 3 の手順について調べた/書いたことを紹介します。

Web Workers

ウェブワーカーの基本 にウェブワーカーの基本が書いてあります。大まかに言うと、ある JavaScript ファイルを指定して new Worker("worker.js") してあげるとそのスクリプトが別スレッドで実行される、メインスレッドとのやり取りは postMessage メソッドと message イベントを使う、別スレッドの処理が終わったら worker.terminate() を呼んでワーカーを終了させるといった感じです。

画像をダウンロードする

ここで注意したいのは、ワーカースレッドからは window オブジェクトやDOMなどにアクセスすることが出来ないということです。

ワーカーで次の機能にはアクセスできません:

  • DOM(非スレッドセーフ)
  • window オブジェクト
  • document オブジェクト
  • parent オブジェクト

コンソールでちょこちょこと確認しただけですが、どうやら Image クラスもないようです。以下のお手軽画像ダウンロードが禁じられてしまいました。

var img = new Image();
img.src = url;
img.onload = ...

途方に暮れていたのですが XMLHttpRequest は使えるということで、xhr で画像も取得できるだろうと調べてみました。Google 先生にしつこく聞いたところ私にぴったりの記事を教えてくれました。

xhr.responseType に希望の型を指定すればよいようです。

var xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.responseType = "arraybuffer";

xhr.onload = function (event) {
    var arrayBuffer = xhr.response;
        ...
}

これで画像を arrayBuffer や blob で取得することができました。めでたいですね。

ダウンロード完了をまって zip する

ウェブワーカー上で動かす XMLHttpRequest もやはり非同期処理を行うことができます。ワーカー自体が非同期なので、さらにその中で非同期にするのか、という気持ちもありますが画像をたくさんダウンロードするにはやはり非同期にしたほうがよいでしょう。問題はすべての画像がダウンロードされるのを待って処理を行わなければならないということです。

普段から jQuery に甘えっぱなしなのでここも $.when(deferreds) といきたいところです。ワーカー内で他のスクリプトを読み込むには importScripts("script1.js", "script2.js") などのようにします。script1.js の中でグローバルにセットされた値をワーカー内で使うことが出来るようになります。いざ jQuery を読み込むこととしましょう

importScripts("jquery.js")
// -> Uncaught ReferenceError: window is not defined

なんと jQuery は読み込み時点で window オブジェクトなどにアクセスするので、worker からは使えないようです。残念ですが今回は deferred できればよいので、そもそも jQuery は大げさすぎるかもしれません。Promise を使ってみます。今度は global is not defined と言われてしまいましたがこれはグローバルにPromiseをセットしようとしているだけだったので以下のコードで回避することができました。

var global = self;
importScripts("/public/js/promise-3.2.0.js");

メインスレッドと通信する

メインスレッドからは生成したワーカーオブジェクトの postMessage メソッドを使います。ワーカースレッドには、グローバルに postMessage があるのでそれを使います。

だいたいこんな感じになりました。

  • main.js
var worker = new Worker("path/to/worker.js");
worker.postMessage({urls:urls});
worker.addEventListener('message', function(event) {
    var command = event.data.command;
    if (command === 'download') {
        var filename = event.data.filename;
        console.log((++i) + ':' + filename);
    }
    if (command === 'complete') {
        var blob = event.data.blob;
        var $a = $('a.download');
        $a.attr('href', window.URL.createObjectURL(blob));
        $a.attr('download', "hoge.zip");

        worker.terminate();
    }
});
  • worker.js
var global = self;
importScripts("/path/to/jszip.js");
importScripts("/path/to/promise-3.2.0.js");

var zip = new JSZip();

self.addEventListener('message', function (event) {
    var urls = event.data.urls;
    var promises = urls.map(function (url) {
        return new Promise(function (resolve, reject) {
            var xhr = new XMLHttpRequest();
            xhr.open("GET", url);
            xhr.responseType = "arraybuffer";

            xhr.onload = function (event) {
                var arrayBuffer = xhr.response;
                var filename = url.replace(/^(.*)\//, '');
                zip.file(filename, arrayBuffer, { binary: true });
                postMessage({
                    command: 'download',
                    filename: filename
                });
                resolve(true);
            };
            xhr.onerror = function (event) {
                resolve(false);
            };
            xhr.send();
        });
    });
    Promise.all(promises).then(function () {
        postMessage({
            command: 'complete',
            blob: zip.generate({ type: "blob" })
        });
    });
});

メインスレッドから落としてほしい url の配列を渡します。ワーカーは self.addEventListener('message', ...) でメッセージを受け取って処理を行います。メインスレッドに進捗を伝えるために画像のダウンロードが終わるたびに通知するようにしてみました。メインスレッドからはすべて message イベントとして通知されるので、進捗報告なのか完了報告なのかわかるように command プロパティを含ませてみました。 もともとバックグラウンドで動く拡張とか、クロスドメインできない普通のwebサイトではほとんど役に立たない話でした。Chromeアプリなんかだと使えるんじゃないかと思います。

どうしてもメモリを食うようです。あんまり大量に zip しようとするとページが落ちるかもしれません。