TypeScriptの非同期処理とは何か・Promiseの基本的な使い方をまとめてみる【then / catch / finally / async / await】
目次
こんにちは。
今回はTypeScriptの基礎シリーズとして「非同期処理とは何か」についてまとめてみようと思います。
Promise
やasync
とawait
構文の使い方についても触れます。
非同期処理について苦手意識を持たれている方の理解の一助になれば幸いです。
ちなみに非同期処理はTypeScript特有の機能ではなく、JavaScriptでも使える内容となっています。
非同期処理とは
通常のプログラムの実行とは別に実行させておきたい「時間のかかる処理」のこと。
たとえばタイマー処理、データベースとの通信処理、ファイルの読み書き処理などです。
通常TypeScriptではプログラム文が上から下へ処理が実行されていきます。これを非同期処理に対して「同期処理」と言います。非同期処理の部分は結果が返ってくるのを待たずにスルーされて、一旦同期処理が最後まで実行されます。その後、非同期処理の結果が返ってきた時点で、非同期処理の部分が実行されます。
コールバック関数
非同期処理におけるコールバック関数は、「非同期処理が終わったら呼び出される関数」のことです。通常、コールバック関数は「関数に渡される引数としての関数」のことです。非同期処理では、同期処理の後にプログラムの途中に書いた関数が「呼び戻される」ので、コールバックというのだと解釈するとわかりやすいです。
有名なものにはタイマーやイベントが該当します。以下はタイマーで非同期処理が行われ、コールバック関数が実行される例です。
setTimeout(()=>{
console.log("Called!")
},5000 // ミリ秒単位
)
console.log("Hello.")
これを実行すると、コンソールには先にHello.
が表示され、その5秒後くらいにCalled!
と表示されます。これはsetTimeout()
が非同期処理になっていて、5000ミリ秒後に実行されるコールバック関数だからです。非同期処理は一旦中身がスルーされて同期処理が先に実行されるので、Hello.
が先に表示されます。
Promise
ES6で追加された非同期処理の機能。ES6が何かわからない方はこちらの記事を参照。
Promiseを使う非同期処理の関数はPrimise
オブジェクトを返します。非同期処理の結果が返ってくると、その結果はPromiseオブジェクトからthen
メソッドで取り出せるようになります。
つまり、従来のコールバック関数が、「Promiseオブジェクトを返す」「コールバック関数を書いてPromiseから結果を取り出す」という2段階にコードが分割されます。これによりコードの見通しが良くなるだけでなく、Promiseオブジェクトに備わっている様々な便利機能を使うことで非同期処理が実装しやすくなります。
先程のタイマーの例をPromiseを使って書くと、このように実装できます。
const p = new Promise(()=>{
setTimeout(()=>{
console.log("called.")
},5000)
})
p.then()
console.log('Hello.')
Hello.
の後にcalled.
と表示されることがわかります。
次のように書くことも可能です。
const p = new Promise<string>((resolve)=>{
setTimeout(() => {
resolve("Called!")
},2000)
})
p.then((result) => console.log(result))
console.log('Hello.')
Promise<>
に指定する引数(型)は非同期処理の結果の型です。ここではCalled!
なのでstring
です。`
Promise()
の引数はexecutor
で、これは関数()=>()
です。非同期処理のコールバック関数です。
このexecutor
の引数であるresolve
に渡される引数が、Promiseの結果です。ここではCalled!
という文字列が、Primiseの結果、つまり非同期処理の結果となります。後の.then()
メソッドはこの非同期処理の結果を受け取り、引数の関数を実行します。ここでは、()=>console.log()
という関数を実行しています。
参考 : MDN web docs | Promise() コンストラクター
参考リンクを見ると分かる通り、executor
には2つめの引数である関数reject
があります。reject
関数を呼び出した場合、Promiseは失敗してエラーとしてreject
の引数を返します。
const useResolve = false
const p = new Promise<string>((resolve,reject)=>{
setTimeout(() => {
useResolve
?resolve("Called!")
:reject("Rejected!") // useResolveがfalseなのでこちらが呼ばれる
},2000)
})
p.then((result) => console.log(result))
// Error : Uncaught (in promise) Rejected!
Promise.all
Promise.all
メソッドは、引数に複数のPromiseオブジェクトの配列をとり、「すべて成功したら成功」となるPromiseオブジェクトを作ることができます。
const p1 = new Promise<string>((resolve)=>{
setTimeout(() => {
resolve("first")},1000)
})
const p2 = new Promise<string>((resolve)=>{
setTimeout(() => {
resolve("second")},2000)
})
const p3 = new Promise<string>((resolve)=>{
setTimeout(() => {
resolve("third")},3000)
})
const pAll = Promise.all([p1,p2,p3])
pAll.then(([res1,res2,res3])=>{
console.log(res1)
console.log(res2)
console.log(res3)
}) // 1秒ごとではなく、p1,p2,p3のすべての結果が揃った時点で出力される
p1
、p2
、p3
という3つのPromiseオブジェクトを作ってみました。これら3つに対し.then()
を書くと1秒ごとにコンソールに結果が表示されますが、Promise.All()
を使うことですべて完了した際に結果を取り出すことが実現できます。ちなみに.then()
ではresults
を引数にとる際、resX
に分割代入して取り出しています。
逆に、どれか1つでも失敗した時点でPromise.All
の結果は失敗となり、エラーオブジェクトは最初に失敗したPromiseオブジェクトのエラーと同じになります。
Promise.allSettled
all
同様Promiseオブジェクトの配列を引数にとり、結果が揃った時点で結果を返します。ただしこちらは、成功オブジェクトは成功の結果を、失敗オブジェクトは失敗の結果を得ることができます。
Promise.race
Promise.race()
はall
のようにPromiseオブジェクトの配列を引数にとり、最初に得た結果を結果とします。
先程all
で使ったのと同じPrimiseオブジェクト配列を引数に取ると、結果は当然first
が最初に得られるので、first
が出力されます。
const pRace = Promise.race([p1,p2,p3])
pRace.then((result)=>{
console.log(`fastest: ${result}`) // first と出力される
})
このようにsecond
とthird
の結果が揃う前に非同期処理の結果を得ることができます。ただし、あくまでrace
の結果が得られるというだけで、裏ではsecond
とthird
の結果を待っています。すべての非同期処理が中断されるわけではありません。
また、「一番最初に結果を得たオブジェクトの結果となる」とは、成功・失敗を問いません。あるPromiseオブジェクトが最初に失敗した場合は、失敗という結果が返ってきます。
Promise.any
race
が成功・失敗を問わないのに対し、成功のみを対象としたい場合に使います。つまり、「一番最初に成功したPromiseオブジェクトの結果を結果とする」のがPromise.any()
です。
失敗はスルーされて他の結果を待ちます。すべて失敗した場合はErrorオブジェクトを返します。
Promiseオブジェクトのメソッド
上記のall
などはPromise.all
などのようにPromise
自体につけて呼び出すメソッドでした。クラスでいう静的メソッドです。静的メソッドについてはこちらの記事で解説しています。
一方、.then()
はPromise.prototype
のメソッド、つまりPromiseオブジェクトを格納した変数(インスタンス)から呼び出すメソッドです。このようなものは他にcatch
とfinally
があります。
これらのメソッドはPromiseオブジェクトを返すため、p.finally().catch().try()
というようにチェーンして書くことができます。
then
すでに何回も出てきていますが、引数に関数を渡すことで、Primiseの非同期処理が成功したときに呼び出されるコールバック関数を書くことができます。
2つめの引数に失敗時の関数を渡すこともできます。
catch
catch()
も関数を引数にとり、この関数はPromiseの結果が失敗したときに呼び出されます。
const p = new Promise<string>((resolve,reject)=>{
setTimeout(() => {
reject("Rejected!") // rejectに渡してPromiseを失敗させる
},2000)
})
p.then((result) => console.log(result)) // then: Errorとして返す
p.catch((result) => console.log(result)) // catch: Rejected!という文字列を返す
Promiseが成功したときは、catch
の引数に渡した関数は実行されません。
const p = new Promise<string>((resolve,reject)=>{
setTimeout(() => {
resolve("Called!") // resolveに渡してPromiseを成功させる
},2000)
})
p.then((result) => console.log(result)) // Called!が表示される
p.catch((result) => console.log(result)) // 実行されないので、何も表示しない
catch
は非同期処理の失敗をエラーではなく成功の結果として返すことができます。「失敗を成功として返す」とは、以下のようにチェーンすることでエラーの場合の結果を操作できるということです。
const isResolved = true
const p = new Promise<string>((resolve,reject)=>{
setTimeout(() => {
isResolved
?resolve("Called!") // isResolvedがtrueなら成功させる
:reject("Rejected!") // isResolvedがfalseなら失敗させる
},2000)
})
p
.catch(() => "Errorでしたよ")
.then((result) => console.log(result)) // 成功なら「Called!」、失敗なら「Errorでしたよ」
isResolved
がtrue
のときは成功するのでcatch
の中の関数は実行されません。
isResolved
がfalse
のときは失敗するのでcatch
の中の関数が実行され、新しく結果が「Errorでしたよ」のPromiseオブジェクトを返します。そのPromiseオブジェクトに対して.then()
の中の関数が呼び出される、という構造になっています。
finally
Promiseの非同期処理が成功でも失敗でも呼び出されるコールバック関数を渡すことができます。
p
.finally(()=> console.log("結果が出ました。"))
.catch(() => "Errorでしたよ")
.then((result) => console.log(result))
// 結果が出ました。
// Errorでしたよ
このようにfinally
をチェーンの中で使うと、渡したコールバック関数はPromiseの結果には影響を与えず実行されます。
非同期関数
Promiseはasync
で宣言する非同期関数やawait
を書く式と組み合わせて使われることが多いです。
async関数
acync
を書いて宣言する関数は、「関数が返す結果」を結果とするPromiseオブジェクトを返します。
const sayYes = async () => "Yes."
sayYes().then((result)=>console.log(result))
console.log('Hello.')
非同期処理は同期処理の後に実行されるので、コンソールに出力される順番は、Hello
が先で、次にYes
となります。
await式
async関数の中で宣言することで、「Promiseの結果が出るまで待つ」という処理にすることができます。
タイマーと組み合わせた例を見て確認してみます。
const p2sec = new Promise<string>((resolve)=>{ // 2秒後に結果を得るPromiseオブジェクト
setTimeout(() => {
resolve("Called!")
},2000)
})
const sayYes = async () => {
console.log("awaitの前です")
await p2sec // Promiseの結果が出るまで待つ
console.log("awaitの後です")
return "Yes."
}
sayYes().then((result)=>console.log(result)) // async関数の結果を出力
console.log('Hello.')
これを実行すると、コンソールにはまず次の順番で出力されます。
awaitの前です
Hello.
async関数の中はawait
が出てくるまで同期処理が行われているため、「awaitの前です」という文字が最初に出力されています。
2秒後、p2sec
の結果が得られるので以下が出力されます。
awaitの後です
Yes.
このタイミングで「awaitの後です」と出てきました。awaitの結果が得られるまでasync関数の中の処理が中断されているためです。awaitによって「Promiseの結果が出るまで待つ」という処理になっていることが確認できました。
その直後、プログラムはreturn文に到達し、async関数がPromiseの結果を得るのでsayYes.then()
の中身がコールバックされ、コンソールにYes.
と表示されます。
await式の返り値
また、await式の返り値はawait
で待つPromiseの結果と同じになります。先程のsayYes
というasync関数の中を以下のように書き換えてみます。
const sayYes = async () => {
console.log("awaitの前です")
const result = await p2sec // resultにawait式を代入
console.log("awaitの後です")
console.log(result) // await式の結果、つまりPromiseの結果を出力
return "Yes."
}
sayYes().then((result)=>console.log(result)) // async関数の結果を出力
この状態で再度実行すると、2秒後に以下の結果がコンソールに出力されます。
awaitの後です
Called!
Yes.
sayYes
関数のスコープ内でresult
はp2sec
のPromiseの結果です。なので、console.log(result)
はCalled!
となります。
次のsayYes.then()
が呼んでいるコールバック関数のスコープでは、result
はsayYes()
の返り値、つまりreturn
文に書かれているYes.
のことなので、Yes.
を出力します。
async関数とawait式のメリット
await式を使うことで、「順序のある非同期処理」を実装しやすくなります。
const pStep = (num:number) => new Promise<string>((resolve)=>{
setTimeout(() => {
resolve(`step${num}`)
},2000)
})
const callStep = async () => {
const res1 = await pStep(1)
console.log(res1)
const res2 = await pStep(2)
console.log(res2)
return "finished!"
}
callStep().then((result)=>console.log(result))
pStep()
という、引数に数字
を入れると2秒後にstep<数字>
をPromiseの結果として得る関数を作りました。これをcallStep
という非同期関数の中で、awaitをつけて2回呼んでいます。
結果、コンソールには2秒後にstep1
、4秒後にstep2
と表示され、その直後return
に到達します。その結果、callStep()
はfinished!
という結果のPromiseオブジェクトを返し、最後のcallStep().then()
の引数に渡されたコールバック関数が呼ばれてコンソール上にfinished!
と表示されます。
このように、async
とawait
を使うことで、非同期処理の中でも「ある結果を得てから次の処理をしたい」といった実装が可能です。また、await
を使うと、同期処理のようにプログラムを上から順番に読むことができるようになるため、コードがとても読みやすくなります。
参考 : MDN web docs | 非同期関数
非同期関数のエラー処理
async
とawait
で非同期処理が同期処理のように書けるということで、エラー処理をしたい場合も同期処理と同様にtry catch
構文で書くことができます。
const httpRes = (code:number) => new Promise<string>((resolve,rejected)=>{
setTimeout(() => {
code===200
?resolve("Success!") // 200なら成功。結果は"Success!"
:rejected(`${code}`) // 200以外なら失敗。結果はcode。
},2000)
})
const statusCode = 200
const request = async() => {
try{
const res = await httpRes(statusCode)
console.log(res)
} catch (err) {
console.log("Failed : ", err)
}
}
request()
上記のようにstatusCode
に200
を代入して実行すると、2秒後にコンソールにはSuccess!
と表示されます。一方200
以外の数字、例えば404
を代入するとPromiseの結果は失敗判定され、エラー処理となってコンソールにはFailed : 404
という結果が出力されます。
まとめ
というわけで今回はTypeScriptにおける非同期処理の基本として、Promise、async関数、await式についてまとめてみました。
簡単なタイマー処理の例で実装も試してみました。
ご参考になれば幸いです。