JavaScriptの非同期処理(コールバック地獄/Promise/Async関数)
JavaScriptで非同期処理が重要な理由
シングルスレッド
JavaScriptはシングルスレッドで動作する。そのため一度に実行できるタスクは1つだけとなる。 JavaScriptは、レイアウト・再フロー・ガベージコレクションなどと同じスレッドで実行される。
そのため、JavaScript関数がスレッドを占有すると、ページの反応が悪くなるという問題が発生する。 この問題を非同期関数を用いて緩和する。
参考
非同期関数
非同期処理はコードを順番に処理するが、1つの非同期処理が終わるのを待たずに次の処理を行う。 これにより複数の処理を並列に実行している。
イベントループ
JavaScriptエンジン(v8など)は、非同期関数をイベントループを用いて実行する。 JavaScriptエンジンは、主に以下の3つによって構成されている。
Javascriptエンジン
- ヒープ領域:動的に確保と解放を繰り返せるメモリ領域
- コールスタック:LIFOで呼び出された関数を保存する領域。格納された関数は順次処理される。
WebAPIs
ブラウザに搭載されている各種API(DOM, Ajax, timerなど)
イベントキュー/タスクキュー
FIFOで、Web APIから受け取ったCallback関数を保存する
イベントループは以下の流れで非同期処理を実現する。
- コールスタックとイベントキューを監視し、コールスタックが空になったら、イベントキューの作業を順番にコールスタックに移動させる。
- JavaScriptがメモリ上に展開され、コールスタックで実行される。
- Web APIsから提供されているAPIを呼び出すと、Web APIsの実行環境で処理が実行する。
- 非同期関数の呼び出しの場合、Web APIsの実行環境内で、条件を満たすまで待機し、条件を満たすとイベントキューに格納される。
参考
- 非同期処理 (1):Javascriptの動作の流れ (JS エンジン/Call Stack/Event Queue) | Zenn
- JavaScriptのイベントループを理解する | Qiita
- JavaScriptがブラウザでどのように動くのか | mercari engineering
非同期関数の例
たとえば、処理を一時停止させる場合はsetTimeout関数を利用して実現する方法がある。
// setTimeout('コールバック関数', 'タイムアウト時間')
function callback(){
console.log('test')
}
非同期処理が複数重なると、コールバック地獄になり、ネストが深く処理が追いづらくなる。
setTimeout(callback, 1)
setTimeout(() => {
console.log(1)
setTimeout(() => {
console.log(2)
setTimeout(() => {
console.log(3)
}, 3)
}, 2)
}, 1)
この問題をPromiseを用いることで緩和する。
参考
- setTimeout()-WebAPI | MDN
- 【JS】setTimeoutを用いた、非同期処理入門 | Qiita
- JavaScriptとコールバック地獄 | Yahoo! JAPAN Tech Blog
- とほほのPromise入門
- [JavaScript]sleep(setTimeout)をPromise化する | DevelopersIO
Promiseオブジェクト[ES2015]
PromiseはES2015で導入された非同期処理の状態や結果を表現するビルトインオブジェクトである。 非同期処理はPromiseのインスタンスを返し、そのPromiseインスタンスには状態変化をした際に呼び出されるコールバック関数を登録できる。
使い方
// asyncPromiseTask関数は、Promiseインスタンスを返す
function asyncPromiseTask() {
return new Promise((resolve, reject) => {
// ここが非同期処理
// 成功時はresolve関数を呼ぶ
// 失敗時はrejectを関数呼ぶ
});
}
// thenメソッドで、Promiseがresolve(成功)、reject(失敗)したときに呼ばれるコールバック関数を登録できる。
asyncPromiseTask().then(()=> {
// 成功したときの処理
}).catch(() => {
// 失敗したときの処理
});
非同期関数では関数を実行してもすぐには結果がわからない。 そのため、Promiseという非同期処理の状態をラップしたオブジェクトを返し、その結果が決まったら登録しておいたコールバック関数へ結果を渡すという仕組みとなっている。
引数の省略
Promiseのthenメソッドは成功と失敗のコールバック関数の2つを受け取るが、どちらの引数も省略できる。
成功のみのパターン
// `then`メソッドで成功時のコールバック関数だけを登録
asyncPromiseTask().then(() => {
console.log("成功時のコールバック");
});
失敗のみのパターン
// 非推奨: `then`メソッドで失敗時のコールバック関数だけを登録
errorPromise("thenでエラーハンドリング").then(undefined, (error) => {
console.log(error.message);
});
// 推奨: `catch`メソッドで失敗時のコールバック関数を登録
errorPromise("catchでエラーハンドリング").catch(error => {
console.log(error.message);
});
Promiseの状態
Promiseインスタンスには、内部的に次の3つの状態が存在する。
- Fulfilled:成功したときの状態
- Rejected:失敗または例外が発生した状態
- Pending:FulfilledまたはRejectedではない状態
一度でもFulfilledかRejectedとなったPromiseインスタンスは、それ以降別の状態には変化しない。 そのため、以下のような挙動となる。
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
// 非同期でresolveする
resolve();
// すでにresolveされているため無視される
reject(new Error("エラー"));
// 二度目以降のresolveやrejectは無視される
resolve();
}, 16);
});
promise.then(() => {
console.log("Fulfilledとなった");
}, (error) => {
// この行は呼び出されない
});
Promiseチェーン
複数の非同期処理を順番に扱いたい場合に利用するのがPromiseチェーンである。 thenやcatchメソッドは常に新しいPromiseインスタンスを返すため、thenメソッドの返り値にさらにthenメソッドで処理を登録できる。
// Promiseインスタンスでメソッドチェーン
Promise.resolve()
// thenメソッドは新しい`Promise`インスタンスを返す
.then(() => {
console.log(1);
})
.then(() => {
console.log(2);
});
Promiseの問題点
Promiseには以下のような問題点が存在する。
- 非同期処理間の連携をするにはPromiseチェーンのように少し特殊な書き方や見た目となる。
- エラーハンドリングでは、catchメソッドやfinallyメソッドなどtry…catch構文とよく似た名前を使う。
- Promiseは構文ではなくただのオブジェクトであるため、メソッドチェーンとして実現しないといけない。
この問題をAsync関数を利用することで緩和する。
参考
- Promise/async/await|サバイバルTypeScript
- Promise|TypeScript Deep Dive 日本語版
- プロミスの使用 | MDN
- イベントループとTypeScriptの型から理解する非同期処理 | Zenn
Async関数[ES2017]
Async関数は必ずPromiseインスタンスを返す関数である。関数の前にasyncをつけることで定義できる。
async function doAsync() {
return "値";
}
// 以下と同等の意味
function doAsync() {
return Promise.resolve("値");
}
await式
Async関数内ではawait式というPromiseの非同期処理が完了する(FulfilledになるかRejectedになる)まで待つ構文が利用できる。 await式を使うことで非同期処理を同期処理のように扱えるため、Promiseチェーンで実現していた処理の流れを読みやすく書ける。
async function asyncMain() {
// PromiseがFulfilledまたはRejectedとなるまで待つ
await Promiseインスタンス;
// Promiseインスタンスの状態が変わったら処理を再開する
}
await式がエラーをthrowするため、try catchが使えるようになる。
async function asyncMain() {
try {
const value = await Promise.reject(new Error("エラーメッセージ"));
} catch (error) {
console.log(error.message);
}
}