2022年8月14日

プロミス

あなたがトップシンガーで、ファンがあなたの次の曲を昼夜問わず要求していると想像してください。

少しでも解放されるために、あなたは公開されたら彼らに送ると約束します。ファンにリストを渡します。彼らは自分のメールアドレスを記入することができ、曲が利用可能になったら、サブスクライブしたすべての関係者がすぐにそれを受け取ります。そして、スタジオで火災が発生し、曲を公開できなくなったとしても、彼らは通知を受け取ります。

誰もが幸せです:人々があなたに群がらなくなったためあなたも、そしてファンも曲を聞き逃さないからです。

これは、私たちがプログラミングでよく行うことの現実世界のアナロジーです。

  1. 何かをして時間がかかる「生成コード」。たとえば、ネットワーク経由でデータをロードするコード。それが「歌手」です。
  2. 準備が整ったら「生成コード」の結果を求める「消費コード」。多くの関数がその結果を必要とする場合があります。これらが「ファン」です。
  3. プロミスは、「生成コード」と「消費コード」をリンクする特別なJavaScriptオブジェクトです。私たちのアナロジーでは、これは「サブスクリプションリスト」です。「生成コード」は、約束された結果を生成するために必要な時間を費やし、「プロミス」は、準備が整ったときにサブスクライブされたすべてのコードがその結果を利用できるようにします。

JavaScriptのプロミスは単なるサブスクリプションリストよりも複雑であるため、このアナロジーはそれほど正確ではありません。追加の機能と制限があります。しかし、始めるにはこれで十分です。

プロミスオブジェクトのコンストラクタ構文は

let promise = new Promise(function(resolve, reject) {
  // executor (the producing code, "singer")
});

new Promiseに渡される関数は、エグゼキュータと呼ばれます。new Promiseが作成されると、エグゼキュータが自動的に実行されます。これには、最終的に結果を生成するはずの生成コードが含まれています。上記のアナロジーでは、エグゼキュータは「歌手」です。

その引数であるresolverejectは、JavaScript自体によって提供されるコールバックです。私たちのコードはエグゼキュータの中にのみあります。

エグゼキュータがすぐにまたは遅れて結果を取得した場合は、関係なく、これらのコールバックの1つを呼び出す必要があります。

  • resolve(value) — ジョブが結果valueで正常に完了した場合。
  • reject(error) — エラーが発生した場合。errorはエラーオブジェクトです。

まとめると、エグゼキュータは自動的に実行され、ジョブの実行を試みます。試行が完了すると、成功した場合はresolveを呼び出し、エラーが発生した場合はrejectを呼び出します。

new Promiseコンストラクタによって返されるpromiseオブジェクトには、次の内部プロパティがあります。

  • state — 最初は"pending"で、resolveが呼び出された場合は"fulfilled"に、rejectが呼び出された場合は"rejected"に変わります。
  • result — 最初はundefinedで、resolve(value)が呼び出された場合はvalueに、reject(error)が呼び出された場合はerrorに変わります。

したがって、エグゼキュータは最終的にpromiseを次のいずれかの状態に移動します。

後で、「ファン」がこれらの変更をサブスクライブする方法を見ていきます。

これは、プロミスコンストラクタと、時間がかかる(setTimeoutを使用)「生成コード」を含む単純なエグゼキュータ関数の例です。

let promise = new Promise(function(resolve, reject) {
  // the function is executed automatically when the promise is constructed

  // after 1 second signal that the job is done with the result "done"
  setTimeout(() => resolve("done"), 1000);
});

上記のコードを実行すると、2つのことがわかります。

  1. エグゼキュータは、(new Promiseによって)自動的にすぐに呼び出されます。

  2. エグゼキュータは2つの引数を受け取ります。resolverejectです。これらの関数はJavaScriptエンジンによって事前定義されているため、作成する必要はありません。準備が整ったら、それらの1つだけを呼び出す必要があります。

    1秒間の「処理」の後、エグゼキュータはresolve("done")を呼び出して結果を生成します。これにより、promiseオブジェクトの状態が変わります。

