JavaScript:非同期処理①

コールバック関数

非同期処理を学ぶ前に、まずはコールバック関数について復習。

コールバック関数とは

MDN Web Docsによると次のように定義されている。

コールバック関数とは、引数として他の関数に渡され、外側の関数の中で呼び出されて、何らかのルーチンやアクションを完了させる関数のこと

Callback function

コールバックには、すぐに実行される同期型コールバックと、非同期操作が完了した後に実行される非同期型コールバックがある。例えば次のコードがあるとする。

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.src} is loaded`);
  alert( _ ); // ロードされたスクリプトで宣言されている関数
});

上の例のコールバック関数は無名関数として、loadScript に渡され、loadScriptonload プロパティに対してイベントハンドラとして設定し、非同期処理を実現している。

image.png

コールバック地獄、破滅のピラミッド

コールバックはネストさせることができる。

image.png

上記のようなコードは、サイクロマティック複雑度(循環的複雑度)が高くなる。一般的に複雑度が高いほどメンテナンス性が悪くなるので、上記のような記述は避ける。

避けるために、次のように記述することもできる。

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 {
    // ...すべてのスクリプトが読み込まれた後に続く (*)
  }
};

上記は関数間でジャンプがあるため読みにくく、また、関数名も再利用性が考慮されていない。ベストな方法は、promise を使う方法。

promise

イベントと処理を結びつける

MDN Web Docs によると、Promise オブジェクトは次のように定義される。

Promise オブジェクトは、非同期処理の完了 (もしくは失敗) の結果およびその結果の値を表します。 Promise は、作成された時点では分からなくてもよい値へのプロキシーです。非同期のアクションの成功値または失敗理由にハンドラーを結びつけることができます。

これにより、非同期メソッドは結果の値を返す代わりに、未来のある時点で値を提供する Promise を返すことで、同期メソッドと同じように値を返すことができるようになります。

『作成された時点では分からなくてもよい値』というのはネットワーク経由でダウンロードするコードなどを指し、そのダウンロードの非同期処理の結果をイベントハンドラと結びつけるのが、Promise オブジェクトの役割。

非同期処理では結果を直ぐに返せない。その際、未来のある時点で値を返す Promise オブジェクトを返すようにすると、非同期処理でも同期処理と同様に値を返すことができるようになる。

// Promise オブジェクトのコンストラクタ構文
let promise = new Promise(function(resolve, reject) {
  // executor(処理)
});

上記構文の中で、非同期の処理自体は executor 部分で、resolve, reject メソッド部分は、JavaScript によって提供されるコールバックであり、次の意味を持つ。

  • resolve(value) – ジョブが正常に終了した場合。結果の value を持つ。
  • reject(error) – エラーが発生した場合。error はエラーオブジェクト。

new Promise で生成される Promise オブジェクトは状態を持っている。

  • 初期状態(pending):初期状態。成功でも失敗でもない
  • 履行(fulfilled):処理が成功
  • 拒否(rejected):処理が失敗。

ジョブが完了した時に resolve が呼ばれ、エラーした時に reject が呼ばれる。

image.png

例えば、次のようなコードを考える。

let promise = new Promise(function(resolve, reject) {
  // promise が作られたとき、関数は自動的に実行

  // 1秒後、ジョブが "done!" という結果と一緒に完了したことを合図する
  setTimeout(() => resolve("done!"), 1000);
});

promiseイベントハンドラ setTimeOut の完了時に、resolve"done!" を渡す。

イベントを受け取る

イベントの受け取りメソッドは、 .then, .catch, .finally がある。

.then が最も重要。.then でイベント発生時のデータを受け取る際の構文は次のようになっている。

// Promise 取得の基本構文
promise.then(
  function(result) { /* 成功した結果を扱う */ },
  function(error) { /* エラーを扱う */ }
);

上記で示した、1秒後のイベントハンドラの例に対して、Promise を受け取るコードは次のようになる。

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

promise.then(
  result => alert(result), // 1秒後に "done!" を表示
  error => alert(error) // 実行されない
);

拒否の場合は、次ようにする。

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

promise.then(
  result => alert(result), // 実行されない
  error => alert(error) // 1秒後に "Error: Whoops!" を表示
);

正常完了だけでいい時は、 .then に1つだけ引数を渡す。

catch はエラーのみに関心がある時に使用し、finallytry{...}catch{...}finally 節のように Promise 完了時に必ず実行させたい時に使用する。finally はクリーンアップを実行するときに便利なハンドラ。

コールバックとの違い

違いを下記表に示す。

Promise コールバック
自然な順序でコード記述できる。どうするかは別で書ける 呼び出し時にどうするかを指定する
.thenを何度も呼べる 1つだけ

コールバックの場合、呼び出し時に何を実行させるかを指定する必要がある。

// コールバックを使った関数
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 ` + src));

  document.head.append(script);
}
// コールバックを使った関数を使う時
loadScript('https://cdnjs.cloudflare.com/ajax/libs/lodash.js/3.2.0/lodash.js', script => {
  alert(`Cool, the ${script.src} is loaded`);
  alert( _ ); // ロードされたスクリプトで宣言されている関数
});

