プログラミングを勉強していた時から非同期処理は苦手でした。エンジニアになってから、当然業務でAPIを叩いてるのですが、バックとの通信箇所は既存のコードの見様見真似でも何とかなってしまう事がほとんどなので、、PromiseやらAsync/Awaitやら完全に理解していると言えない状態でした。エンジニアも3年目に入るので、いつまでもこれではいけないので、苦手意識を少しでも減らすべく記事にまとめました。
まず、そもそも非同期処理
って何?同期処理
とどう違うのか?
同期処理も、非同期処理も処理の実行自体
はコードの上から順番に実行されます。
同期処理: ある処理の完了を待って、次の処理へと順番に進んでいく。つまり順番を守る真面目な奴(でも遅い)
非同期処理: ある処理の完了を待たずに、次の処理へ進む。つまり順番を守らない無礼でせっかちな奴。(でも速い)
JavaScriptは、ご存知の通り時間のかかる処理(AJAX通信など)は完了を待たずに
次の処理の実行へ進んでいく非同期言語
です。
しかし順番通りに処理を実行してもらわないと困るケースがある。今回その例として扱ったケースは2つのAPIを叩いてデータをFetchして、そのデータを元にUIを構築するというもの。
時間のかかる処理の完了を待てないせっかちな非同期処理に「順番」を守らせる為に、この後詳しく説明するPromiseやAsyncAwaitが生まれた。「非同期処理を同期的に扱う」という表現もよく使われますね。
どんな処理(関数)が非同期処理になるのでしょうか?
APIからデータをフェッチしてきたり等、時間がかかる処理は非同期処理になってしまいます。
つまり実行する関数に依存しているんです。
・アニメーション系
・イベント系 (DOM Events)
・タイマー系 (setTimeout, setInterval)
では本題です、そんな非同期処理を同期的に扱うための記法は2種類あります。
・Promise記法(thenでつなぐ)
・async/await
Promiseとは?
詳細は割愛しますが、Promise以前はコールバックを駆使して、この処理が終わったら、これを呼ぶという風にネストさせていたが、可読性、エラー発生時の扱いが大変だった。
それでES2015からPromiseが生まれた(もともと外部ライブラリだったけど、言語の使用に組み込まれた)
Promiseは奥が深くて、内部仕様を全て把握していなくても、フロント開発は基本的にfetchなど最初からPromiseを返してくれるメソッドを使ってthenでつないだり、async/awaitしたりして、処理の順番を担保させて、受け取ったデータをフロント側のコードで使うというやり方が基本なので、自分でPromiseオブジェクトを作るという事はあまりないと思います。しかし完璧に理解までいかなくても基本は整理しようと思います。
まずPromiseは3つの状態を持ってる
・Fulfilled(成功):resolve関数が呼ばれた時→then
・Rejected(失敗):reject関数が呼ばれた時→catch
・Pending(待機):Fulfilled、Rejectedでもなく、newPromiseでインスタンス作成した初期状態
すごいシンプルだけど、Promise構文の実サンプルはこんな感じ。
const resolveSample = new Promise((resolve, reject) => {
resolve('成功');
})
resolveSample
.then(value => {
console.log(value); //呼ばれて「成功」と出力
})
.catch(error => {
console.log(error); //呼ばれない
})
const rejectSample = new Promise((resolve, reject) => {
reject('失敗');
})
rejectSample
.then(value => {
console.log(value); //呼ばれない
})
.catch(error => {
console.log(error); //呼ばれて「失敗」と出力
})
promiseオブジェクトは、 then() と catch() という2つのメソッドを持っており、promiseオブジェクト内部の状態が Fulfilled になると、 then() メソッドのコールバックが呼ばれ、逆に Rejected になると catch() メソッドのコールバックが呼ばれます。
上記のサンプルでは、resolveSampleは、resolve関数を呼んでるので、thenメソッドが呼ばれ。
rejectSampleは、reject関数を呼んでるので、catchメソッドが呼ばれます。
また、thenもcatchも返り値はPromiseなので、メソッドチェーンにして繋げられます。これをPromiseチェーンと呼んだりします。
では順番を守らない非同期処理を順番に実行させる
にはこのPromiseをどう使ったらいいかの超絶シンプルなサンプルでみてみましょう。タイマーで、2つのログを時間差で出力させるという処理です。
まず普通の関数
function delay(timeoutMs) {
setTimeout(() => {
console.log('3秒後に出力されるdelay関数のログ')
}, timeoutMs);
}
delay(3000)
console.log('delay関数の後に出力')
この結果は、以下の通りで、意図した順番になりません。
delay関数の後に出力
3秒後に出力されるdelay関数のログ
ではPromiseを使って、順番通りに実行させましょう。
function delay(timeoutMs) {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, timeoutMs);
})
}
//thenメソッドで成功時のコールバックだけを登録
delay(3000)
.then(() => {
console.log('3秒後に出力されるdelay関数のログ')
})
.then(() => {
console.log('delay関数の後に出力')
})
この結果は、意図した順番になります。
3秒後に出力されるdelay関数のログ
delay関数の後に出力
上記サンプルはPromiseオブジェクトを作りましたが、AJAX通信のライブラリAxiosや、fetchメソッドはPromiseを返してくれるので、new PromiseしてPromiseオブジェクトを作る必要はありません。次のサンプルをご覧ください。fetchメソッドを使う時に、thenを繋げたこんなコードをよく見ますね。
2つのAPIからデータをフェッチしてグローバル変数の配列に入れておき、2つ目のフェッチが終わったらinitと言う名前の関数を呼んでます。
let data1 = [];
let data2 = [];
function fetchData() {
fetch('http://XXXXX.com/hoge')
.then(res => res.json())
.then(data => {
data1 = data;
return fetch('http://XXXXX.com/hoge2')
})
.then(res => res.json())
.then(data => data2 = data)
.then(() => {
allData = [...data1, ...data2]
init()
})
.catch(err => console.log(err))
.finally(() => {
//finallyは成功、失敗に関わらず最後に呼び出される
})
}
ここで大事なポイントが、thenの中の処理が成功すると、次のthenに結果が渡されているのがわかると思います。Promiseチェーンでは、コールバックで返した値を次のコールバックへ引数として渡されるからです。その性質をわかりやすく解説したのが以下のサンプルです。
fetch('http://XXXXX.com/hoge')
.then(res=>res.json())
.then(init)
// ↓と同じ事
const response = fetch('http://XXXXX.com/hoge')
const handledData = response.json()
init(handledData)
余談ですが、先日業務でAPIの実装が間に合ってないけど、フロントは実装を進めたいという時に、仮でこんな実装をしました。(API通信するモジュールにダミーデータをPromise.resolveで返す処理を実装。実際はモジュールも分けてますが便宜上まとめて書いてます)
//APIが返してくれる型のデータをフロントで用意
const dummyData = [
{name: 'hoge taro', id: 1},
{name: 'hoge jiro', id: 2},
{name: 'hoge goro', id: 3}
];
const getHogeData = () => {
return Promise.resolve({
ok: true,
status: 200,
json: {
data: dummyData
}
})
}
getHogeData().then(res => {
if(res.ok && res.json){
setHogeData(res.json.data)
}
})
要はfetchの擬態みたいなことをしてるんですね。
Promise.allとは??
複数の非同期処理を並列でまとめて実行してくれるのがPromise.all
こちらのサンプルは、複数のファイル(files)をアップロードするというイメージでみてください。
const promiseResponses = [...Array(files.length)].map((_, index) => {
const file = files[index];
return API.upload(file);
});
//複数のファイルのアップロードが終わることを待つ
Promise.all(promiseResponses).then(responses => {
//responsesには複数のレスポンスが配列形式できます。
});
API側の仕様で一個ずつしかファイルを受けてくれないので、ループして順番に投げますが、当然非同期処理で実行します。
そのレスポンスを1個ずつ待つと効率が悪いですし、パフォーマンスにも影響します。
そこでPromise.allを使います。引数はPromiseオブジェクトの配列です、配列内のPromiseオブジェクトの処理が全部完了するまで待って、それからthenで完了後の処理するという事が可能になります。
今回のように複数のファイルをアップロードとか、順番通りに処理されなくてもいい時(並列処理)はPromise.allはもってこいですが、API-1を叩いてその返り値をAPI-2に渡すなんて順番を担保しておかないといけない処理にはPromise.allは使えないですが。。
Async/Awaitとは??
ずっとアシンクアウェイト
って勘違いしてました、、正確にはエイシンクアウェイト
です!
関数の中でAwaitを使いたかったらまず関数をAsyncとして宣言する必要があります。
そして、処理の完了を待ちたい箇所の頭に、awaitをつけると、その名の通り、完了するまでそこで待ってくれて、次の処理へ行かせなくします
。
しかし、、注意したいのが awaitできるのはPromiseを返すものだけなのです
(Promiseのルールに沿った処理だけがawaitできるという訳です)
つまりはAsync/AwaitはPromiseありきのものだから、ある程度Promiseを知っておいたほうがいいという事なんですね。
では、上で挙げた例をAsync/Awaitで書いてみましょう。
let data1 = [];
let data2 = [];
async function fetchData() {
try {
const fetchedData1 = await fetch('http://XXXXX.com/hoge')
data1 = await fetchedData1.json()
const fetchedData2 = await fetch('http://XXXXX.com/hoge2')
data2 = await fetchedData2.json()
init()
} catch(e) {
console.log('error', e)
}
}
凄い簡潔に書ける様になりますね。
Async/AwaitはPromiseの糖衣構文(Syntax Sugar)ってなんだ??
よく本とか技術ブログでこんな風に書かれてるのを見かけます。糖衣構文(Syntax Sugar)は「同じ意味の処理を元の構文よりもシンプルに書ける別の書き方」の事です。
つまりはAsync/AwaitはPromiseを簡単に扱えるようにしたものという事です。
まとめ
結構長くなってしまいましたが、あまり深いところには触れず、基本だけをまとめました。
間違ってる部分などありましたらご指摘ください。
こうして言語化すると考えが整理されて凄いためになるので、スキル向上のため今後もいろいろ投稿していこうと思います。