前回は非同期処理の概念を取り上げました。その中で「ある非同期の処理が終わったら、次の処理をはじめたい」という要望に応えようとすると、コールバック関数が入れ子になり読みにくくなる「コールバック地獄」のことをお伝えしました。非同期処理が難しい理由は、このような可読性の悪さが一つの要因でしょう。
今回はその解決法として「Promiseやasync/awaitの基本・仕組み」を学びましょう。特にPromiseの特長を理解していると、非同期処理の扱いがグッと楽になります。動作や実行の流れがよくわからなくなった場合には、前回の内容を参考にしてください。
今回の学び
- 難解な非同期処理を理解するには、「Promise」が鍵となる
- Promiseよりも、スッキリ書くことのできる「async/await」
- ただし、「async/await」の中身は、「Promise」
- 非同期処理のよくある悩み1「既存の処理をPromise化したい」
- 非同期処理のよくある悩み2「複数の非同期処理の完了後に何かしたい」
契約社員、客員研究員、フリーランスなどさまざまな立場で開発現場を経験。フロントエンド、バックエンド開発、チームリードやOJTの育成担当まで幅広くこなしてきた。現在プログラミングトレーニング事業を行うサークルアラウンド株式会社を立ち上げ、経営や現場開発を行いながらOJT経験をもとに後進の育成に励んでいる。ノウハウのコンテンツ化にも取り組んでおり、著書に『ステップアップJavaScript』(翔泳社)がある。
*記事内に使用しているサンプルコードは、以下からも確認することができます。
https://github.com/CircleAround/engineer_style_js/tree/main/04
Promiseの基本
/users.json
というパスに通信するコードを書いてみました。これを使ってPromiseの基本的な流れや考え方をつかみましょう。
const fetchUsers0 = () => {
// 通信の結果オブジェクトを表示するコールバック関数の作成
const onfulfilled = (res) => { // *1
console.log("[1']", res); // *1'
}
// エラーオブジェクトを表示するコールバック関数の作成
const onrejected = (err) => { // *2
console.error("[2']", err); // *2'
}
// fetchを呼ぶと通信が開始されかつ、Promiseオブジェクトが返る
const promise = fetch("users.json"); // *3
promise.then(onfulfilled, onrejected); // *4
}
fetchUsers0
関数を動作させたときの動きは以下のようになります。
- 成功時のコールバック関数を定義(*1)
- 失敗時のコールバック関数を定義(*2)
- fetch関数を利用して通信を開始(*3)
- Promiseオブジェクトのthen関数を使って*1、*2の関数をセットします(*4)
a. 第一引数が成功時、第二引数が失敗時のコールバックです - しばらくして通信が終わります
a. 成功した場合にはonfulfilled関数がコールバックされ、*1’ の処理が呼ばれます
b. 失敗した場合にはonrejected関数がコールバックされ、*2’ の処理が呼ばれます
前回学んだように、*3で非同期の通信処理が始まりますが、同期の処理が先に処理されるので *4のthen関数が先に実行されることが保証されています。
Promiseは状態を持っていて、生成時にはpending状態です。処理が成功するとfulfilled、失敗するとrejectedの状態に変更されます。この状態変更の際にthenで仕掛けたコールバックが呼ばれる仕組みです。
先の例では処理を区切って書いていましたが、大抵は以下のように続けて記述します。
const fetchUsers1 = () => {
fetch("users.json").then((res) => {
console.log(res);
}, (err) => {
console.error(err);
});
}
Promiseの基本を押さえたら、仕組みを理解しよう
Promiseは連続したPromiseの処理が読みやすくなるような仕組みを持ちます。次のコードを見ながら確認してみましょう。
const fetchUsers2 = () => {
fetch("users.json").then((res) => {
console.log(res);
return res.json();
}).then((users) => {
console.log(users);
});
}
先のコードとの違いは *1でjson関数を呼んでいることです。resは通信の結果のResponseオブジェクトですが、jsonメソッドで受け取ったJSONをObjectに変換できます。
*1のようにPromiseのコールバック中にreturnを使うと、結果を受け取って動作するのは次のthenである*2になります。users変数はres.json()の返すPromiseがfulfiledになった際に得られたオブジェクトです。この仕組みによって、thenを繋いで上から順番に処理が実行されるような書き方ができます。
あるURLからJSONを取得してObjectに変換することはよくあるので、この流れを理解すると通信の基本的な扱いがグッと楽になるでしょう。
const fetchUsers3 = () => {
fetch("users.json").then((res) => {
console.log(res);
return res.json();
}).then((users) => {
console.log(users);
}).catch((err) => {
console.error(err);
});
}
エラー処理を省略していましたが、thenの第二引数の他に、catch関数が用意されています。これを使うとtry - catchで行う例外処理のように、処理の中途で起きたエラーをまとめて捕まえることができます。こちらの方が使いやすいケースが多いです。
Promiseの動作仕様は初見では理解するのが難しいところがあるので、何度か書いたり読んだりして身につけていくものかと思います。
async/awaitは、Promiseよりも記述が簡単
async/awaitは、Promise より簡単な記述で同様の効果が得られます。先に紹介したfetchUsers3の関数をasync/awaitを利用して書き換えると下記のように、大変スッキリします。
const fetchUsersAsyncAwait = async () => {
try {
const res = await fetch("users.json")
console.log(res);
const users = await res.json();
console.log(users);
} catch(err){
console.error(err);
}
}
記述の際、以下の2つのルールがポイントです。
- 処理の全体をasyncキーワードで装飾した関数で囲むこと
- Promiseを戻り値にする処理では awaitキーワードで装飾すること
awaitを書いたところで「処理を待つ」といった脳内イメージを描けると扱いやすいでしょう。前回紹介したような「同期的な待ち」ではないので、パフォーマンスに酷く影響を与えることはありません。
現在では多くのブラウザが対応しているので、ほとんどの場合にこちらで十分です。ただし、中身がPromiseであるということを理解していると柔軟に扱えたり、このあと紹介するPromiseを扱う機能と上手に連携できるので、Promiseの理解も一緒に進めると効果的です。
非同期処理で、よくある悩みへの対応
■既存の処理をPromise化したい場合は?
何らかの非同期処理のコードがあったとき、それをPromise化(Promisify)できると、async/awaitと一緒に使える読みやすいコードに変えることができます。
例えば「指定時間後にfulfilledになるPromiseオブジェクトを返す wait 関数」を setTimeoutを内包する形で作ることができます。
const wait = (timeout) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(true); // fullfiled時にtrueを値として返す例です
}, timeout);
});
}
new Promise をする際に、引数として関数を渡します。第一引数の関数をコールするとfulfilled、第二引数の関数をコールするとrejected状態になります。さらに、引数として値を渡すことができます。
wait関数を以下のように利用できます。
const waitSample = async () => {
const ret = await wait(1000);
console.log(ret); // => true 1秒後に呼ばれます
}
waitSample(); // 1秒後にtrueと表示される
外部ライブラリなどがコールバックで提供されているときなどに、役立つでしょう。
■複数の非同期処理の完了後に何かしたい場合は?
「複数の通信を同時に開始して、その両方が終わったら何かする」といった処理はまともに書くと煩雑になりがちです。Promise.all 関数を利用するとこれを読みやすい形で実現できます。先に書いたwaitを利用すると、以下のように2つのPromiseが完了してから次の処理が始まるコードを書くことができます。
const waitAll = async () => {
const ret = await Promise.all([wait(5000), wait(3000)])
console.log(ret); // => [true, true] 5秒後に呼ばれます
}
Promiseオブジェクトを配列として渡すだけで実現でき、シンプルに書けました。
次回は非同期処理が特に関係するAjaxについて取り上げます。今回の内容を踏まえて進みますので、Promiseやasync/await を少し触っておくと良いでしょう。
今回のまとめ
- Promiseオブジェクトを利用すると、非同期処理の状態変化に対応するコードを整えて書くことができ、コールバック地獄を回避して上から下へ読めるコードとして表現しやすくなります。
- Promiseよりもさらに読みやすいasync/awaitが今はよく使われますが、Promiseを理解していると使いこなしやすくなります。
▼これまでのJavaScriptを学ぶ上でつまずきやすいポイントを理解するための連載
・JS特有の「this」の使い方6つをおさえよう
・2つの利用シーンから理解する「クロージャ」活用法
・実行処理の図を見て「非同期処理」について知ろう