2023年4月6日

プロミスのチェーン

はじめに: コールバックの章で述べた問題に戻りましょう。一連の非同期タスクを順番に実行する必要があります。たとえば、スクリプトの読み込みなどです。どのようにコーディングすればよいでしょうか?

Promiseには、それを行うためのいくつかの方法があります。

この章では、プロミスのチェーンについて説明します。

次のようになります。

new Promise(function(resolve, reject) {

  setTimeout(() => resolve(1), 1000); // (*)

}).then(function(result) { // (**)

  alert(result); // 1
  return result * 2;

}).then(function(result) { // (***)

  alert(result); // 2
  return result * 2;

}).then(function(result) {

  alert(result); // 4
  return result * 2;

});

基本的な考え方は、結果が.thenハンドラのチェーンを通過することです。

ここでの流れは次のとおりです。

  1. 最初のPromiseは1秒後に解決し(*)
  2. 次に、.thenハンドラが呼び出され(**)、新しいPromise(値2で解決される)が作成されます。
  3. 次のthen(***)は、前の結果を受け取り、処理(2倍にする)して、次のハンドラに渡します。
  4. …など。

結果がハンドラのチェーンに沿って渡されるため、alertの呼び出しが順番に表示されます:124

すべてが機能するのは、.thenを呼び出すたびに新しいPromiseが返されるため、その後に次の.thenを呼び出すことができるからです。

ハンドラが値を返すとき、それはそのPromiseの結果となり、次の.thenがその値で呼び出されます。

よくある初心者のエラー: 技術的には、単一のPromiseに多くの.thenを追加することもできます。これはチェーンではありません。

例:

let promise = new Promise(function(resolve, reject) {
  setTimeout(() => resolve(1), 1000);
});

promise.then(function(result) {
  alert(result); // 1
  return result * 2;
});

promise.then(function(result) {
  alert(result); // 1
  return result * 2;
});

promise.then(function(result) {
  alert(result); // 1
  return result * 2;
});

ここでやったことは、1つのPromiseにいくつかのハンドラを追加しただけです。それらは結果を互いに渡すのではなく、独立して処理します。

これが図です(上記のチェーンと比較してください)

同じPromiseのすべての.thenは、同じ結果、つまりそのPromiseの結果を受け取ります。したがって、上記のコードでは、すべてのalertが同じものを表示します:1

実際には、1つのPromiseに対して複数のハンドラが必要になることはめったにありません。チェーンがはるかに頻繁に使用されます。

Promiseの返却

.then(handler)で使用されるハンドラは、Promiseを作成して返すことができます。

その場合、さらに後続のハンドラはそれが確定するまで待ち、その結果を取得します。

例:

new Promise(function(resolve, reject) {

  setTimeout(() => resolve(1), 1000);

}).then(function(result) {

  alert(result); // 1

  return new Promise((resolve, reject) => { // (*)
    setTimeout(() => resolve(result * 2), 1000);
  });

}).then(function(result) { // (**)

  alert(result); // 2

  return new Promise((resolve, reject) => {
    setTimeout(() => resolve(result * 2), 1000);
  });

}).then(function(result) {

  alert(result); // 4

});

ここで最初の.then1を表示し、(*)の行でnew Promise(…)を返します。1秒後、それは解決され、結果(resolveの引数で、ここではresult * 2)が2番目の.thenのハンドラに渡されます。そのハンドラは(**)の行にあり、2を表示し、同じことを行います。

したがって、出力は前の例と同じ:1 → 2 → 4ですが、今度はalertの呼び出しの間に1秒の遅延があります。

Promiseを返すことで、非同期アクションのチェーンを構築できます。

例: loadScript

前の章で定義したPromise化されたloadScriptで、この機能を使用して、スクリプトを順番に一つずつロードしましょう。

loadScript("/article/promise-chaining/one.js")
  .then(function(script) {
    return loadScript("/article/promise-chaining/two.js");
  })
  .then(function(script) {
    return loadScript("/article/promise-chaining/three.js");
  })
  .then(function(script) {
    // use functions declared in scripts
    // to show that they indeed loaded
    one();
    two();
    three();
  });

このコードは、アロー関数を使用して少し短くすることができます。

loadScript("/article/promise-chaining/one.js")
  .then(script => loadScript("/article/promise-chaining/two.js"))
  .then(script => loadScript("/article/promise-chaining/three.js"))
  .then(script => {
    // scripts are loaded, we can use functions declared there
    one();
    two();
    three();
  });

