いまさら理解する jQuery Deferred

# 概要

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

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

# jQuery の Deferred と Promise

[Deferred Object](https://api.jquery.com/category/deferred-object/) は jQuery において主に非同期処理を扱うための仕組みです。例えば、以下のように非同期処理の完了を待って `then` に登録したコールバックを実行させることができます。

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

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

```js: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)
 */
```

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

[^ignoremultiplecall]: "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()](https://api.jquery.com/jQuery.Deferred/)

# then と done/fail/always

さきほどの例では `fail` を使って、状態が `rejected` に決定されると呼び出されるコールバックを登録しましたが、 `then` の第 2 引数も状態が `rejected` に決定されると呼び出されるコールバックを受け付けます。
jQuery 1.8 以降の `then` は[以下のように定義されています](https://api.jquery.com/deferred.then/#deferred-then-doneFilter-failFilter-progressFilter)。

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

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

```js: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 を返す例です。

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

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

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

[^vspromisecatch]: これとは対照に、[Promise.prototype.catch()](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Promise/catch) は `then` を内部的に呼び出しており省略形といえます。

# Promise との比較

jQuery 3.x では `then` の仕様が [Promises/A+](https://promisesaplus.com/) に[基づくように変更されました](https://blog.jquery.com/2016/06/09/jquery-3-0-final-released/)。これは、ES6 で導入された [Promise](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Promise) と互換性をもつためですが、jQueryPromise の `then` と Promise の `then` の挙動は**同じではありません**。

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

```js: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 までは `resolve` や `reject` が呼び出される、つまり状態が決定されると**即座に** `then` や `done/fail/always` に登録されたコールバックを実行していました。[^promisesa] jQuery 3.x はその仕組みを引き継ぎつつ、`then` に登録されたコールバックだけは `setTimeout` に渡して[実行します](https://github.com/jquery/jquery/blob/3.6.0/src/deferred.js#L230)。
`setTimeout` に登録されたコールバックは指定時間後にタスクキューに追加され、[イベントループの最初に実行されます](https://zenn.dev/qnighy/articles/345aa9cae02d9d#web%E3%83%96%E3%83%A9%E3%82%A6%E3%82%B6%E3%81%AE%E3%82%BF%E3%82%B9%E3%82%AF%E3%82%AD%E3%83%A5%E3%83%BC)。そのため、上記の例を jQuery 3.x で実行すると、まず同期的なコード断片がすべて実行され、次に `then` と `setTimeout` に登録されたコールバックが順に実行されます。[^pipe]

[^promisesa]: この実装は Promises/A+ の基となった [Promises/A](http://wiki.commonjs.org/wiki/Promises/A) に相当するようです。
[^pipe]: jQuery 2.x までの `then` と同じ実装は jQuery 3.x では `pipe` に[残されています](https://api.jquery.com/deferred.pipe/)。

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

```js: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` に登録されたコールバックを[マイクロタスク](https://developer.mozilla.org/ja/docs/Web/API/HTML_DOM_API/Microtask_guide)キューに追加します。マイクロタスクは現在実行中のタスクが完了した直後に実行されるため、`setTimeout` に登録したコールバックよりも優先されます。

jQuery では `then` や `done/fail/always` は渡されたコールバックに [Callbacks Object](https://api.jquery.com/category/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 とは挙動が異なる