JavaScript for impatient programmers (ES2022版)
本書をサポートしてください: 購入 または 寄付
(広告、ブロックしないでください。)

39 JavaScriptにおける非同期プログラミング



この章では、JavaScriptにおける非同期プログラミングの基礎について説明します。

39.1 JavaScriptにおける非同期プログラミングのロードマップ

このセクションでは、JavaScriptにおける非同期プログラミングに関する内容のロードマップを提供します。

  詳細を心配しないでください!

まだすべてを理解していなくても心配しないでください。これは、これから何が起こるのかを簡単に覗き見しただけです。

39.1.1 同期関数

通常の関数は同期です。呼び出し側は、呼び出し先が計算を完了するまで待ちます。A行のdivideSync()は同期関数呼び出しです。

function main() {
  try {
    const result = divideSync(12, 3); // (A)
    assert.equal(result, 4);
  } catch (err) {
    assert.fail(err);
  }
}

39.1.2 JavaScriptは単一プロセス内でタスクを順次実行する

デフォルトでは、JavaScriptのタスクは、単一のプロセスで順次実行される関数です。これは次のようになります。

while (true) {
  const task = taskQueue.dequeue();
  task(); // run task
}

このループは、マウスのクリックなどのイベントがタスクをキューに追加するため、イベントループとも呼ばれます。

この協調マルチタスクのスタイルにより、タスクが、たとえばサーバーからの結果を待っている間、他のタスクの実行をブロックすることは望ましくありません。次のサブセクションでは、このケースを処理する方法について説明します。

39.1.3 コールバックベースの非同期関数

divide()が結果を計算するためにサーバーを必要とする場合はどうなるでしょうか?その場合、結果は異なる方法で配信される必要があります。呼び出し側は、結果が準備できるまで(同期的に)待つ必要はなく、準備ができたら(非同期的に)通知される必要があります。結果を非同期的に配信する方法の1つは、divide()に、呼び出し側に通知するために使用するコールバック関数を渡すことです。

function main() {
  divideCallback(12, 3,
    (err, result) => {
      if (err) {
        assert.fail(err);
      } else {
        assert.equal(result, 4);
      }
    });
}

非同期関数呼び出しがある場合

divideCallback(x, y, callback)

次のステップが発生します。

39.1.4 Promiseベースの非同期関数

Promiseは2つのものです。

Promiseベースの関数の呼び出しは次のようになります。

function main() {
  dividePromise(12, 3)
    .then(result => assert.equal(result, 4))
    .catch(err => assert.fail(err));
}

39.1.5 async関数

async関数をPromiseベースのコードのより良い構文として見なすことができます。

async function main() {
  try {
    const result = await dividePromise(12, 3); // (A)
    assert.equal(result, 4);
  } catch (err) {
    assert.fail(err);
  }
}

A行で呼び出しているdividePromise()は、前のセクションと同じPromiseベースの関数です。ただし、呼び出しを処理するための同期的な構文があります。awaitは、特別な種類の関数であるasync関数内でのみ使用できます(キーワードfunctionの前のキーワードasyncに注意してください)。awaitは現在のasync関数を一時停止し、そこから戻ります。待機された結果の準備ができると、関数の実行は中断された場所から続行されます。

39.1.6 次のステップ

39.2 コールスタック

関数が別の関数を呼び出すたびに、後者の関数が終了した後、どこに戻るかを覚えておく必要があります。これは通常、スタック(コールスタック)を介して行われます。呼び出し側は戻る場所をプッシュし、呼び出し先はそれが完了した後、その場所にジャンプします。

これは、いくつかの呼び出しが発生する例です。

function h(z) {
  const error = new Error();
  console.log(error.stack);
}
function g(y) {
  h(y + 1);
}
function f(x) {
  g(x + 1);
}
f(3);
// done

最初に、このコードを実行する前は、コールスタックは空です。11行目の関数呼び出しf(3)の後、スタックには1つのエントリがあります。

9行目の関数呼び出しg(x + 1)の後、スタックには2つのエントリがあります。

6行目の関数呼び出しh(y + 1)の後、スタックには3つのエントリがあります。

3行目のerrorのロギングは、次の出力を生成します。