ここで、各loadScriptの呼び出しはPromiseを返し、それが解決されると次の.thenが実行されます。次に、次のスクリプトの読み込みを開始します。したがって、スクリプトは次々とロードされます。

チェーンにさらに非同期アクションを追加できます。コードは依然として「フラット」であることに注意してください。それは右ではなく下に伸びます。「破滅のピラミッド」の兆候はありません。

技術的には、次のように各loadScriptに直接.thenを追加することもできます。

loadScript("/article/promise-chaining/one.js").then(script1 => {
  loadScript("/article/promise-chaining/two.js").then(script2 => {
    loadScript("/article/promise-chaining/three.js").then(script3 => {
      // this function has access to variables script1, script2 and script3
      one();
      two();
      three();
    });
  });
});

このコードは同じことを行います:3つのスクリプトを順番にロードします。しかし、それは「右に伸びます」。したがって、コールバックと同じ問題があります。

Promiseを使い始めた人は、チェーンについて知らないことがあるため、このように記述します。一般的に、チェーンが推奨されます。

入れ子になった関数が外側のスコープにアクセスできるため、.thenを直接記述しても問題ない場合があります。上記の例では、最もネストされたコールバックは、すべての変数script1script2script3にアクセスできます。ただし、これはルールではなく例外です。

Thenables

厳密に言うと、ハンドラは厳密にPromiseではなく、いわゆる「thenable」オブジェクト、つまり.thenメソッドを持つ任意のオブジェクトを返す可能性があります。これはPromiseと同じように扱われます。

その考え方は、サードパーティのライブラリが独自の「Promise互換」オブジェクトを実装できるということです。それらは拡張されたメソッドのセットを持つことができますが、.thenを実装するため、ネイティブPromiseとも互換性があります。

これはthenableオブジェクトの例です。

class Thenable {
  constructor(num) {
    this.num = num;
  }
  then(resolve, reject) {
    alert(resolve); // function() { native code }
    // resolve with this.num*2 after the 1 second
    setTimeout(() => resolve(this.num * 2), 1000); // (**)
  }
}

new Promise(resolve => resolve(1))
  .then(result => {
    return new Thenable(result); // (*)
  })
  .then(alert); // shows 2 after 1000ms

JavaScriptは、(*)の行の.thenハンドラによって返されたオブジェクトをチェックします。もし呼び出し可能なthenという名前のメソッドがある場合、ネイティブ関数resolverejectを引数として(executorと同様に)提供してそのメソッドを呼び出し、それらのいずれかが呼び出されるまで待ちます。上記の例では、(**)で1秒後にresolve(2)が呼び出されます。その後、結果はチェーンをさらに下に渡されます。

この機能により、Promiseから継承することなく、カスタムオブジェクトをPromiseチェーンと統合できます。

より大きな例:fetch

フロントエンドプログラミングでは、Promiseはネットワークリクエストによく使用されます。したがって、その拡張例を見てみましょう。

fetchメソッドを使用して、リモートサーバーからユーザー情報をロードします。別の章で説明されている多くのオプションパラメータがありますが、基本的な構文は非常に簡単です。

let promise = fetch(url);

これにより、urlへのネットワークリクエストが行われ、Promiseが返されます。Promiseは、リモートサーバーがヘッダーで応答したときに、完全な応答がダウンロードされる前にresponseオブジェクトで解決されます。

完全な応答を読み取るには、メソッドresponse.text()を呼び出す必要があります。これにより、リモートサーバーから完全なテキストがダウンロードされると、そのテキストを結果として解決するPromiseが返されます。

次のコードは、user.jsonにリクエストを行い、サーバーからそのテキストをロードします。

fetch('/article/promise-chaining/user.json')
  // .then below runs when the remote server responds
  .then(function(response) {
    // response.text() returns a new promise that resolves with the full response text
    // when it loads
    return response.text();
  })
  .then(function(text) {
    // ...and here's the content of the remote file
    alert(text); // {"name": "iliakan", "isAdmin": true}
  });

fetchから返されるresponseオブジェクトには、リモートデータを読み取り、JSONとして解析するメソッドresponse.json()も含まれています。私たちの場合はさらに便利なので、それに切り替えましょう。

