【JavaScript】非同期処理 まとめ
JavaScriptの躓きポイントの代表格である非同期処理(Promise
や async/await
)について解説します。
非同期処理とは
非同期処理とは、あるタスクを実行をしている際に、他のタスクが別の処理を実行できる方式をいいます。
一般的に、データベースから値を取得する等、通信を伴う処理は通信状況によって取得まで時間がかかったり、値が返ってくる保証がない等、時間がかかる処理とされています。
このような時間のかかる処理を、シングルスレッドで実行すると、実行中は他のタスクを行うことができなくなります。
例えば、データベースから値を取得する処理が走った後に、ユーザがページスクロールをしたとします。その場合、DBから値を取得し終わるまで、ページはスクロールされず、ユーザからすると画面がフリーズしたと思われるかもしれません。
このような事態を防ぐため、非同期処理が存在します。
先ほどのページスクロールの例を図に表すと以下のようになります。
同期処理の場合は、ページスクロールをしてから実際にスクロールされるまでに待ち時間が発生していましたが、非同期処理ではDBから値を取得するタスクを一時中断して先にページスクロールを完了させた後、再度DBから値取得を行っています。
シングルスレッドでこれを実現させるには、何らかの非同期処理がきた場合は、その処理の完了を待たずに、次のタスクを実行する必要があります。
完了を待っていたら割込みタスクを実行できませんからね。
JavaScriptで非同期処理を実装する上で、非同期処理は処理の完了を待たないという特徴がとても大切になってくるのでここで覚えておきましょう。
JavaScript の非同期処理について
JavaScriptは歴史のある言語で、いくつものアップデートがされてきました。中でも、ES2015というバージョンからは大きな変化があり、非同期処理もまたES2015以降から新たな書き方が追加され、2017年にはさらに便利な非同期処理の書き方が追加されています。
ここでは、ES2015で追加されたPromise
、ES2017で追加されたasyn/await
について解説していきます。
※JavaScriptの歴史について調べると、沼にハマることができるので、お時間のある方は一度沼にハマってみると面白いかもしれません。
Promise
Promise の基本理解
Promiseとは、非同期処理を行うためのものです。
まず、Promiseには以下3つの状態があります。
-
pending
:非同期処理の実行中の状態を表す -
fulfilled
:非同期処理が正常終了した状態を表す -
rejected
:非同期処理が異常終了した状態を表す
取り合えずサンプルコードを実行してこれらの存在を確認してみましょう。
まずはPromise
の基本構文から。Promiseは以下のような式になります。
new Promise(function (resolve, reject){
// 非同期処理
})
Promise()
内のコールバック関数の引数にはそれぞれ、resolve
、reject
が渡ります。
引数はそれぞれ以下の役割を持ちます。
-
resolve
:非同期処理が正常終了したことを知らせるメソッド。return
の代わりに、resolve()
と記述することで、非同期関数が正常終了したことを知らせる。 -
reject
:非同期処理が異常終了したことを知らせるメソッド。return
の代わりに、reject()
と記述することで、非同期関数が異常終了したことを知らせる。
上記ソースコード(Promiseの基本構文)をJavaScriptのコンソールで実行すると以下のような結果が得られます。
非同期処理の実行中を表す、pending
が表示されました。
これは、非同期処理が正常終了したことを表すresolve
と異常終了したことを表すreject
のいずれも呼び出されていないため、非同期処理が完了していない、ということです。
そのため、非同期処理を呼び出す場合は必ず非同期処理が終了したことを教える必要があります。
非同期処理が完了したことを知らせるためには、// 非同期処理
で処理が正常終了したら、return
の代わりにresolve()
を実行し、異常終了した場合はreturn
の代わりにreject()
を実行します。
また、処理が成功したか失敗したかを検知できるように、try, catch
で処理を囲みます。
new Promise(function (resolve, reject){
try {
// 非同期処理
// returnの代わりに正常終了したことを表すresolveを呼び出して、fulfilledを返す
resolve();
} catch (e) {
// 異常終了時の処理
// returnの代わりに異常終了したことを表すrejectを呼び出して、rejectedを返す
reject();
}
})
上記ソースコードをJavaScriptのコンソールで実行すると以下のような結果が得られます。
非同期処理が正常終了した状態を表すfulfilled
が表示されました。
では、異常終了させた場合はどうなるでしょうか。
new Promise(function (resolve, reject){
try {
// 非同期処理
// 異常終了させる
throw "異常終了";
// returnの代わりに正常終了したことを表すresolveを呼び出して、fulfilledを返す
resolve();
} catch (e) {
// 異常終了時の処理
// returnの代わりに異常終了したことを表すrejectを呼び出して、rejectedを返す
reject();
}
})
非同期処理が異常終了した状態を表すrejected
が表示されました。
実際に非同期処理を走らせてみる
先ほど、非同期処理は処理の完了を待たない、ということをお伝えしました。
Promise
は非同期処理のため、new Promise
内のコールバック関数の処理が完了する前に、その後の処理が終了してしまうことになります。
こちらもサンプルコードで処理の流れを確認してみましょう。
new Promise(function (resolve, reject){
try {
// 1秒後に"非同期処理"とコンソールに出力
setTimeout(()=>{
console.log("非同期処理")
// returnの代わりに正常終了したことを表すresolveを呼び出して、fulfilledを返す
resolve()
}, 1000)
} catch (e) {
// 異常終了時の処理
// returnの代わりに異常終了したことを表すrejectを呼び出して、rejectedを返す
reject()
}
})
上記コードを実行すると以下のような実行結果になります。
setTimeout
内でresolve()
を呼び出しているにもかかわらず、ログにはpending
と表示され、その後"非同期処理"
のログが出力されました。
これが非同期処理は処理の完了を待たないという特徴です。非同期処理内で処理の完了を伝えるresolve()
やreject()
を呼んだとしても、それを待つことなく次の処理に進んでしまいます。
非同期処理の特徴を理解したところで、次は処理の完了の待ち方についてみていきます。
非同期処理の完了を待つには
それでは、どのようにして非同期処理の完了を待てば良いのかについて解説していきます。
Promise
に限らず、JavaScriptの非同期処理は、その戻り値に対してthen
メソッドとcatch
メソッドが利用できます。
それぞれの意味は以下の通りです。
-
then()
:非同期処理が正常終了した際に呼ばれるメソッド -
catch()
:非同期処理が異常終了した際に呼ばれるメソッド
これをみると、先ほど確認したPromise
の3つの状態との関係性が分かると思います。
resolve
は非同期処理が正常終了したことを表すもので、then()
は非同期処理が正常終了した際に呼ばれるメソッド(関数)です。
つまり、非同期処理が処理中(pending
)から、正常終了(resolve
)に変化したら、続いてthen()
が呼ばれるというです。
catch()
はその逆で、非同期処理が処理中(pending
)から、異常終了(reject
)に変化したら、呼ばれるということになります。
こちらもサンプルコードで処理の流れを確認してみましょう。
function asyncFunction() {
return new Promise((resolve, reject) => {
try {
// 1秒後に"非同期処理"とコンソールに出力
setTimeout(() => {
console.log("非同期処理")
// returnの代わりに正常終了したことを表すresolveを呼び出して、fulfilledを返す
resolve();
}, 1000)
} catch (e) {
// 異常終了時の処理
// returnの代わりに異常終了したことを表すrejectを呼び出して、rejectedを返す
reject();
}
})
}
asyncFunction().then(() => {
console.log("resolve後の処理");
}).catch(e => {
console.log("reject後の処理");
})
上記コードを実行すると以下のような実行結果になります。
setTimeout
内の"非同期処理"
というログ出力のあとにthen()
メソッド内の"resolve後の処理"
というログが出力されていることがわかります。
このように、非同期処理の完了後に他の処理を行いたい場合は非同期処理.then().catch()
といった形に、メソッドチェーンでthen()
とcatch()
を続けてやればOKです。
非同期で処理した後に、その値を取得する
非同期処理の完了の待ち方はわかりましたが、非同期処理中に取得した値を活用したい場合はどうすればよいかについて解説していきます。
実はthen()
メソッドとcatch()
メソッドはそれぞれ引数を取ることができます。
then()
とcatch()
に値を渡すには、処理を完了したことを知らせるresolve()
とreject()
で引数を渡すことで、任意の値を送ることができます。
これもサンプルコードを見てみましょう。
function asyncFunction() {
return new Promise((resolve, reject) => {
try {
// 1秒後に"非同期処理"とコンソールに出力
setTimeout(() => {
console.log("非同期処理")
const num = 1
resolve(num);
}, 1000)
} catch (e) {
// 異常終了時の処理
// returnの代わりに異常終了したことを表すrejectを呼び出して、rejectedを返す
reject(e);
}
})
}
asyncFunction().then((num) => {
console.log(`引数で受け取った値:${num}`);
}).catch(e => {
console.log(`引数で受け取った値:${e}`);
})
上記コードを実行すると以下のような実行結果になります。
このように、処理の完了をお知らせするついでに引数に渡したいものを入れることで、then()
やcatch()
で受け取ることができます。
以上がPromise
の基本的な使い方になります。
ここまでで、理解できていない部分がある方は、先に進まずにゆっくりとPromise
について理解をしてください。
async / await
async / await の基本理解
それでは、続いてES2017で導入されたasync / await
についてみていきましょう。
といっても、Promise
を理解したら、async / await
の理解はそんなに難しくありません。
なぜなら、async / await
はPromise
の糖衣構文だからです。
糖衣構文とは、
プログラミング言語において、読み書きのしやすさのために導入される書き方であり、複雑でわかりにくい書き方と全く同じ意味になるものを、よりシンプルでわかりやすい書き方で書くことができるもののこと
つまり、Promise
を簡単に書けるようにしたものが、async / await
ということです。
async
まずはasync
についてみていきましょう。
async
は、非同期関数を作るものです。基本構文は以下の通りです。
function
宣言の前にasync
とつけるだけです。
async function asyncFunction() {}
async function
の特徴は以下の通りです。
-
async function
は呼び出されるとPromise
を返す。 async function
が値をreturn
した場合はその値をresolve
する。async function
が例外をthrow
した場合はその値をreject
する。
ここでPromiseを返す???と思った方、Promiseとは3つの状態を持つオブジェクトのことでしたね。
pending
:非同期処理の実行中の状態を表すfulfilled
:非同期処理が正常終了した状態を表すreject
:非同期処理が異常終了した状態を表す
実行したらpending
とか出力していたあれのことです。
ここで、Promise
とasync function
でどのように書き方が異なるのか、サンプルコードで見てみましょう。
function promiseFunction() {
return new Promise((resolve, reject) => {
try {
resolve("resolve")
} catch (e) {
reject("reject")
}
})
}
// アロー関数で書くとこうなります
// const asyncFunction = async () => {
async function asyncFunction() {
try {
return "resolve"
} catch (e) {
throw "reject"
}
}
どうでしょうか。async function
の方がreturn new Promise()
がいらなくなったので、見た目がスッキリしていると思います。
Promise
がなくなったことで、resolve
、reject
が引数に取れなくなったので、return
とthrow
がその代わりとなっていますね。
await
それでは、続いてawait
についてみていきましょう。
await
は非同期処理の完了を待つためのものです。
もう少し具体的に説明すると、Promise
の結果(resolve
もしくはreject
)が返されるまで待機する(処理を一時停止する)演算子のことです。
ただし、ここで重要な注意点として await
はasync function
の中でしか使えない、という制約があります。
こちらもPromiseとの比較をみていきましょう。
function promiseFunction() {
return new Promise((resolve, reject) => {
try {
resolve("resolve")
} catch (e) {
reject("reject")
}
})
}
async function asyncFunction() {
try {
return "resolve"
} catch (e) {
throw "reject"
}
}
promiseFunction().then(txt => {
console.log(txt);
}).catch(e => {
console.error(e);
})
async function main() {
const txt = await asyncFunction()
console.log(txt);
}
main()
少し比較しづらくなりましたが、await
はthen()
とcatch()
を置き換えている、ということがわかりますね。
非同期関数に対してawait
を使うことでresolve()
やreject()
で返される値を待つことができます。
async / await の動作確認でよくやるミス
async / await
を理解しようとサンプルコードを書いているときに、よくある(?)躓きポイントです。ここで引っかかると非同期処理がよくわからなくなると思ったので載せておきます。
まずは該当のサンプルコードをご覧ください。
async function asyncFunction() {
try {
setTimeout(()=>{
return "resolve"
}, 1000)
} catch (e) {
throw "reject"
}
}
async function main() {
const txt = await asyncFunction()
console.log(txt);
}
main()
async / await
はPromise
の糖衣構文なのだから、これでOKでしょ!と思って実行すると
console.log
の結果がundefind
となってしまいました。
await
は非同期処理に対して、resolve
かreject
が返されるまで待ってくれるはずなのに、このサンプルコードでは待ってくれませんでした。
一体どうしてでしょうか。
これは、JavaScriptのコールバック関数に関する話が絡んできます。
setTimeout
は引数にコールバック関数をとり、その中身を第2引数で指定したミリ秒後に実行します。
setTimeout(コールバック関数, 遅らせるミリ秒)
つまり、上記コードでsetTimeout
のコールバック関数はasync function
ではなく、通常の関数になっているため、return
はresolve
ではなく、通常の関数のreturn
になってしまっているのです。
じゃあ、setTimeout(async () => {})
とすればいいじゃないか、と思う方もいらっしゃるかもしれません。
async function asyncFunction() {
try {
setTimeout(async ()=>{
return "resolve"
}, 1000)
} catch (e) {
throw "reject"
}
}
async function main() {
const txt = await asyncFunction()
console.log(txt);
}
main()
しかし、これもまたNGなコード例です。
何故かというと、setTimeout
のコールバック関数を非同期関数にし、その中でreturn
すなわちresolve
をしても、その戻り値の送り先はsetTimeout
を呼び出している関数自身になります。(今回の場合はasyncFunction
が該当します)
そのため、asyncFunction
はsetTimeout
からの戻り値を何かしらの形(変数など)で受け取っていないので、結果としてresolve
は消失してしまっているのです。
それならば、return setTimeout
にすればいいじゃないか!
こういうことですね。
async function asyncFunction() {
try {
return setTimeout(async ()=>{
return "resolve"
}, 1000)
} catch (e) {
throw "reject"
}
}
async function main() {
const txt = await asyncFunction()
console.log(txt);
}
main()
残念ながらこれもまたNGな例です。
setTimeout
メソッドは戻り値としてtimerId
を返します。このtimerId
はclearTimeout
メソッドへ渡すことで、タイムアウトを取り消すことができるものです。
参考:MDN - SetTimeout
そのため、setTimeout
のコールバック関数内でいくらreturn
をしたところで、そのreturn
はどこへ渡るでもなく消えてしまうのです。
つまり、async / await
の検証で処理を遅らせるために、setTimeout()
を用いることはできない、ということです。
一方でPromise
ではresolve
で戻り値を返すので、関数のreturn
と認識されずに済む、という訳です。
以上、JavaScriptのややこしい小話でした。
then, catch, await
ここで、then()
とcatch()
はPromise
に対してのみ使えて、await
はasync
に対してのみ使えるのか?と疑問に思う方が居るかもしれません。
async / await
はPromise
の糖衣構文という事からも想像ができますが、お互いに利用可能です。
こちらもサンプルコードで例をみていきましょう。
function promiseFunction() {
return new Promise((resolve, reject) => {
try {
resolve("resolve")
} catch (e) {
reject("reject")
}
})
}
async function asyncFunction() {
try {
return "resolve"
} catch (e) {
throw "reject"
}
}
// promiseFunction() から asyncFunction()に入れ替え
asyncFunction().then(txt => {
console.log(txt);
}).catch(e => {
console.error(e);
})
async function main() {
// asyncFunction() から promiseFunction()に入れ替え
const txt = await promiseFunction()
console.log(txt);
}
main()
このように、先ほどのサンプルコードを入れ替えたものでも無事に動くことが確認できます。
以上がasync / await
がPromise
の糖衣構文というお話でした。
極論JavaScriptで非同期処理を扱いたい場合は、Promise
かasync / await
のどちらかが使えれば問題ないということです。
ここまで覚えていればJavaScriptにおける非同期処理は問題なく利用することができますが、さらに余力のある方は次に説明する非同期処理を並列で行うPromise.all
についても理解しておくと、よりスッキリとしたコードが書けると思います。
非同期処理を並列で行う Promise.all について
Promise.all()
メソッドはPromise
オブジェクトの配列を受け取り、全てのPromise
オブジェクトがresolve
されたタイミングでthen
が呼び出されます。
Promise.all の基本構文
Promise.allは、上記でも説明した通り、配列内の全てのオブジェクトがresolve
されたタイミングでthen
メソッドが呼ばれます。
Promise.all([taskA, taskB]).then(() => {})
こちらもサンプルコードをみてみましょう。
function promiseFunction() {
return new Promise((resolve, reject) => {
try {
setTimeout(() => {
resolve("resolve")
}, 1000)
} catch (e) {
reject("reject")
}
})
}
async function asyncFunction() {
try {
return "resolve"
} catch (e) {
throw "reject"
}
}
const asyncTaskA = promiseFunction()
const asyncTaskB = asyncFunction()
Promise.all([asyncTaskA, asyncTaskB]).then(() => {
console.log(`全てのタスク完了!`);
})
このように、全ての非同期処理が完了するまで待機することができるので、いくつかのDBから値をとってきて、両方の値を利用して処理Aを行う、といったシチュエーションでとても役立つメソッドです。
ここで注意すべき点として、いずれかの非同期処理が1つでもreject
してしまうと、Promise.all
のthen
が呼び出されない、という点です。
こちらもサンプルコードをみていきましょう。
function promiseFunction() {
return new Promise((resolve, reject) => {
try {
setTimeout(() => {
throw "意図的なエラー"
resolve("resolve")
}, 1000)
} catch (e) {
reject("reject")
}
})
}
async function asyncFunction() {
try {
return "resolve"
} catch (e) {
throw "reject"
}
}
const asyncTaskA = promiseFunction()
const asyncTaskB = asyncFunction()
Promise.all([asyncTaskA, asyncTaskB]).then(() => {
console.log(`全てのタスク完了!`);
})
このように、いずれかの非同期処理が失敗すると、Promise.all
のthen
が呼ばれずに、"全てのタスク完了!"
というログが出力されていないことがわかります。
これはこれでありがたい仕様ですが、たとえ1つがreject
されても続行してほしい!というシチュエーションもあるかと思います。
そんな時に役立つのがES2020で導入されたPromise.allSettled()
です。
Promise.allSettled
Promise.allSettled
はPromise.all
と比べて、いずれかのタスクがreject
を起こしても、その後のthen
が呼ばれるメソッドです。
こちらもサンプルコードをみていきましょう。
function promiseFunction() {
return new Promise((resolve, reject) => {
reject("reject")
})
}
async function asyncFunction() {
try {
return "resolve"
} catch (e) {
throw "reject"
}
}
const asyncTaskA = promiseFunction()
const asyncTaskB = asyncFunction()
Promise.allSettled([asyncTaskA, asyncTaskB]).then(() => {
console.log(`全てのタスク完了!`);
})
このように、promiseFunction()
がreject
を返したとしても、"全てのタスク完了!"
というログが出力されていることがわかります。
Promise.race
Promise.race
は2つ以上ののPromise
のうちの1つがresolve
またはreject
するとすぐに、そのPromise
の値または理由で解決または拒否する Promise を返します。
const promise1 = new Promise((resolve, reject) => {
setTimeout(resolve, 500, 'one');
});
const promise2 = new Promise((resolve, reject) => {
setTimeout(resolve, 100, 'two');
});
Promise.race([promise1, promise2]).then((value) => {
console.log(value);
});
このように、Promise
の中で最初に解決されたpromise2
で返されている値がログが出力されていることがわかります。
Promise.any
続いてES2021で追加されたPromise.any
です。
Promise.rece
では最初にresolve
もしくはreject
された値を返しますが、Promise.any
では最初にresolve
された値を返します。
const pErr = new Promise((resolve, reject) => {
reject("Always fails");
});
const pSlow = new Promise((resolve, reject) => {
setTimeout(resolve, 500, "Done eventually");
});
const pFast = new Promise((resolve, reject) => {
setTimeout(resolve, 100, "Done quick");
});
Promise.any([pErr, pSlow, pFast]).then((value) => {
console.log(value);
})
このように、最初にreject("Always fails");
が完了しますが、Promise.any
では最初にresolve
された値を返すので、Done quick
が返されています。
以上、JavaScriptの非同期処理についての解説でした。
Discussion
ぶ厚いJavaScript参考書を読んだり、有料のWEB記事を読んでも非同期通信・Promiseについて全然理解できなかった人間です。
ですが、この記事はとてもわかりやすくて理解できました。
ありがとうございます。
嬉しいお言葉ありがとうございます!
お役に立てたようで何よりです😆