現役エンジニアが教える!実践JavaScript入門 〜非同期処理発展編〜
今回扱う内容:非同期処理
非同期処理の応用的な使い方を理解しよう。
コールバック関数を使った非同期処理
前回の記事で触れたPromiseを使わない非同期処理の例を紹介します。
タイマー処理
setTimeoutは第2引数にミリ秒で指定した時間後に第1引数のコールバック関数がよばれます。
https://developer.mozilla.org/ja/docs/Web/API/setTimeout
次のように記述することで1秒後にHello
が出力されます。
setTimeout(() => {
console.log("Hello");
},1000);
fetch以前のサーバとの通信
fetch以前にサーバからデータを取得する処理ではXMLHttpRequestが使われていました。
https://developer.mozilla.org/ja/docs/Web/API/XMLHttpRequest
addEventListenerメソッドではサーバからデータを読み込んだら(load)第2引数のコールバック関数を呼びます。
const req = new XMLHttpRequest();
req.addEventListener("load", (e) => {
console.log(e.target.response);
});
req.open("GET", "https://jsonplaceholder.typicode.com/todos/1");
req.send();
コールバック地獄
前述のPromiseを使わない非同期処理では、「コールバック地獄」と呼ばれる問題があります。
setTimeout(() =>{
console.log("Hello!");
setTimeout(() =>{
console.log("Hello!");
setTimeout(() =>{
console.log("Hello!");
setTimeout(() =>{
console.log("Hello!");
}, 1000);
}, 1000);
}, 1000);
}, 1000);
このようにコールバック関数での非同期処理を繰り返し行うとネスト(入れ子)が深くなり、コードが読みづらくなるという欠点があリます。このコールバック地獄をなくすためにPromiseが生まれました。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Promise
次のようにPromiseを使えば、非同期処理を何回呼んでもネストが深くなりません。
asyncHello()
.then(() => asyncHello())
.then(() => asyncHello())
.then(() => asyncHello())
.catch((error) =>{
console.log("error");
});
Promiseを返す非同期処理
Promiseを用いた非同期処理はコールバック地獄の観点から推奨される一方で、Promiseを使わないコールバック処理の非同期処理は現在も多く使われています。対処法としてPromiseでラップした非同期処理を自分で作ることで、コールバック地獄を防ぐことができます。
書き方は、次のようにPromiseが返ってくる関数を定義します。またPromise内はresolveとrejectの引数を取り処理が成功したらresolve
を、失敗したらreject
を呼び出すというルールがあります。
const asyncHello = () =>
new Promise((resolve, reject) => {
setTimeout(() =>{
console.log("Hello!");
resolve();
}, 1000);
});
このようにコールバックの非同期処理もPromise
でラップすることで、次のようにタイマー処理を同期的にthen
で書くことができます。
asyncHello()
.then(() => asyncHello())
.then(() => asyncHello())
.then(() => asyncHello())
.catch((error) =>{
console.log("error");
});
またasync/awaitを用いることで、より直感的で分かりやすいコードにできます。
const main = async () =>{
await asyncHello();
await asyncHello();
await asyncHello();
};
main();
resolveとreject
Promise
でラップする際には引数としてresolve
とreject
をもつことができます。1つ目のresolve
は引数に値を渡すと、渡した値を作成した関数の返り値として読み取ることができます。
const asyncHello = () =>
new Promise((resolve, reject) => {
setTimeout(() =>{
console.log("Hello!");
resolve("hello");
}, 1000);
});
const main = async () => {
// "hello"を受け取る
const hello = await asyncHello();
console.log(hello);
}
main();
2つ目のreject
に関してはサーバからデータを取ってくる際にリクエスト先がない場合など、非同期処理に失敗した際などに呼ばれます。
使い方は次の通りです。
const asyncHello = () =>
new Promise((resolve, reject) => {
setTimeout(() =>{
console.log("Hello!");
reject();
}, 1000);
});
const main = async () => {
try{
const hello = await asyncHello();
} catch (error) {
// catchに入る
console.log("error");
}
};
main();
非同期処理の直列実行と並列実行
非同期処理を繰り返し行いたい場合には、順番に実行するか同時に実行するかを選ぶことができ、それぞれ直列実行と並列実行と呼びます。
APIサーバーとしてJSONPlaceholder (https://jsonplaceholder.typicode.com/todos) を使用し、末尾に1から5のパラメータを渡してデータを直列実行または並列実行で取得する場合を考えます。
直列で非同期処理を行う場合は、次のようになります。for...of
文を使うことで、非同期処理の反復処理を実現します。直列に実行されるので、意図した順番で非同期処理が行えます。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/for...of
const ids = [1, 2, 3, 4, 5];
const main = async () => {
for (const id of ids) {
const res = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`);
const json = await res.json();
console.log(json);
}
};
main();
並列で非同期処理を行う場合は、Promiseの配列をPromise.all
に渡すことで実現できます。データが並列で取得されるので、直列実行に比べて高速にデータの取得が完了します。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Promise/all
const ids = [1, 2, 3, 4, 5];
const main = async () => {
const fetches = ids.map((id) =>
fetch(`https://jsonplaceholder.typicode.com/todos/${id}`).then(res =>
res.json()
)
);
const jsons = await Promise.all(fetches);
console.log(jsons);
};
main();
使用時の注意点
- 使うメソッドが同期処理なのか非同期処理かをドキュメントを参考に確認する
- Promiseやasync/awaitはES6から使われる書き方なのでブラウザの互換性をチェックする
- 直列実行か並列実行で行うかの選択は、自分の実行したいことで判断する
- 実行したい処理が前の処理での結果を必要とする場合では直列実行が必要となる
- 前の処理での結果を必要としない場合では並列実行の方がパフォーマンスは良い
- ただし、並列数が極端に多いとサーバに負荷がかかって落ちてしまうこともあるので配慮が必要
まとめ
Promiseとasync/awaitを用いた非同期処理の記述方法を解説しました。次回はJavaScriptのDOM操作について解説します。