簡潔にするために、アロー関数も使用します。

// same as above, but response.json() parses the remote content as JSON
fetch('/article/promise-chaining/user.json')
  .then(response => response.json())
  .then(user => alert(user.name)); // iliakan, got user name

ロードされたユーザーで何かをしてみましょう。

たとえば、GitHubにさらにリクエストを行い、ユーザープロファイルをロードしてアバターを表示することができます。

// Make a request for user.json
fetch('/article/promise-chaining/user.json')
  // Load it as json
  .then(response => response.json())
  // Make a request to GitHub
  .then(user => fetch(`https://api.github.com/users/${user.name}`))
  // Load the response as json
  .then(response => response.json())
  // Show the avatar image (githubUser.avatar_url) for 3 seconds (maybe animate it)
  .then(githubUser => {
    let img = document.createElement('img');
    img.src = githubUser.avatar_url;
    img.className = "promise-avatar-example";
    document.body.append(img);

    setTimeout(() => img.remove(), 3000); // (*)
  });

コードは機能します。詳細についてはコメントを参照してください。ただし、それには潜在的な問題があり、Promiseを使い始めた人の典型的なエラーです。

(*)の行を見てください。アバターの表示が終了して削除されたに何かをするにはどうすればよいでしょうか?たとえば、そのユーザーを編集するためのフォームなどを表示したいと思います。今のところ、方法はありません。

チェーンを拡張可能にするには、アバターの表示が終了したときに解決されるPromiseを返す必要があります。

こんな感じです。

fetch('/article/promise-chaining/user.json')
  .then(response => response.json())
  .then(user => fetch(`https://api.github.com/users/${user.name}`))
  .then(response => response.json())
  .then(githubUser => new Promise(function(resolve, reject) { // (*)
    let img = document.createElement('img');
    img.src = githubUser.avatar_url;
    img.className = "promise-avatar-example";
    document.body.append(img);

    setTimeout(() => {
      img.remove();
      resolve(githubUser); // (**)
    }, 3000);
  }))
  // triggers after 3 seconds
  .then(githubUser => alert(`Finished showing ${githubUser.name}`));

つまり、(*)の行の.thenハンドラは、setTimeout(**)resolve(githubUser)の呼び出し後にのみ確定するnew Promiseを返します。チェーン内の次の.thenはそれを待ちます。

良い習慣として、非同期アクションは常にPromiseを返す必要があります。これにより、その後にアクションを計画することが可能になります。今すぐチェーンを拡張する予定がない場合でも、後で必要になる可能性があります。

最後に、コードを再利用可能な関数に分割できます。

function loadJson(url) {
  return fetch(url)
    .then(response => response.json());
}

function loadGithubUser(name) {
  return loadJson(`https://api.github.com/users/${name}`);
}

function showAvatar(githubUser) {
  return new Promise(function(resolve, reject) {
    let img = document.createElement('img');
    img.src = githubUser.avatar_url;
    img.className = "promise-avatar-example";
    document.body.append(img);

    setTimeout(() => {
      img.remove();
      resolve(githubUser);
    }, 3000);
  });
}

// Use them:
loadJson('/article/promise-chaining/user.json')
  .then(user => loadGithubUser(user.name))
  .then(showAvatar)
  .then(githubUser => alert(`Finished showing ${githubUser.name}`));
  // ...

まとめ

.then(またはcatch/finally。関係ありません)ハンドラがPromiseを返す場合、チェーンの残りの部分はそれが確定するまで待ちます。そうすると、その結果(またはエラー)がさらに渡されます。

これが全体像です。

課題

これらのコードフラグメントは等しいでしょうか?言い換えれば、任意のハンドラ関数に対して、どのような状況でも同じように動作するでしょうか?

promise.then(f1).catch(f2);

promise.then(f1, f2);

簡単な答えは:いいえ、それらは等しくありません

違いは、f1でエラーが発生した場合、それはここで.catchによって処理されるということです。

promise
  .then(f1)
  .catch(f2);

…しかし、ここではそうではありません。

promise
  .then(f1, f2);

それは、エラーがチェーンを下に渡され、2番目のコードではf1の下にチェーンがないためです。

言い換えれば、.thenは結果/エラーを次の.then/catchに渡します。したがって、最初の例では、下にcatchがあり、2番目の例ではそうではないため、エラーは処理されません。

チュートリアルマップ