jQuery の Deferred と Promise/A+

jQuery の Deferred といわゆる Promise/A+ について、ごっちゃになってたのでメモ。

then/catch で発生した例外は reject された Promise になる

jQuery 3 から? Promise/A+ 互換になったため then/catch で発生した例外は reject された Promise として次のチェインに渡る。

$.Deferred().resolve(1).promise()
    .then((v) => {
        console.log('then', v); // then 1
        throw 2;
    })
    .catch((v) => {
        console.log('catch', v); // catch 2
    })

なので catch で処理しない例外は静かに無視される。

$.Deferred().resolve(1).promise()
    .then((v) => {
        console.log('then', v); // then 1
        throw 2;
    })
    .then((v) => {
        console.log('then', v); // never
    })

Node.js の Promise ならそういう状況では以下のような警告が表示される。

(node:87126) UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 1): 2
(node:87126) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.

そうではない処理系のために Promise のライブラリによっては Promise#done というメソッドが設けられていることがあるらしい。

要するにチェインの最後で .done() しておけばキャッチされない例外は Promise の外まで飛んで行くようになる。

一方で jQuery の done/fail/always で例外が発生した場合はそのまま Promise の外まで飛んで行く。done の動きは↑の説明とぜんぜん違うので注意(done は Promise/A+ の仕様ではないので jQuery が特別おかしいわけではない)。

$.Deferred().resolve(1).promise()
    .done((v) => {
        console.log('done', v); // done 1
        throw 2;                // uncaught exception: 2
    })
    .fail((v) => {
        console.log('fail', v); // never
    })
    .always((v) => {
        console.log('always', v); // never
    })

jQuery で then しつつ catch されない例外を Promise の外に持っていきたければチェインの最後で次のようにすると良いだろう。

$.Deferred().resolve(1).promise()
    .then((v) => {
        console.log('then', v); // then 1
        throw 2;
    })
    .then((v) => {
        console.log('then', v); // never
    })
    .fail((err) => {
        throw err;              // uncaught exception: 2
    })

jQuery の done/fail/always の戻り値は Promise の状態を変えない

thencatch はコールバック関数が返した値で解決された新たな Promise を返す(コールバックが Promise を返したならその Promise の状態と値を持つ新たな Promise が返る)。

$.Deferred().reject(1).promise()
    .catch((v) => {
        console.log('catch', v); // catch 1
        return 2;
    })
    .then((v) => {
        console.log('then', v); // then 2
        return 3;
    })
    .then((v) => {
        console.log('then', v); // then 3
        return $.Deferred().reject(4).promise();
    })
    .catch((v) => {
        console.log('catch', v); // catch 4
    })

一方で done/fail/always の戻り値は元の Promise のまま。

var promise = $.Deferred().resolve(1).promise();
console.log(promise === promise.done(() => {})); // true
console.log(promise === promise.then(() => {})); // false

そのためコールバックが何を返してもチェインされる Promise の状態や値は変わらない。

$.Deferred().reject(1).promise()
    .always((v) => {
        console.log('always', v); // always 1
        return 0;
    })
    .fail((v) => {
        console.log('fail', v); // fail 1
        return 2;
    })
    .done((v) => {
        console.log('done', v); // never
        return 3;
    })
    .done((v) => {
        console.log('done', v); // never
        return $.Deferred().reject(4).promise();
    })
    .fail((v) => {
        console.log('fail', v); // fail 1
    })

jQuery の Promise は複数の値を持てる

本来 Promise/A+ では1つの値しか持てない。

Promise.resolve(1, 2, 3)
    .then((a, b, c) => {
        console.log('then', a, b, c); // then 1 undefined undefined
    })

jQuery の Promise は複数の値を持てる。

$.Deferred().resolve(1, 2, 3).promise()
    .then((a, b, c)=>{
        console.log('then', a, b, c); // then 1 2 3
    })

thencatch をチェインするときに次の Promise に複数の値を持たせたいときは複数の値を持つ jQuery の Promise を返せば良い。

$.Deferred().resolve(1, 2).promise()
    .then((a, b)=>{
        console.log(a, b); // 1 2
    })
    .then((a, b)=>{
        console.log(a, b); // undefined undefined
        return $.Deferred().resolve(3, 4)
    })
    .then((a, b)=>{
        console.log(a, b); // [3, 4]
    })

さいごに

昨今はバベれば async/await が非常に書きやすいので、jQuery 独特の仕様に依存しないよう $.ajax をラップした関数で Promise が1つの値だけを持つようにして、async/await 前提で書くのが良いと思う。

function ajaxGet(url, params) {

    return $.ajax(/* ... */)
        .then(
            (data, textStatus, jqXHR) => {

                // data を見て(必要なら jqXHR とかも)リクエストの成否を判断する

                if (err) {
                    // リクエストが失敗しているなら失敗の理由を示すなにかを例外として投げる
                    throw err;

                    // こっちのほうが良いかも
                    return Promise.reject(err);
                }

                // リクエストが成功したならその結果を返す
                return data;
            },
            (jqXHR, textStatus, errorThrown) => {
                
                // リクエストの失敗を示すなにかを例外として投げる
                throw err;
            }
        )
}

async function handler() {

    try {
        const data = await ajaxGet('/path/to/api', {});

        // .done() の処理

    } catch (err) {

        // .fail() の処理

        // 握りつぶさないように再送
        throw err;

    } finally {

        // .always() の処理 
    }
}

バベれないときは thencatch で発生した例外が闇の彼方に葬られないように気をつける必要がある。 ↑の方で書いたように thencatch の後で fail で例外を throw するか、catchsetTimeout(function () { throw err }, 0) とかだろうか。