コールバック関数
非同期処理を学ぶ前に、まずはコールバック関数について復習。
コールバック関数とは
MDN Web Docsによると次のように定義されている。
コールバック関数とは、引数として他の関数に渡され、外側の関数の中で呼び出されて、何らかのルーチンやアクションを完了させる関数のこと
コールバックには、すぐに実行される同期型コールバックと、非同期操作が完了した後に実行される非同期型コールバックがある。例えば次のコードがあるとする。
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
に渡され、loadScript
の onload
プロパティに対してイベントハンドラとして設定し、非同期処理を実現している。
コールバック地獄、破滅のピラミッド
コールバックはネストさせることができる。
上記のようなコードは、サイクロマティック複雑度(循環的複雑度)が高くなる。一般的に複雑度が高いほどメンテナンス性が悪くなるので、上記のような記述は避ける。
避けるために、次のように記述することもできる。
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 によって提供されるコールバックであり、次の意味を持つ。
new Promise
で生成される Promise オブジェクトは状態を持っている。
- 初期状態(
pending
):初期状態。成功でも失敗でもない - 履行(
fulfilled
):処理が成功 - 拒否(
rejected
):処理が失敗。
ジョブが完了した時に resolve
が呼ばれ、エラーした時に reject
が呼ばれる。
例えば、次のようなコードを考える。
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
はエラーのみに関心がある時に使用し、finally
は try{...}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; });
実行の流れは、次のようになっている。
- 最初の promise は1秒で解決 (*)
- その後、.then ハンドラが呼ばれる (**)
- 返却された値は、次の .then ハンドラへ (***)
- …以下繰り返し
図で示すと、次の順序で実行されている。
これは、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; });
上記は、次のように解釈される。
.then
の中にさらにハンドラがあるような場合、外側のハンドラは、その promise
が完了するまで待ち、その結果を取得する。
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
に渡された、いずれかの promise
が reject
された場合 Promise.all
から即座に reject
が返される。reject
された エラー内容は、Promise.all
の全体の結果となる。
Promise.all
では、いずれかの promise
が reject
された段階で、以降の処理は無視される。
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
が返す promise
は AggregateError
の reject
を返す。
構文は次のもの。
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
がある。await
は promise
が確定し、その結果を返すまで、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), ... ]);
promise
が reject の場合はエラーをスローし、throw
文があるかのように振舞う。エラー自体は、 try-catch
で処理できる。
async function f() { try { let response = await fetch('http://no-such-url'); } catch(err) { alert(err); // TypeError: failed to fetch } } f();