2022年6月18日

イントロダクション: コールバック

ここでは例としてブラウザメソッドを使用します

コールバック、Promise、その他の抽象的な概念の使い方を説明するために、いくつかのブラウザメソッドを使用します。具体的には、スクリプトの読み込みと簡単なドキュメント操作です。

これらのメソッドに慣れていない場合、例での使い方がわかりにくい場合は、チュートリアルの次のパートのいくつかの章を読むことをお勧めします。

いずれにしても、分かりやすく説明するように努めます。ブラウザに関しては、実際には複雑なことはありません。

JavaScriptのホスト環境では、*非同期*アクションをスケジュールできる多くの関数が提供されています。つまり、今は開始するが、後で完了するアクションです。

たとえば、そのような関数の1つは`setTimeout`関数です。

非同期アクションの現実世界の例としては、スクリプトやモジュールの読み込みなどがあります(これらについては後の章で説明します)。

指定された`src`を持つスクリプトを読み込む関数`loadScript(src)`を見てみましょう。

function loadScript(src) {
  // creates a <script> tag and append it to the page
  // this causes the script with given src to start loading and run when complete
  let script = document.createElement('script');
  script.src = src;
  document.head.append(script);
}

これは、指定された`src`を持つ新しい動的に作成されたタグ`<script src="…">`をドキュメントに挿入します。ブラウザは自動的に読み込みを開始し、完了時に実行します。

この関数は次のように使用できます

// load and execute the script at the given path
loadScript('/my/script.js');

スクリプトは「非同期」に実行されます。つまり、今は読み込みを開始しますが、関数がすでに終了した後で実行されます。

`loadScript(…)`の下にコードがある場合、スクリプトの読み込みが完了するまで待機しません。

loadScript('/my/script.js');
// the code below loadScript
// doesn't wait for the script loading to finish
// ...

新しいスクリプトが読み込まれたらすぐに使用したいとしましょう。それは新しい関数を宣言し、私たちはそれらを実行したいと考えています。

しかし、`loadScript(…)`呼び出しの直後にそれを行うと、うまくいきません

loadScript('/my/script.js'); // the script has "function newFunction() {…}"

newFunction(); // no such function!

当然のことながら、ブラウザはおそらくスクリプトを読み込む時間がありませんでした。現在のところ、`loadScript`関数は読み込みの完了を追跡する方法を提供していません。スクリプトは読み込まれ、最終的には実行されます。それだけのことです。しかし、そのスクリプトの新しい関数や変数を使用するために、いつそれが起こるのかを知りたいと思っています。

スクリプトの読み込み時に実行される`callback`関数を`loadScript`の2番目の引数として追加してみましょう。

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

  script.onload = () => callback(script);

  document.head.append(script);
}

`onload`イベントについては、リソースの読み込み: onloadとonerrorの記事で説明されています。基本的には、スクリプトが読み込まれて実行された後に、関数が実行されます。

これで、スクリプトから新しい関数を呼び出したい場合は、コールバックにそれを記述する必要があります

loadScript('/my/script.js', function() {
  // the callback runs after the script is loaded
  newFunction(); // so now it works
  ...
});

これが考え方です。2番目の引数は、アクションが完了したときに実行される関数(通常は匿名)です。

実際のスクリプトを使用した実行可能な例を次に示します

function loadScript(src, callback) {
  let script = document.createElement('script');
  script.src = src;
  script.onload = () => callback(script);
  document.head.append(script);
}

loadScript('https://cdnjs.cloudflare.com/ajax/libs/lodash.js/3.2.0/lodash.js', script => {
  alert(`Cool, the script ${script.src} is loaded`);
  alert( _ ); // _ is a function declared in the loaded script
});

これは「コールバックベース」の非同期プログラミングスタイルと呼ばれます。非同期的に何かを行う関数は、完了後に実行する関数を配置する`callback`引数を提供する必要があります。

ここでは`loadScript`で行いましたが、もちろんこれは一般的なアプローチです。

コールバック内のコールバック

2つのスクリプトを順番に読み込むにはどうすればよいでしょうか。最初のスクリプトを読み込み、次に2番目のスクリプトを読み込みますか?

自然な解決策は、次のように2番目の`loadScript`呼び出しをコールバック内に配置することです

loadScript('/my/script.js', function(script) {

  alert(`Cool, the ${script.src} is loaded, let's load one more`);

  loadScript('/my/script2.js', function(script) {
    alert(`Cool, the second script is loaded`);
  });

});

外側の`loadScript`が完了すると、コールバックは内側の`loadScript`を開始します。

さらにスクリプトが必要な場合はどうでしょうか...?

