風柳メモ

ソフトウェア・プログラミング関連の覚書が中心。

ブラウザ拡張機能用に background で ZIP 化するためのライブラリを試作(Chrome拡張機能/Firefox Quantum WebExtensions 用)


前書き

WebExtensions について調べていると、Promise を使用して云々……という記述が出てきて、今さらながらに Promise というものの存在を知りました(ヲイ。
慣れれば使い勝手が良さそうなので、練習を兼ねて、ブラウザ拡張機能の background で ZIP 化することが出来るようなライブラリを試作してみました。
github.com
習作なので、いつも以上に動作保証できません。ご利用は計画的に(汗)。

概要

content_scripts に対し、ZipRequest クラスを提供します。
ZipRequest#open()/file()/generate()/close() という一連の関数にて、background に対してメッセージを送ることで ZIP 化に関する指示を出し、background からの応答メッセージで結果を受け取り、content_scrips に返します。
background での ZIP 化には、JSZip を使用しています。

比較する意味もあって、content_scripts 用には Promise を使ったもの(Promise版・zip_request.js) と、使わないもの(コールバック版・zip_request_legacy.js)とがあります。
background 用のもの(zip_worker.js)は共通です。

サンプル

はてなブログ("*://*.hatenablog.com/*")を開くと、画像を適当な数選んで ZIP 化・ダウンロードする、という迷惑な(汗)サンプルコード(抜粋)です。
サンプルソースコード全文は、こちらをご覧ください

並列処理

複数のファイルを同時並行で取得しながらアーカイブする処理です。
コールバック版(zip_request_legacy.js)の場合

'use strict';

( function () {

var zip_request = new ZipRequest(),
// (中略)

zip_request.open();

files.forEach( function ( file ) {
    var url = file.src || file.href,
        filename = get_filename( url );
    
    console.log( '[start]', url, filename );
    
    zip_request.file( {
        url : url,
        filename : filename,
        zip_options : {
            date : new Date( '2017-01-01' )
        }
    }, function ( result ) {
        console.log( '[result]', url, filename, result );
    } );
} );

zip_request.generate( 'blob', function ( response ) {
    zip_request.close();
    
    // 以下、Aタグのdownload 属性を使ったダウンロード処理
} );

} )();

Promise版(zip_request.js)の場合

'use strict';

( async function () {

let zip_request = new ZipRequest(),
// (中略)

await zip_request.open();

await Promise.all(
    files.map( async ( file ) => {
        let result,
            url = file.src || file.href,
            filename = get_filename( url );
        
        console.log( '[start]', url, filename );
        
        result = await zip_request.file( {
            url : url,
            filename : filename,
            zip_options : {
                date : new Date( '2017-01-01' )
            }
        } )
        .catch( result => { return result } ); // Promise.all() を停止させないための対策
        
        console.log( '[result]', url, filename, result );
        
        return result;
    } )
);

let response,
    download_link;

response = await zip_request.generate( 'blob' );

await zip_request.close();

// 以下、Aタグのdownload 属性を使ったダウンロード処理

} )();

コールバック版のライブラリ内で多少工夫をしていることもあり、並列処理に関しては、一見したところそれ程違いは無いかもしれません。
コールバック版では、例えば file() に対応する応答が background からまだ来ない状態で generate() が呼ばれたとしても、全てのファイルについて結果が返るのを待って background に要求を出すようにしているため、上記の書き方が可能。ただし、close() については、generate() のコールバック後に呼び出す必要あり。

直列処理

ファイルを一つずつ順番に取得しながら(逐次)アーカイブする処理です。
コールバック版(zip_request_legacy.js)の場合

'use strict';

( function () {

var zip_request = new ZipRequest(),
// (中略)

zip_request.open( function ( result ) {
    var file_index = 0;
    
    function zip_files() {
        if ( files.length <= file_index ) {
            zip_request.generate( 'blob', function ( response ) {
                zip_request.close();
                
                // 以下、Aタグのdownload 属性を使ったダウンロード処理                                
            } );
            return;
        }
        
        var file = files[ file_index ++ ],
            url = file.src || file.href,
            filename = get_filename( url );
        
        console.log( '[start]', url, filename );
        
        zip_request.file( {
            url : url,
            filename : filename,
            zip_options : {
                date : new Date( '2017-01-01' )
            }
        }, function ( result ) {
            console.log( '[result]', url, filename, result );
            
            zip_files();
        } );
    }
    
    zip_files();
} );

} )();

Promise版(zip_request.js)の場合

'use strict';

( async function () {

let zip_request = new ZipRequest(),
// (中略)

await zip_request.open();

for ( let file of files ) {
    // files.map( async ( file ) => { ... } ) は使えないことに注意
    // ※ map() では、コールバック関数の戻り値が Promise object になり、直列処理されない
    let url = file.src || file.href,
        filename = get_filename( url ),
        result;
    
    console.log( '[start]', url, filename );
    
    result = await zip_request.file( {
        url : url,
        filename : filename,
        zip_options : {
            date : new Date( '2017-01-01' )
        }
    } )
    .catch( result => { return result } ); // エラーで停止させないための対策
    
    console.log( '[result]', url, filename, result );
}

let response,
    download_link;

response = await zip_request.generate( 'blob' );

await zip_request.close();

// 以下、Aタグのdownload 属性を使ったダウンロード処理

} )();

こちらは、Promise 版のメリットが出ていると思います。
コールバック版は処理の流れが一見解りにくいのに対し、Promise 版では上から下への自然な流れで解りやすくなっています。

はまった点など

  • Promise.all() は、並列実行中の Promise オブジェクトが一つでもエラーになると異常終了してしまう(catchされてしまう)ため、中断したくない場合、それぞれの Promise で reject() ではなく resolve() を呼び、戻り値によって判別するようにする
  • Array#map() 等のコールバック処理を持つものは、直列処理では使用できない(コールバックの結果が Promise オブジェクトで返されるため)
  • Promise の resolve() や reject() は、呼んだ後も続きが実行される(実行されないようにするには、直後に return が必要)
  • background における、browser/chrome.runtime.onMessage.addListener() のコールバック関数内で、非同期の処理を呼んでから sendResponse()を返す場合、コールバック関数の戻り値に true を設定する必要がある(chrome.runtime - Google Chrome
  • content_scripts と background 間のやり取り(sendMessage()/sendResponse())では、JSON で基本的にはシリアライズ可能なオブジェクトしか渡せない……ところが、渡せるオブジェクトの種類に、ブラウザ間で差異がある(関数オブジェクトは Firefox で NG、Blob が渡せるのは Firefox のみ、等)
  • background で URL.createObjectURL() により得られた Blob URL を content_scripts に送ると、Chrome では download 属性付き A タグでダウンロード可能なのに対し、Firefox や MS-Edge では不可(Firefoxについては、なぜか Blob がそのまま content_scripts に送れるため、そちらで Blob URL に変換することで対応している)
  • MS-Edge では、作成した ZIP をダウンロードさせる術が見つからない

Promise/async/awaitやclassの書き方でもっとはまると思っていたが、これらはそれ程でもなかった代わりに、拡張機能の仕様やブラウザ間の細かい差異の方が難解。