Promise の場合、呼び出し時には Promise のコンストラクタだけでよく、イベント検知時の処理は別に記述することができる。

// Promiseを使った関数
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: " + src));

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

// Promiseで作った関数を使うとき
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とコールバックの違いを確認する。

Promise コールバック
自然な順序でコード記述できる。どうするかは別で書ける 呼び出し時にどうするかを指定する
.thenを何度も呼べる 1つだけ

Promise チェーンは、.then を何度も呼び、順次実行される一連の非同期タスクを実現できる。例えば、次のような感じ。

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;

});

実行の流れは、次のようになっている。

  1. 最初の promise は1秒で解決 (*)
  2. その後、.then ハンドラが呼ばれる (**)
  3. 返却された値は、次の .then ハンドラへ (***)
  4. …以下繰り返し

図で示すと、次の順序で実行されている。

image.png

これは、promise.then の戻り値が promise を返すため、続けて .then を呼び出すことができる。ハンドラが結果を返す時には、それは promise の結果となり、 .then はその結果とともに呼ばれる。

非同期版の Ruby のメソッドチェインみたいな感じと思っておけば良さそう🧐

単一の 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;
});

上記は、次のように解釈される。

image.png

.then の中にさらにハンドラがあるような場合、外側のハンドラは、その promise が完了するまで待ち、その結果を取得する。

image.png

Promise での例外対応

promise チェーンで例外が起きると、最も近い reject ハンドラに移る。例外対応の例を示す。

fetch('https://no-such-server.blabla') // rejects
  .then(response => response.json())
  .catch(err => alert(err))

C++ でも throw された例外は、catch されるまで関数呼び出しを辿っていくけど、それに近いようなものと考えれば良さそう🧐よって、 .catch で最も簡単な方法は、チェーンの末尾につけるということっぽい。

また、catch は明示的な reject だけでなく、throw や プログラムエラーも捕まえてくれる。

// 例外を投げる
new Promise(function(resolve, reject) {
  resolve("ok");
}).then(function(result) {
  throw new Error("Whoops!");
}).catch(alert); // Error: Whoops!

// 存在しない関数を呼ぶ
new Promise(function(resolve, reject) {
  resolve("ok");
}).then(function(result) {
  blabla(); // このような関数はありません
}).catch(alert); // ReferenceError: blabla is not defined

再スロー

.catch の中から再度例外を投げることも可能。基本的な例外の動きは、C++と同じような感じだと思って良さそう。

// 実行: catch -> catch -> then
new Promise(function(resolve, reject) {

  throw new Error("Whoops!");

}).catch(function(error) { // (*)

  if (error instanceof URIError) {
    // エラー処理
  } else {
    alert("Can't handle such error");

    throw error; // ここで投げられたエラーは次の catch へジャンプします
  }

}).then(function() {
  /* 実行されません */
}).catch(error => { // (**)

  alert(`The unknown error has occurred: ${error}`);
  // 何も返しません => 実行は通常通りに進みます

});

reject を未処理にしない

例外発生時に、reject ハンドラがない場合、スクリプトはコンソールにエラーメッセージを表示して終了する。通常、ユーザはコンソールを見ないので、ユーザからすると『ただ落ちた』という状況になってしまうので、なんらかの対応が必要。

当該状況をユーザに通知するために、 unhandledrejection イベントハンドラが使える。unhandledrejection は、例外が発生し .catch がない場合に発生するハンドラで、エラー情報を持っており、なんらかの情報を伝えることに役立つ。

