概要
現代の JavaScript における Promise と jQuery Promise では、非同期処理の振る舞いが異なることを示します。また、混同されがちな jQuery の then
と done/fail/always
の使い分けについて解説します。
この記事のすべてのコードは、以下のページで試すことができます。
https://at6ue.github.io/jquery_deferred_demo/
jQuery の Deferred と Promise
Deferred Object は jQuery において主に非同期処理を扱うための仕組みです。例えば、以下のように非同期処理の完了を待って then
に登録したコールバックを実行させることができます。
const fn = () => {
const deferred = $.Deferred();
setTimeout(() => {
console.log("resolve");
deferred.resolve("1st");
});
return deferred.promise();
};
const promise = fn();
const ret = promise
.then((first) => {
console.log(`${first} then`);
return "2nd";
})
.then((second) => {
console.log(`${second} then`);
});
console.log("start");
/**
* start
* resolve
* 1st then
* 2nd then
*/
非同期処理といっても JavaScript はシングルスレッドで実行されるため、コード断片が同時に実行されることはありません。あるタスクが完了したときに呼び出されるコールバックを登録できるだけです。
jQueryPromise は、初期状態の pending
(待機)と、決定された resolved
(解決)と rejected
(拒否)のいずれかの状態を持ち、状態がいずれかに決定されたときに予め登録されたコールバックを呼び出す仕組みです。
ところで上の例では、 非同期関数 fn
は jQueryPromise を返しています。jQueryDeferred 自身も jQueryPromise と同じように、 then
などを使って非同期処理の完了後に呼び出されるコールバックを登録できます。しかし、特別な理由がない限り非同期関数は jQueryPromise を返すべきです。
以下は、 jQueryDeferred を返す非同期関数 fn
の外側で状態が決定されてしまった例です。
const fn = () => {
const deferred = $.Deferred();
setTimeout(() => {
console.log("(won't be resolved)");
deferred.resolve("resolved");
});
return deferred;
};
const deferred = fn();
deferred
.done((resolved) => {
console.log(resolved);
})
.fail((rejected) => {
console.log(rejected);
});
deferred.reject("rejected");
/**
* rejected
* (won't be resolved)
*/
done
と fail
はそれぞれ状態が resolved
と rejected
に決定されると呼び出されるコールバックを登録する関数です。この例では、非同期関数 fn
の外側で状態が rejected
に決定されてしまったため、 fn
の内側で resolve
を呼び出しても done
に登録したコールバックは呼び出されません。jQueryPromise は最初に決定された状態を保ち、その後に呼び出された resolve/reject
は無視します。1
jQueryDeferred とは異なり、jQueryPromise は resolve
や reject
を持たないので、関数の返値を jQueryPromise にしておけば、関数の外側で状態が決定される心配はありません。
then と done/fail/always
さきほどの例では fail
を使って、状態が rejected
に決定されると呼び出されるコールバックを登録しましたが、 then
の第 2 引数も状態が rejected
に決定されると呼び出されるコールバックを受け付けます。
jQuery 1.8 以降の then
は以下のように定義されています。
deferred.then( doneFilter [, failFilter ] [, progressFilter ] )
then
と done
、fail
および always
は似たような目的で使われますが、その役割は大きく異なります。then
は新しい jQueryPromise を返すのに対して、done/fail/always
は自分自身を返します。
const deferred = $.Deferred();
const promise = deferred.promise();
const promiseDone = promise.done(() => {});
const promiseThen = promise.then(() => {});
console.log(`done returns ${promise === promiseDone ? "same" : "new"} promise`);
console.log(`then returns ${promise === promiseThen ? "same" : "new"} promise`);
/**
* done returns same promise
* then returns new promise
*/
つまり、then
が返す新しい jQueryPromise は、元のオブジェクトとは異なる状態に決定できます。これは、任意状態に決定した jQueryPromise を返すコールバックを then
に登録することで実現できます。
以下は、rejected
に決定された jQueryPromise を受けて、resolved
に決定された jQueryPromise を返す例です。
const firstArg = "1st arg";
const secondArg = "2nd arg";
const deferred = $.Deferred();
setTimeout(() => {
deferred.reject(firstArg); // A
});
deferred
.promise()
.fail((arg) => {
// B
console.log(arg);
return secondArg;
})
.then(
() => {},
(arg) => {
// C
console.log(arg);
return $.Deferred().resolve(secondArg).promise();
}
)
.done((arg) => {
// D
console.log(arg);
});
/**
* 1st arg
* 1st arg
* 2nd arg
*/
A
で jQueryDeferred が rejected
に決定されます。ここで rejected
状態のために登録された B
と C
のコールバックが登録された順に呼ばれます。
C
のコールバックが resolved
に決定された jQueryDeferred を返したため、これに続く resolved
状態のために登録された D
のコールバックが呼ばれます。
このように、then
に登録したコールバックの返値が、then
の返値である新しい jQueryPromise に反映されていることが分かります。
一方、B
で fail
に渡されたコールバックの返値は使われていません。done/fail/always
は自分自身を返すため、登録されたコールバックの返値を後続のメソッドチェインに渡せないのです。これは done/fail/always
に登録したコールバックが非同期処理を含む場合、後続するメソッドチェインではその完了を待機できないことを意味しています。
以下は、done
に登録したコールバックのなかで、非同期的にリソースを読み込んでいる例です。
const deferred = $.Deferred();
setTimeout(() => {
deferred.resolve();
});
deferred
.promise()
.done(() => {
console.log("1st done");
return $.get("resource").then((data) => console.log(`done: ${data}`));
})
.done(() => {
console.log("2nd done");
})
.then(() => {
console.log("then");
return $.get("resource").then((data) => console.log(`then: ${data}`));
})
.done(() => {
console.log("3rd done");
});
/**
* 1st done
* 2nd done
* then
* done: data retrieved
* then: data retrieved
* 3rd done
*/
最初の done
に渡されたコールバックによる非同期処理の完了を待たず、即座に 2 つ目の done
に渡されたコールバックが実行されています。
一方、3 つ目の done
に渡されたコールバックは、then
に渡されたコールバックの非同期処理の完了を待って実行されています。
このように、done/fail/always
は then
の省略形ではなく2、状態の決定に伴って即座に実行されるべき同期的な処理を登録するための関数といえます。
Promise との比較
jQuery 3.x では then
の仕様が Promises/A+ に基づくように変更されました。これは、ES6 で導入された Promise と互換性をもつためですが、jQueryPromise の then
と Promise の then
の挙動は同じではありません。
まず、jQuery 2.x までと jQuery 3.x の then
の実行順を比べてみましょう。
const deferred = $.Deferred();
setTimeout(() => {
deferred.resolve();
console.log("resolved");
});
deferred
.then(() => {
console.log("1st then");
setTimeout(() => console.log("1st timeout"));
})
.then(() => {
console.log("2nd then");
setTimeout(() => console.log("2nd timeout"));
});
/**
* jQuery <2.x
* ---
* 1st then
* 2nd then
* resolved
* 1st timeout
* 2nd timeout
*
* jQuery 3.x
* ---
* resolved
* 1st then
* 1st timeout
* 2nd then
* 2nd timeout
*/
jQuery 2.x までは resolve
や reject
が呼び出される、つまり状態が決定されると即座に then
や done/fail/always
に登録されたコールバックを実行していました。3 jQuery 3.x はその仕組みを引き継ぎつつ、then
に登録されたコールバックだけは setTimeout
に渡して実行します。
setTimeout
に登録されたコールバックは指定時間後にタスクキューに追加され、イベントループの最初に実行されます。そのため、上記の例を jQuery 3.x で実行すると、まず同期的なコード断片がすべて実行され、次に then
と setTimeout
に登録されたコールバックが順に実行されます。4
同様のコードを Promise で試してみましょう。
const promise = new Promise((resolve) => {
setTimeout(() => {
resolve();
console.log("resolved");
});
});
promise
.then(() => {
console.log("1st then");
setTimeout(() => console.log("1st timeout"));
})
.then(() => {
console.log("2nd then");
setTimeout(() => console.log("2nd timeout"));
});
/**
* resolved
* 1st then
* 2nd then
* 1st timeout
* 2nd timeout
*/
Promise では then
に登録されたコールバックが先に呼び出され、次に setTimeout
に登録されたコールバックが呼び出されています。Promise は then
に登録されたコールバックをマイクロタスクキューに追加します。マイクロタスクは現在実行中のタスクが完了した直後に実行されるため、setTimeout
に登録したコールバックよりも優先されます。
jQuery では then
や done/fail/always
は渡されたコールバックに Callbacks Object に追加していますが、この内部実装は配列です。そのため、これらのコールバックは resolve/reject
を呼び出したタスクの一部として即座に実行されます。
まとめ
- jQueryDeferred は
resolve/reject
関数をもち、状態を決定できる - jQueryPromise は初期状態の
pending
、および決定されたresolved
とrejected
の状態をもつが、自身では状態を決定できない -
done/fail/always
はresolved/rejected
のうち対応する状態に割り当てられたキューにコールバックを追加し、自分自身を返す関数 -
then
はresolved/rejected
のそれぞれの状態に割り当てられたキューにコールバックを追加し、そのコールバックの返値で決定される新しい jQueryPromise を返す関数- jQuery 3.x では、
then
に渡されたコールバックはsetTimeout
を介してタスクキューに追加されるため、コールスタックが空になった後に遅延実行される - ES6 の Promise における
then
は、渡されたコールバックをマイクロタスクキューに追加するため、jQueryPromise とは挙動が異なる
- jQuery 3.x では、
-
"Once the Deferred has been resolved or rejected it stays in that state; a second call to deferred.resolve(), for example, is ignored." -- jQuery.Deferred() ↩
-
これとは対照に、Promise.prototype.catch() は
then
を内部的に呼び出しており省略形といえます。 ↩ -
この実装は Promises/A+ の基となった Promises/A に相当するようです。 ↩