loadScript('/my/script.js', function(script) {

  loadScript('/my/script2.js', function(script) {

    loadScript('/my/script3.js', function(script) {
      // ...continue after all scripts are loaded
    });

  });

});

そのため、すべての新しいアクションはコールバック内にあります。これは、アクションが少ない場合は問題ありませんが、多数のアクションには適していないため、すぐに他のバリアントが表示されます。

エラー処理

上記の例では、エラーを考慮していませんでした。スクリプトの読み込みに失敗した場合はどうなりますか?コールバックはそれに反応できる必要があります。

読み込みエラーを追跡する、改良版の`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);
}

読み込みが成功した場合は`callback(null, script)`を呼び出し、そうでない場合は`callback(error)`を呼び出します。

使用方法

loadScript('/my/script.js', function(error, script) {
  if (error) {
    // handle error
  } else {
    // script loaded successfully
  }
});

繰り返しますが、`loadScript`で使用したレシピは実際には非常に一般的です。これは「エラーファーストコールバック」スタイルと呼ばれます。

規則は次のとおりです

  1. `callback`の最初の引数は、エラーが発生した場合に備えて予約されています。その後、`callback(err)`が呼び出されます。
  2. 2番目の引数(および必要に応じて次の引数)は、成功した結果用です。その後、`callback(null, result1, result2…)`が呼び出されます。

そのため、単一の`callback`関数は、エラーの報告と結果の受け渡しの両方に使用されます。

破滅のピラミッド

一見すると、非同期コーディングの実行可能なアプローチのように見えます。そして実際そうです。1つまたは2つのネストされた呼び出しの場合は問題ありません。

しかし、次々に続く複数の非同期アクションの場合、次のようなコードになります

loadScript('1.js', function(error, script) {

  if (error) {
    handleError(error);
  } else {
    // ...
    loadScript('2.js', function(error, script) {
      if (error) {
        handleError(error);
      } else {
        // ...
        loadScript('3.js', function(error, script) {
          if (error) {
            handleError(error);
          } else {
            // ...continue after all scripts are loaded (*)
          }
        });

      }
    });
  }
});

上記のコードでは

  1. `1.js`を読み込み、エラーがない場合は…
  2. `2.js`を読み込み、エラーがない場合は…
  3. `3.js`を読み込み、エラーがない場合は、何か他のことを行います`(*)`。

呼び出しがネストされるほど、コードは深くなり、管理がますます難しくなります。特に、`...`の代わりに、より多くのループ、条件付きステートメントなどを含む実際のコードがある場合はそうです。

これは、「コールバック地獄」または「破滅のピラミッド」と呼ばれることがあります。

ネストされた呼び出しの「ピラミッド」は、非同期アクションごとに右に大きくなります。すぐに制御不能になります。

そのため、このコーディング方法はあまり良くありません。

次のように、すべてのアクションをスタンドアロン関数にすることで、問題を軽減しようとすることができます

loadScript('1.js', step1);

function step1(error, script) {
  if (error) {
    handleError(error);
  } else {
    // ...
    loadScript('2.js', step2);
  }
}

function step2(error, script) {
  if (error) {
    handleError(error);
  } else {
    // ...
    loadScript('3.js', step3);
  }
}

function step3(error, script) {
  if (error) {
    handleError(error);
  } else {
    // ...continue after all scripts are loaded (*)
  }
}

分かりますか?同じことを行いますが、すべてのアクションを個別のトップレベル関数にしたため、深いネストはなくなりました。

これは機能しますが、コードは引き裂かれたスプレッドシートのように見えます。読むのが難しく、おそらく読んでいる間、部分と部分の間を目線がジャンプする必要があることに気づいたでしょう。これは不便です。特に、読者がコードに慣れておらず、どこに目線をジャンプさせるべきかわからない場合はそうです。

また、`step*`という名前の関数はすべて単一使用であり、「破滅のピラミッド」を回避するためだけに作成されています。アクションチェーンの外でそれらを再利用する人はいません。そのため、ここには少し名前空間の混乱があります。

もっと良いものが必要です。

幸いなことに、そのようなピラミッドを回避する他の方法があります。最良の方法の1つは、次の章で説明する「Promise」を使用することです。

チュートリアルマップ

コメント

コメントする前にこれを読んでください…
  • 改善のための提案がある場合は、コメントする代わりにGitHubのissueを送信するか、プルリクエストを送信してください。
  • 記事の内容が理解できない場合は、詳しく説明してください。
  • 数語のコードを挿入するには、`<code>`タグを使用します。数行の場合は、`<pre>`タグで囲みます。10行を超える場合は、サンドボックス(plnkrjsbincodepen…)を使用します。