window.addEventListener('unhandledrejection', function(event) {
  // イベントオブジェクトは2つの特別なプロパティを持つ:
  alert(event.promise); // エラーを生成した promise
  alert(event.reason);  // 未処理のエラーオブジェクト
});

new Promise(function() {
  throw new Error("Whoops!");
}); // エラーを処理する catch がない

Promise API

Promise クラスには6つの静的メソッドがある。

Promise.all

Promise.all は、並列に複数の promise を実行し、すべて準備できるまで待ちたい時に使う。例えば、同時に複数のURLをダウンロードし、すべて完了したらコンテンツを処理する場合などがある。

構文は次のもの。

let promise = Promise.all(iterable);

通常、引数は promise の配列を取り、戻り値は新しい promise を返す。

次のように、配列として渡したり、

Promise.all([
  new Promise((resolve, reject) => setTimeout(() => resolve(1), 3000)), // 1
  new Promise((resolve, reject) => setTimeout(() => resolve(2), 2000)), // 2
  new Promise((resolve, reject) => setTimeout(() => resolve(3), 1000))  // 3
]).then(alert);

一般的には、処理データ配列を promise の配列にマップし、 Promise.all にラップすることが多いらしい。

let urls = [
  'https://api.github.com/users/iliakan',
  'https://api.github.com/users/remy',
];

// 各 url を promise の fetch(github url) へマップする
let requests = urls.map(url => fetch(url));

// Promise.all はすべてのジョブが解決されるまで待つ
Promise.all(requests)
  .then(responses => responses.forEach(
    response => alert(`${response.url}: ${response.status}`)
  ));

Promise.all に渡された、いずれかの promisereject された場合 Promise.all から即座に reject が返される。reject された エラー内容は、Promise.all の全体の結果となる。

Promise.all では、いずれかの promisereject された段階で、以降の処理は無視される。

Promise.all は、all-or-nothing で、白か黒かといったケースに適当と言える。

Promise.allSettled

Promise.allSettled は結果に関わらず全ての promise が解決するまで待つ。1リクエストが失敗しても、他の結果が欲しいような時に使える。

結果の配列は以下を持つ。

  • 成功したレスポンスの場合: {status:"fulfilled", value:result}
  • エラーの場合: {status:"rejected", reason:error}

次のような感じで使える。

let urls = [
  'https://api.github.com/users/iliakan',
  'https://api.github.com/users/remy',
  'https://no-such-url'
];

Promise.allSettled(urls.map(url => fetch(url)))
  .then(results => {
    results.forEach((result, num) => {
      if (result.status == "fulfilled") {
        alert(`${urls[num]}: ${result.value.status}`);
      }
      if (result.status == "rejected") {
        alert(`${urls[num]}: ${result.reason}`);
      }
    });
  });

Promise.allSettled は最近追加されたもので、古いブラウザでは対応していない。その場合、 Polyfill*1 してしまえばよく、下記のように、比較的簡単に導入できる。

if (!Promise.allSettled) {
  const rejectHandler = reason => ({ status: 'rejected', reason });

  const resolveHandler = value => ({ status: 'fulfilled', value });

  Promise.allSettled = function (promises) {
    const convertedPromises = promises.map(p => Promise.resolve(p).then(resolveHandler, rejectHandler));
    return Promise.all(convertedPromises);
  };
}

Promise.race

Promise.all の最初の結果のみに注目したもの。構文は次のもの。

let promise = Promise.race(iterable);

例えば、次のコードの結果は、1 になる。最初の結果が Promise.race 全体の結果になる。最初以外の結果は無視される。

Promise.race([
  new Promise((resolve, reject) => setTimeout(() => resolve(1), 1000)),
  new Promise((resolve, reject) => setTimeout(() => reject(new Error("Whoops!")), 2000)),
  new Promise((resolve, reject) => setTimeout(() => resolve(3), 3000))
]).then(alert); // 1

Promise.any

Promise.all の最初にレスポンスが成功(fulfilled)した promise のみを持つ。promise 全てが reject された場合、Promise.any が返す promiseAggregateErrorreject を返す。

構文は次のもの。

let promise = Promise.any(iterable);

例えば、次のコードの結果は、2つ目の結果が最初にレスポンス成功するため1 になる。

Promise.any([
  new Promise((resolve, reject) => setTimeout(() => reject(new Error("Whoops!")), 1000)),
  new Promise((resolve, reject) => setTimeout(() => resolve(1), 2000)),
  new Promise((resolve, reject) => setTimeout(() => resolve(3), 3000))
]).then(alert);