DEBUG
Error: 
    at h (file://demos/async-js/stack_trace.mjs:2:17)
    at g (file://demos/async-js/stack_trace.mjs:6:3)
    at f (file://demos/async-js/stack_trace.mjs:9:3)
    at file://demos/async-js/stack_trace.mjs:11:1

これは、Errorオブジェクトが作成された場所のいわゆるスタックトレースです。戻り先ではなく、呼び出しが行われた場所を記録していることに注意してください。2行目で例外を作成することも、別の呼び出しです。そのため、スタックトレースにはh()内の場所が含まれています。

3行目の後、各関数は終了し、そのたびに、トップエントリがコールスタックから削除されます。関数fが完了すると、トップレベルスコープに戻り、スタックは空になります。コードフラグメントが終了すると、それは暗黙的なreturnのようになります。コードフラグメントが実行されるタスクであると考えると、空のコールスタックで戻ることはタスクを終了します。

39.3 イベントループ

デフォルトでは、JavaScriptはWebブラウザとNode.jsの両方で、単一のプロセスで実行されます。いわゆるイベントループは、そのプロセス内でタスク(コードの一部)を順次実行します。イベントループを図21に示します。

Figure 21: Task sources add code to run to the task queue, which is emptied by the event loop.

2つの当事者がタスクキューにアクセスします。

次のJavaScriptコードは、イベントループの近似値です。

while (true) {
  const task = taskQueue.dequeue();
  task(); // run task
}

39.4 JavaScriptプロセスのブロッキングを回避する方法

39.4.1 ブラウザのユーザーインターフェースがブロックされる可能性がある

ブラウザのユーザーインターフェースメカニズムの多くも(タスクとして)JavaScriptプロセスで実行されます。したがって、実行時間の長いJavaScriptコードは、ユーザーインターフェースをブロックする可能性があります。それを実証するWebページを見てみましょう。そのページを試すには、2つの方法があります。

次のHTMLは、ページのユーザーインターフェースです。

<a id="block" href="">Block</a>
<div id="statusMessage"></div>
<button>Click me!</button>

アイデアは、「ブロック」をクリックすると、実行時間の長いループがJavaScriptを介して実行されることです。そのループ中、ブラウザ/JavaScriptプロセスがブロックされているため、ボタンをクリックできません。

JavaScriptコードの簡略化されたバージョンは次のようになります。

document.getElementById('block')
  .addEventListener('click', doBlock); // (A)

function doBlock(event) {
  // ···
  displayStatus('Blocking...');
  // ···
  sleep(5000); // (B)
  displayStatus('Done');
}

function sleep(milliseconds) {
  const start = Date.now();
  while ((Date.now() - start) < milliseconds);
}
function displayStatus(status) {
  document.getElementById('statusMessage')
    .textContent = status;
}

これらはコードの重要な部分です。

39.4.2 ブラウザのブロッキングを回避するには?

実行時間の長い操作がブラウザをブロックしないようにするための方法はいくつかあります。

39.4.3 休憩を取る

次のグローバル関数は、パラメーターcallbackmsミリ秒の遅延後に実行します(型シグネチャは簡略化されています。setTimeout()にはより多くの機能があります)。

function setTimeout(callback: () => void, ms: number): any

関数は、次のグローバル関数を介してタイムアウトをクリア(コールバックの実行をキャンセル)するために使用できるハンドル(ID)を返します。

function clearTimeout(handle?: any): void

setTimeout()は、ブラウザとNode.jsの両方で使用できます。次のサブセクションで、その動作を示します。

  setTimeout() はタスクに休憩を与える

setTimeout() の別の見方として、現在のタスクが休憩し、後でコールバックを介して処理を続けるという考え方があります。

39.4.4 完了まで実行されるセマンティクス

JavaScript はタスクに関して保証をします。

各タスクは、次のタスクが実行される前に必ず終了します(「完了まで実行」)。

その結果、タスクは作業中にデータが変更されること(同時変更)を心配する必要がありません。これにより、JavaScript でのプログラミングが簡略化されます。

次の例は、この保証を示しています。

console.log('start');
setTimeout(() => {
  console.log('callback');
}, 0);
console.log('end');

// Output:
// 'start'
// 'end'
// 'callback'

setTimeout() は、そのパラメータをタスクキューに入れます。したがって、パラメータは現在のコード(タスク)が完全に終了した後に実行されます。

パラメータ ms は、タスクがキューに入れられるタイミングのみを指定し、正確な実行タイミングを指定するものではありません。キューに先に終了しないタスクがある場合など、実行されないことさえあります。前のコードで、パラメータ ms0 であるにもかかわらず、'callback' の前に 'end' がログに記録されるのはそのためです。

39.5 非同期結果を配信するためのパターン

長時間実行される操作が完了するのを待つ間、メインプロセスがブロックされるのを避けるために、JavaScript では結果が非同期で配信されることがよくあります。これを行うための3つの一般的なパターンは次のとおりです。

最初の2つのパターンは次の2つのサブセクションで説明します。Promise は次の章で説明します。

39.5.1 イベントを介して非同期結果を配信する

パターンとしてのイベントは、次のように機能します。

このパターンの複数のバリエーションが JavaScript の世界に存在します。次に3つの例を見ていきます。

39.5.1.1 イベント:IndexedDB

IndexedDB は、Web ブラウザに組み込まれているデータベースです。以下はその使用例です。

const openRequest = indexedDB.open('MyDatabase', 1); // (A)

openRequest.onsuccess = (event) => {
  const db = event.target.result;
  // ···
};

openRequest.onerror = (error) => {
  console.error(error);
};

indexedDB には、操作を呼び出すための珍しい方法があります。

39.5.1.2 イベント:XMLHttpRequest

XMLHttpRequest API を使用すると、Web ブラウザ内からダウンロードを行うことができます。これは、ファイル http://example.com/textfile.txt をダウンロードする方法です。

const xhr = new XMLHttpRequest(); // (A)
xhr.open('GET', 'http://example.com/textfile.txt'); // (B)
xhr.onload = () => { // (C)
  if (xhr.status == 200) {
    processData(xhr.responseText);
  } else {
    assert.fail(new Error(xhr.statusText));
  }
};
xhr.onerror = () => { // (D)
  assert.fail(new Error('Network error'));
};
xhr.send(); // (E)

function processData(str) {
  assert.equal(str, 'Content of textfile.txt\n');
}

この API では、最初にリクエストオブジェクトを作成し(A 行)、次にそれを構成し、次にアクティブ化します(E 行)。構成は次のとおりです。

39.5.1.3 イベント:DOM

DOM イベントの動作については、§39.4.1「ブラウザのユーザーインターフェースがブロックされる可能性がある」ですでに説明しました。次のコードも click イベントを処理します。

const element = document.getElementById('my-link'); // (A)
element.addEventListener('click', clickListener); // (B)

function clickListener(event) {
  event.preventDefault(); // (C)
  console.log(event.shiftKey); // (D)
}

最初に、ブラウザに ID が 'my-link' の HTML 要素を取得するように要求します(A 行)。次に、すべての click イベントのリスナーを追加します(B 行)。リスナーでは、まずブラウザにデフォルトのアクション(リンクのターゲットに移動すること)を実行しないように指示します(C 行)。次に、シフトキーが現在押されているかどうかをコンソールに記録します(D 行)。

39.5.2 コールバックを介して非同期結果を配信する

コールバックは、非同期の結果を処理するための別のパターンです。これらは1回限りの結果にのみ使用され、イベントよりも冗長性が低いという利点があります。

例として、テキストファイルを読み込み、その内容を非同期で返す関数 readFile() を考えてみましょう。以下は、Node.js スタイルのコールバックを使用する場合の readFile() の呼び出し方法です。

readFile('some-file.txt', {encoding: 'utf8'},
  (error, data) => {
    if (error) {
      assert.fail(error);
      return;
    }
    assert.equal(data, 'The content of some-file.txt\n');
  });

成功と失敗の両方を処理する単一のコールバックがあります。最初のパラメータが null でない場合はエラーが発生しました。それ以外の場合は、2 番目のパラメータに結果が見つかります。

  練習問題:コールバックベースのコード

以下の演習では、非同期コードのテストを使用しますが、これは同期コードのテストとは異なります。詳細については、§10.3.2「Mochaでの非同期テスト」を参照してください。

39.6 非同期コード:欠点

ブラウザまたは Node.js のどちらでも、多くの場合、選択の余地はなく、非同期コードを使用する必要があります。この章では、そのようなコードが使用できるいくつかのパターンを見てきました。それらすべてに2つの欠点があります。

最初の欠点は、Promises(次の章で説明)で軽減され、async 関数(次の次の章で説明)でほとんど解消されます。

残念ながら、非同期コードの感染性はなくなりません。ただし、async 関数を使用すると同期と非同期の切り替えが簡単になるという事実によって緩和されます。

39.7 リソース