LoginSignup
2
1

More than 1 year has passed since last update.

いまさら理解する jQuery Deferred

Last updated at Posted at 2022-01-10

概要

現代の JavaScript における Promise と jQuery Promise では、非同期処理の振る舞いが異なることを示します。また、混同されがちな jQuery の thendone/fail/always の使い分けについて解説します。

この記事のすべてのコードは、以下のページで試すことができます。
https://at6ue.github.io/jquery_deferred_demo/

jQuery の Deferred と Promise

Deferred Object は jQuery において主に非同期処理を扱うための仕組みです。例えば、以下のように非同期処理の完了を待って then に登録したコールバックを実行させることができます。

01.js
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 (拒否)のいずれかの状態を持ち、状態がいずれかに決定されたときに予め登録されたコールバックを呼び出す仕組みです。

ところで上の例では、 非同期関数 fnjQueryPromise を返しています。jQueryDeferred 自身も jQueryPromise と同じように、 then などを使って非同期処理の完了後に呼び出されるコールバックを登録できます。しかし、特別な理由がない限り非同期関数は jQueryPromise を返すべきです。
以下は、 jQueryDeferred を返す非同期関数 fn外側で状態が決定されてしまった例です。

02.js
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)
 */

donefail はそれぞれ状態が resolvedrejected に決定されると呼び出されるコールバックを登録する関数です。この例では、非同期関数 fn の外側で状態が rejected に決定されてしまったため、 fn の内側で resolve を呼び出しても done に登録したコールバックは呼び出されません。jQueryPromise は最初に決定された状態を保ち、その後に呼び出された resolve/reject は無視します。1
jQueryDeferred とは異なり、jQueryPromise は resolvereject を持たないので、関数の返値を jQueryPromise にしておけば、関数の外側で状態が決定される心配はありません。

then と done/fail/always

さきほどの例では fail を使って、状態が rejected に決定されると呼び出されるコールバックを登録しましたが、 then の第 2 引数も状態が rejected に決定されると呼び出されるコールバックを受け付けます。
jQuery 1.8 以降の then以下のように定義されています

deferred.then( doneFilter [, failFilter ] [, progressFilter ] )

thendonefail および always は似たような目的で使われますが、その役割は大きく異なりますthen新しい jQueryPromise を返すのに対して、done/fail/always自分自身を返します。

03.js
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 を返す例です。

04.js
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 状態のために登録された BC のコールバックが登録された順に呼ばれます。
C のコールバックが resolved に決定された jQueryDeferred を返したため、これに続く resolved 状態のために登録された D のコールバックが呼ばれます。
このように、then に登録したコールバックの返値が、then の返値である新しい jQueryPromise に反映されていることが分かります。

一方、Bfail に渡されたコールバックの返値は使われていませんdone/fail/always自分自身を返すため、登録されたコールバックの返値を後続のメソッドチェインに渡せないのです。これは done/fail/always に登録したコールバックが非同期処理を含む場合、後続するメソッドチェインではその完了を待機できないことを意味しています。
以下は、done に登録したコールバックのなかで、非同期的にリソースを読み込んでいる例です。

05.js
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/alwaysthen省略形ではなく2、状態の決定に伴って即座に実行されるべき同期的な処理を登録するための関数といえます。

Promise との比較

jQuery 3.x では then の仕様が Promises/A+基づくように変更されました。これは、ES6 で導入された Promise と互換性をもつためですが、jQueryPromise の then と Promise の then の挙動は同じではありません

まず、jQuery 2.x までと jQuery 3.x の then の実行順を比べてみましょう。

06.js
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 までは resolvereject が呼び出される、つまり状態が決定されると即座に thendone/fail/always に登録されたコールバックを実行していました。3 jQuery 3.x はその仕組みを引き継ぎつつ、then に登録されたコールバックだけは setTimeout に渡して実行します
setTimeout に登録されたコールバックは指定時間後にタスクキューに追加され、イベントループの最初に実行されます。そのため、上記の例を jQuery 3.x で実行すると、まず同期的なコード断片がすべて実行され、次に thensetTimeout に登録されたコールバックが順に実行されます。4

同様のコードを Promise で試してみましょう。

07.js
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 では thendone/fail/always は渡されたコールバックに Callbacks Object に追加していますが、この内部実装は配列です。そのため、これらのコールバックは resolve/reject を呼び出したタスクの一部として即座に実行されます。

まとめ

  • jQueryDeferred は resolve/reject 関数をもち、状態を決定できる
  • jQueryPromise は初期状態の pending 、および決定された resolvedrejected の状態をもつが、自身では状態を決定できない
  • done/fail/alwaysresolved/rejected のうち対応する状態に割り当てられたキューにコールバックを追加し、自分自身を返す関数
  • thenresolved/rejected のそれぞれの状態に割り当てられたキューにコールバックを追加し、そのコールバックの返値で決定される新しい jQueryPromise を返す関数
    • jQuery 3.x では、then に渡されたコールバックは setTimeout を介してタスクキューに追加されるため、コールスタックが空になった後に遅延実行される
    • ES6 の Promise における then は、渡されたコールバックをマイクロタスクキューに追加するため、jQueryPromise とは挙動が異なる

  1. "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() 

  2. これとは対照に、Promise.prototype.catch()then を内部的に呼び出しており省略形といえます。 

  3. この実装は Promises/A+ の基となった Promises/A に相当するようです。 

  4. jQuery 2.x までの then と同じ実装は jQuery 3.x では pipe残されています。 

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1