Promise.resolve/reject

最近のコードにおいては、async/await 構文によって、滅多に必要とされない。必要となった時に調べるくらいで良い。

Promisification

コールバックを受け付ける関数から Promise を返す関数への変換を行うのが Promisisication

よくわからないので、例で考える。

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);
}

// 使用例:
// loadScript('path/script.js', (err, script) => {...})

loadScript は指定された srcスクリプトを読み込み、エラーの場合は callback(err), 読み込みに成功した場合には callback(null, script) を呼び出す。

こいつを Promise を返すようにしてみる。src を渡し、戻り値で promise を返す。この promise は読み込みが成功すると、 script で resolve し、それ以外はエラーで reject する。

let loadScriptPromise = function(src) {
  return new Promise((resolve, reject) => {
    loadScript(src, (err, script) => {
      if (err) reject(err)
      else resolve(script);
    });
  })
}

// 使用例:
// loadScriptPromise('path/script.js').then(...)

上記の通り、loadScriptPromise は元の関数 loadScript のラッパーになっている。結果を Promise の resolve/reject に変換する独自のコールバックを提供し、呼び出す。

Promise に変換することの旨みみたいなことについてはピンときませんでした😅

Microtasks

Promise.then/ .catch/ .finally は常に非同期で、 Promise がすぐに解決されても、他の Promise 以外のコードが先に実行される。

理由は、非同期タスクは適切な管理が必要なため、実行はキューで管理されている。キューからの取り出しはタスクの実行が他に何も実行されていない時に開始される。

Promise の準備ができると、.then/ .catch/ .finally がキューに入れられる。

例えば次のコードの場合は、

let promise = Promise.resolve();

promise.then(() => alert("promise done!"));

alert("code finished");

code finished が先に実行される。理由は、.then は一度キューに入ってから、他に実行するタスクがなくなった時に実行されるため。つまり、Promise ハンドラは常に内部キューを通る。

この仕組みを知ると、unhandledrejection イベントがどう検知されるかがわかるようになる。 unhandledrejection は『microtask キューの最後で Promise エラーが処理されない場合に発生』となっている。

次の場合、キューには reject と catch が入るので、unhandledrejection は実行されない。

let promise = Promise.reject(new Error("Promise Failed!"));
promise.catch(err => alert('caught'));

// 実行されません: error handled
window.addEventListener('unhandledrejection', event => alert(event.reason));

一方、次の場合はキューへの格納はイベント後(setTimeout)となるため、unhandledrejection 実行時にはキューが空のため、window.addEventListener が実行される。

let promise = Promise.reject(new Error("Promise Failed!"));
setTimeout(() => promise.catch(err => alert('caught')), 1000);

// 実行されます: Error: Promise Failed!
window.addEventListener('unhandledrejection', event => alert(event.reason));

async/await

async は1つの単純なことを意味し、常に promise を返す関数。

例えば次のコードは、戻り値 1 を持つ resolve された promise を返す。

async function f() {
  return 1;
}

f().then(alert); // 1

async 関数の中でのみ使えるキーワードに await がある。awaitpromise が確定し、その結果を返すまで、JavaScript を待機させる。

例えば、次のコードを考える。

async function f() {

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

  let result = await promise; // promise が解決するまで停止 (*)

  alert(result); // "done!"
}

f();

関数の実行は、(*) で一時停止し、 promise が確定した時に再開し、 result がその結果になる。上のコードの場合、1秒後に done が表示される。

await は文字通り、 promise が確定するまで JavaScript を待つ。待っている間、エンジンは他のジョブを実行することができ、CPUリソースを必要としない。promise.then よりも promise の結果を得るために読みやすく書きやすい構文になっている。

また、複数 promise を待つような場合には、Promise.all と相性がいい。次のように書け理解しやすい。

// 結果の配列をまつ
let results = await Promise.all([
  fetch(url1),
  fetch(url2),
  ...
]);

promisereject の場合はエラーをスローし、throw 文があるかのように振舞う。エラー自体は、 try-catch で処理できる。

async function f() {

  try {
    let response = await fetch('http://no-such-url');
  } catch(err) {
    alert(err); // TypeError: failed to fetch
  }
}

f();

参考

*1:ポリフィルとは、最近の機能をサポートしていない古いブラウザーで、その機能を使えるようにするためのコード