これは、ジョブが正常に完了した「履行されたプロミス」の例でした。

そして、これはエグゼキュータがエラーでプロミスを拒否する例です。

let promise = new Promise(function(resolve, reject) {
  // after 1 second signal that the job is finished with an error
  setTimeout(() => reject(new Error("Whoops!")), 1000);
});

reject(...)の呼び出しは、プロミスオブジェクトを"rejected"状態に移動します。

まとめると、エグゼキュータはジョブ(通常は時間がかかるもの)を実行し、対応するプロミスオブジェクトの状態を変更するためにresolveまたはrejectを呼び出す必要があります。

解決済みまたは拒否済みのプロミスは、初期の「保留中」のプロミスとは対照的に、「確定」と呼ばれます。

結果またはエラーは1つだけです。

エグゼキュータは、resolveまたはrejectのいずれか1つだけを呼び出す必要があります。状態の変更はすべて最終的なものです。

resolveおよびrejectのそれ以降の呼び出しはすべて無視されます。

let promise = new Promise(function(resolve, reject) {
  resolve("done");

  reject(new Error("…")); // ignored
  setTimeout(() => resolve("…")); // ignored
});

エグゼキュータによって実行されるジョブには、結果またはエラーが1つしかないという考えです。

また、resolve/rejectは1つの引数(またはなし)のみを予期し、追加の引数は無視します。

Errorオブジェクトで拒否する

何か問題が発生した場合、エグゼキュータはrejectを呼び出す必要があります。これは、(resolveのように)任意の型の引数を使用して行うことができます。ただし、Errorオブジェクト(またはErrorから継承するオブジェクト)を使用することをお勧めします。その理由はすぐに明らかになります。

resolve/rejectをすぐに呼び出す

実際には、エグゼキュータは通常、何かを非同期的に実行し、しばらくしてからresolve/rejectを呼び出しますが、そうする必要はありません。このように、resolveまたはrejectをすぐに呼び出すこともできます。

let promise = new Promise(function(resolve, reject) {
  // not taking our time to do the job
  resolve(123); // immediately give the result: 123
});

たとえば、ジョブを開始したものの、すべてがすでに完了してキャッシュされていることがわかった場合に、このようなことが発生する可能性があります。

それで問題ありません。すぐに解決済みのプロミスが得られます。

stateresultは内部です。

プロミスオブジェクトのプロパティstateresultは内部です。直接アクセスすることはできません。そのためには、.then/.catch/.finallyメソッドを使用できます。それらについては以下で説明します。

コンシューマー: then、catch

プロミスオブジェクトは、エグゼキュータ(「生成コード」または「歌手」)と、結果またはエラーを受信する消費関数(「ファン」)との間のリンクとして機能します。消費関数は、.thenおよび.catchメソッドを使用して登録(サブスクライブ)できます。

then

最も重要で基本的なものは.thenです。

構文は

promise.then(
  function(result) { /* handle a successful result */ },
  function(error) { /* handle an error */ }
);

.thenの最初の引数は、プロミスが解決されたときに実行され、結果を受け取る関数です。

.thenの2番目の引数は、プロミスが拒否されたときに実行され、エラーを受け取る関数です。

たとえば、正常に解決されたプロミスに対する反応を次に示します。

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

// resolve runs the first function in .then
promise.then(
  result => alert(result), // shows "done!" after 1 second
  error => alert(error) // doesn't run
);

最初の関数が実行されました。

そして、拒否の場合、2番目の関数が実行されます。

let promise = new Promise(function(resolve, reject) {
  setTimeout(() => reject(new Error("Whoops!")), 1000);
});

// reject runs the second function in .then
promise.then(
  result => alert(result), // doesn't run
  error => alert(error) // shows "Error: Whoops!" after 1 second
);

成功した完了にのみ関心がある場合は、.thenに1つの関数引数のみを提供できます。

let promise = new Promise(resolve => {
  setTimeout(() => resolve("done!"), 1000);
});

promise.then(alert); // shows "done!" after 1 second

catch

エラーのみに関心がある場合は、最初の引数としてnullを使用できます: .then(null, errorHandlingFunction)。または、.catch(errorHandlingFunction)を使用できます。これはまったく同じです。

let promise = new Promise((resolve, reject) => {
  setTimeout(() => reject(new Error("Whoops!")), 1000);
});

// .catch(f) is the same as promise.then(null, f)
promise.catch(alert); // shows "Error: Whoops!" after 1 second

.catch(f)の呼び出しは.then(null, f)と完全に同じで、単なる省略形です。

クリーンアップ: finally

通常のtry {...} catch {...}finally句があるように、プロミスにもfinallyがあります。

.finally(f)の呼び出しは、プロミスが解決または拒否されたときに、常にfが実行されるという意味で.then(f, f)に似ています。

finallyの考え方は、前の操作が完了した後でクリーンアップ/ファイナライズを実行するためのハンドラを設定することです。

たとえば、読み込みインジケータの停止、不要になった接続のクローズなど。

パーティーのフィニッシャーと考えてください。パーティーが良かったか悪かったか、何人の友達がいたかに関係なく、私たちはまだ(少なくともそうするべきです)その後にクリーンアップをする必要があります。

コードは次のようになります。

new Promise((resolve, reject) => {
  /* do something that takes time, and then call resolve or maybe reject */
})
  // runs when the promise is settled, doesn't matter successfully or not
  .finally(() => stop loading indicator)
  // so the loading indicator is always stopped before we go on
  .then(result => show result, err => show error)

finally(f)then(f,f)の正確なエイリアスではないことに注意してください。

重要な違いがあります。

  1. finallyハンドラには引数はありません。finallyでは、プロミスが成功したか失敗したかわかりません。私たちのタスクは通常、「一般的な」ファイナライズ手順を実行することなので、それで問題ありません。

    上記の例を見てください。ご覧のとおり、finallyハンドラには引数がなく、プロミスの結果は次のハンドラによって処理されます。

  2. finallyハンドラは、結果またはエラーを次の適切なハンドラに「パススルー」します。

    たとえば、ここで結果はfinallyを介してthenに渡されます。

    new Promise((resolve, reject) => {
      setTimeout(() => resolve("value"), 2000);
    })
      .finally(() => alert("Promise ready")) // triggers first
      .then(result => alert(result)); // <-- .then shows "value"

    ご覧のとおり、最初のPromiseによって返されたvalueは、finallyを介して次のthenに渡されます。

    これは非常に便利です。なぜなら、finallyはPromiseの結果を処理するためのものではないからです。前述のとおり、これは結果がどうであれ、一般的なクリーンアップを行うための場所です。

    エラーがどのようにfinallyを介してcatchに渡されるかを示すために、エラーの例を次に示します。

    new Promise((resolve, reject) => {
      throw new Error("error");
    })
      .finally(() => alert("Promise ready")) // triggers first
      .catch(err => alert(err));  // <-- .catch shows the error
  3. finallyハンドラーも何も返す必要はありません。もし返した場合、返された値は黙って無視されます。

    このルールの唯一の例外は、finallyハンドラーがエラーをスローした場合です。この場合、このエラーは以前の結果の代わりに次のハンドラーに渡されます。

要約すると

  • finallyハンドラーは、前のハンドラーの結果を取得しません(引数はありません)。この結果は代わりに、次の適切なハンドラーに渡されます。
  • finallyハンドラーが何かを返した場合、それは無視されます。
  • finallyがエラーをスローすると、実行は最も近いエラーハンドラーに進みます。

これらの機能は役立ち、finallyを本来の使用方法である一般的なクリーンアップ手順に使用すると、物事が適切に機能するようになります。

確定済みのPromiseにハンドラーをアタッチできます

Promiseが保留中の場合、.then/catch/finallyハンドラーはその結果を待ちます。

場合によっては、ハンドラーを追加したときにPromiseがすでに確定していることがあります。

このような場合、これらのハンドラーはすぐに実行されます。

// the promise becomes resolved immediately upon creation
let promise = new Promise(resolve => resolve("done!"));

promise.then(alert); // done! (shows up right now)

これは、Promiseが現実世界の「サブスクリプションリスト」のシナリオよりも強力であることを示しています。もし歌手がすでに曲をリリースしていて、その後、ある人がサブスクリプションリストに登録した場合、おそらくその曲を受け取ることはできません。現実世界のサブスクリプションは、イベントが発生する前に完了する必要があります。

Promiseはより柔軟性があります。ハンドラーはいつでも追加できます。結果がすでに存在する場合、ハンドラーはただ実行されます。

例:loadScript

次に、Promiseが非同期コードの記述にどのように役立つかについて、より実践的な例を見ていきましょう。

前の章からスクリプトをロードするためのloadScript関数があります。

コールバックベースのバリアントを思い出させるために、ここに示します。

function loadScript(src, callback) {
  let script = document.createElement('script');
  script.src = src;

  script.onload = () => callback(null, script);
  script.onerror = () => callback(new Error(`Script load error for ${src}`));

  document.head.append(script);
}

Promiseを使用してそれを書き直しましょう。

新しい関数loadScriptは、コールバックを必要としません。代わりに、ロードが完了したときに解決されるPromiseオブジェクトを作成して返します。外部コードは、.thenを使用してハンドラー(サブスクリプション関数)を追加できます。

function loadScript(src) {
  return new Promise(function(resolve, reject) {
    let script = document.createElement('script');
    script.src = src;

    script.onload = () => resolve(script);
    script.onerror = () => reject(new Error(`Script load error for ${src}`));

    document.head.append(script);
  });
}

使用法

let promise = loadScript("https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.11/lodash.js");

promise.then(
  script => alert(`${script.src} is loaded!`),
  error => alert(`Error: ${error.message}`)
);

promise.then(script => alert('Another handler...'));

コールバックベースのパターンと比較して、いくつかの利点をすぐに確認できます。

Promise コールバック
Promiseを使用すると、自然な順序で処理を実行できます。最初にloadScript(script)を実行し、.thenを使用して結果をどう処理するかを記述します。 loadScript(script, callback)を呼び出す際に、callback関数を使用可能にする必要があります。言い換えれば、loadScriptが呼び出されるに、結果をどう処理するかを知っておく必要があります。
Promiseで.thenを必要な回数だけ呼び出すことができます。毎回、新しい「ファン」、つまり新しいサブスクリプション関数を「サブスクリプションリスト」に追加しています。これについては次の章「Promiseチェーン」で詳しく説明します。 コールバックは1つしか存在できません。

したがって、Promiseはコードの流れと柔軟性を向上させます。しかし、それだけではありません。それについては次の章で見ていきましょう。

課題

以下のコードの出力は何ですか?

let promise = new Promise(function(resolve, reject) {
  resolve(1);

  setTimeout(() => resolve(2), 1000);
});

promise.then(alert);

出力は1です。

resolveへの2回目の呼び出しは、reject/resolveの最初の呼び出しのみが考慮されるため、無視されます。以降の呼び出しは無視されます。

組み込み関数setTimeoutはコールバックを使用します。Promiseベースの代替を作成してください。

関数delay(ms)はPromiseを返す必要があります。そのPromiseは、.thenを追加できるように、msミリ秒後に解決される必要があります。このような感じです。

function delay(ms) {
  // your code
}

delay(3000).then(() => alert('runs after 3 seconds'));
function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

delay(3000).then(() => alert('runs after 3 seconds'));

この課題では、resolveは引数なしで呼び出されることに注意してください。delayからは何も値を返さず、遅延だけを保証します。

コールバック付きアニメーション円の課題の解決策にあるshowCircle関数を、コールバックを受け入れる代わりにPromiseを返すように書き換えてください。

新しい使用法

showCircle(150, 150, 100).then(div => {
  div.classList.add('message-ball');
  div.append("Hello, world!");
});

課題コールバック付きアニメーション円の解決策をベースとして使用してください。

チュートリアルマップ