はじめに
JavaScriptにおいて、非同期処理の扱い・知識は欠かせないものとなっています。
この記事ではそもそも非同期処理とは何なのか、JavaScriptで非同期処理がどのような変遷を遂げてきたのかについて解説していきたいと思います。
同期処理
JavaScriptで以下のようにコードを書いた場合、各行の処理が完了するまで次の行の処理が開始されません。
変数num
が初期化される前にconsole.log(num)
が呼び出されることはなく、必ずnum
の初期化が完了してからコンソールに出力されます。
let num = 1;
console.log(num);
num++;
console.log(num);
// => 1
// => 2
このように、書いた順番にプログラムが実行され、現在行の処理が完了してから次の行の処理に移るような処理は同期処理と言われています。
同期処理の問題点
JavaScriptにおいて、HTTPリクエストや端末のカメラやマイクへのアクセス、ファイルの読み込みといった完了までに長い時間がかかる可能性のある機能を使用する際、
それらの機能が完了するまで処理が止まってしまうとその他の処理や画面操作(リンクの押下やテキストボックスへの入力等)も受け付けなくなります
(厳密には実行待ちの状態になり、「長い時間のかかる処理」が完了するまで実行されず、画面がフリーズしたように見える)。
この状態については、MDNの長時間実行される同期関数の問題点にわかりやすい例が載っています。
非同期処理
前述のような完了までに長い時間を要する処理を行う際、処理が完了するのを待たずに次の処理を実行する仕組みが非同期処理です。
例えばGoogleMapのようなWebサービスが非同期処理を行わず、すべて同期処理で実装されていた場合、裏でサーバーと通信し、画面を更新するような処理が走るたびに画面が一切の操作を受け付けずに処理の完了を待つ時間が生じる、非常に使いづらいサービスとなってしまいます。
そのため、定期的にサーバーと通信するようなWebアプリケーション制作では非同期処理は欠かせない技術となっています。
以下MDNより引用
非同期プログラミングは、長く続く可能性のあるタスクを開始しても、そのタスクが完了するまで待つのではなく、そのタスクの実行中も他のイベントに応答できるようにする技術です。タスクが完了すると、プログラムはその結果を表示します。
JavaScriptにおける非同期処理の歴史
ES2015以前
ES2015以前は非同期処理を行いたい場合、callback関数とイベントハンドラーを用いるのが一般的でした。
callback関数
callback関数はいわゆる普通の関数ですが、ほかの関数へ引数として渡され、その関数内で適切なタイミングで実行されるものです。
callback関数を受け取る関数としては、下記のようなsetTimeout
をはじめとして様々なものがあります。
// 処理開始時の時刻を出力
console.log(new Date());
// 5000ミリ秒経過したら第一引数に渡したcallback関数を実行する
setTimeout(function () {
// 5000ミリ秒後の時刻を出力
console.log(new Date());
}, 1000 * 5);
イベントハンドラー
イベントハンドラーとは、画面をクリックする・画面の読み込みが完了する、といったイベントが発生した際にイベントに紐づいた処理(関数)を実行する機能です。
ボタンをクリックすると音が鳴る、画面をスクロールすると画像が切り替わるといったユーザの操作に合わせて、紐づいた処理を行ってくれます。
/** 画面をクリックするとクリックした箇所の座標を表示する処理 */
// addEventListenerにイベント名とcallback関数を渡す方法
window.addEventListener("click", function(event) {
alert(`座標${event.offsetX}:${event.offsetY}がクリックされました!`);
});
// on + イベント名に関数を代入する方法
window.onclick = function(event) {
alert(`座標${event.offsetX}:${event.offsetY}がクリックされました!`);
}
Ajax(Asynchronous JavaScript and XML)
ページ全体を再読み込みせずにサーバーと情報をやり取りして画面を動的に更新する、といった非同期処理をJavaScriptを用いて行いたい場合はAjaxと呼ばれる手法を用いることが主流でした。
古くは2004年に発表されたGMailでも用いられており、Webアプリケーションの時代の幕開けに大きく貢献した手法です。
そのコアとなるのがHTTPリクエストを送信するXMLHttpRequest
オブジェクトです。
XMLHttpRequest
にはload
やerror
、loadend
といったイベントハンドラーが存在し、それらのイベントリスナーとcallback関数を用いて非同期処理の実装を行っています。
// XMLHttpRequestオブジェクトを生成
var xhr = new XMLHttpRequest();
// メソッドとURLを指定してリクエスト内容を設定
xhr.open('GET', 'https://qiita.com/api/v2/users/BAITO0123/items?page=1');
xhr.responseType = "json";
// 正常に完了した時のイベントを登録
xhr.addEventListener('load', function () {
console.log(xhr.response);
});
// エラー発生時のイベントを登録
xhr.addEventListener('error', function () {
console.log(xhr.err);
});
// 正常・エラー関係なくリクエスト完了した時のイベントを登録
xhr.addEventListener('loadend', function () {
// 正常時の処理
if(xhr.status === 200) {
console.log(xhr.response);
}
// エラー時の処理
else {
console.log(xhr.response);
}
});
// リクエストを送信
xhr.send();
callback地獄
callback関数を用いた非同期処理ではとある非同期処理が完了してからまた別の非同期処理を行いたい場合、callback関数の内部で非同期関数を実行し、またそれにcallback関数を渡しその内部でまた非同期関数を実行してcallback関数を渡す・・・といったcallback地獄へと誘われる危険性があります。
callback地獄はやたらと内部のネストが深くなり、エラーハンドリングも各スコープ内で行う必要があるなど、非常に面倒かつ可読性の問題もありました。
var xhr1 = new XMLHttpRequest();
xhr1.open('GET', 'https://qiita.com/api/v2/users/BAITO0123/items?page=1');
xhr1.responseType = "json";
xhr1.addEventListener('loadend', function () {
if (xhr1.status === 200) {
console.log(xhr1.response);
// xhr1の処理が正常に完了したらxhr2の処理を開始する
var xhr2 = new XMLHttpRequest();
xhr2.open('GET', 'https://qiita.com/api/v2/users/BAITO0123/items?page=1');
xhr2.responseType = "json";
xhr2.addEventListener('loadend', function() {
if (xhr2.status === 200) {
console.log(xhr2.response);
// xhr2の処理が正常に完了したらxhr3の処理を開始する
var xhr3 = new XMLHttpRequest();
xhr3.open('GET', 'https://qiita.com/api/v2/users/BAITO0123/items?page=1');
xhr3.responseType = "json";
xhr3.addEventListener('loadend', function() {
if (xhr3.status === 200) {
console.log(xhr3.response);
}
else {
// エラーが帰ってきたら内容を出力する
console.log(xhr3.status + " " + xhr3.response);
}
});
xhr3.send();
}
else {
// エラーが帰ってきたら内容を出力して終了する
console.log(xhr2.status + " " + xhr2.response);
}
});
xhr2.send();
}
else {
// エラーが帰ってきたら内容を出力して終了する
console.log(xhr1.status + " " + xhr1.response);
}
});
xhr1.send();
上記ファイルのAjax処理部分を関数に分けて呼び出すと以下のようになります。
// URLとcallback関数を受け取り、処理が正常に完了したらcallback関数を実行する
function ajax(url, callback) {
var xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.responseType = "json";
xhr.addEventListener('loadend', function () {
if (xhr.status === 200) {
console.log(xhr.response);
if (callback) {
callback();
}
}
else {
console.log(xhr.status + " " + xhr.response);
}
});
xhr.send();
}
// 呼び出し側は多少スッキリするがcallback地獄は変わらず・・・
ajax("https://qiita.com/api/v2/users/BAITO0123/items?page=1", function() {
ajax("https://qiita.com/api/v2/users/BAITO0123/items?page=1", function() {
ajax("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
});
});
Generator関数
上記のようなcallback地獄に対抗するためにGenerator関数を用いた手法がとられていたこともあります。
Generator関数はざっくり言うとyield
演算子で処理が止まり、next()
関数を呼び出すことで処理が再開されるという特性を持った関数です。
yield
で処理を止め、呼び出し先の非同期処理を行う関数内でnext()
関数を実行することで同期処理的な記述で非同期処理を実行することができます。
// GETメソッドを送信して取得結果を返すメソッド
function requestGetMethod(gen, url) {
var xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.responseType = "json";
xhr.addEventListener('loadend', function () {
// 処理が完了したら値を返して次の処理を実行する
gen.next(xhr.response);
});
xhr.send();
}
// Generator関数を作成してnext()を呼び出す
var generator = (function *() {
// requestGetMethod内でnext()関数が実行されるまで処理を停止し、next()関数の引数をresult1に代入する
var result1 = yield requestGetMethod(generator, "https://qiita.com/api/v2/users/BAITO0123/items?page=1");
console.log(result1);
var result2 = yield requestGetMethod(generator, "https://qiita.com/api/v2/users/BAITO0123/items?page=1");
console.log(result2);
var result3 = yield requestGetMethod(generator, "https://qiita.com/api/v2/users/BAITO0123/items?page=1");
console.log(result3);
})();
generator.next();
ES2015以降
Promise
ES2015では上記のcallback地獄を解消するPromise
オブジェクトが追加されました。
Promiseオブジェクトは非同期処理の状態、およびその結果を保持しており、非同期処理が正常に完了した場合、失敗した場合に処理を結び付けて実行できます。
Promiseの状態
Promiseオブジェクトは以下の3つのいずれかの状態を持っています。
-
pending
Promiseオブジェクトが作成された初期の状態でまだ処理が完了も失敗もしていない状態。 -
fulfilled
処理が正常に完了した状態。Promiseオブジェクト内でresolve()
が呼び出されるとこの状態に移行し、resolve()
の引数を値として返す。 -
rejected
処理が失敗した状態。Promiseオブジェクト内でreject()
が呼び出されるとこの状態に移行し、reject()
の引数をエラー内容として返す。
then/catch
Promiseオブジェクトはthen()
関数を呼び出すことでそれぞれfulfilled
/rejected
に移行したタイミングで引数に渡した処理を実行できます。
then
関数は引数としてonFulfilled、onRejectedの二つの関数を取り、fulfilled
時には第一引数に渡した関数、rejected
時には第二引数に渡した関数が実行され、それらの戻り値をPromiseでラップして返します。
第二引数を省略した場合は自動的にrejected
された値をエラーとしてスローしてくれます。そのため、どこかのthen
関数内でエラーが発生した場合は後述するcatch
関数内でまとめてエラーハンドリングを行うことができます。
catch
関数は引数としてonRejected
関数を取り、rejected
時に関数を実行し、その戻り値をPromiseでラップして返します。
catch
関数はthen(undefined, err => {...});
の省略系として扱われます。
// fulfilled状態のPromiseオブジェクトを生成
const p1 = Promise.resolve("resolved!");
p1.then(val => {
console.log(val);
}, err => {
console.error("error " + err);
});
// => "resolved!"
// rejected状態のPromiseオブジェクトを生成
const p2 = Promise.reject("rejected!");
// 自動的に第二引数が置き換えられ、 err => { throw err }の処理に入る
p2.then(val => {
console.log(val);
return "resolved!";
})
// ここも自動的に第二引数が置き換えられ、 err => { throw err; }の処理に入る
.then(val => {
console.log(val);
})
.catch(err => {
console.error("error " + err);
});
// => "error rejected!"
const p3 = Promise.reject("rejected!");
// 第二引数の処理に入り、fulfilledなPromiseを返す。
p3.then(val => {
console.log(val);
}, err => {
// 例外処理をして、次のthenのonFulfilledの処理に入る
console.log("onRejected " + err);
return "fulfilled";
})
.then(val => {
console.log(val);
})
.catch(err => {
console.error("error " + err);
});
// => "onRejected rejected!"
// => "fulfilled"
さらばcallback地獄
先ほどのAjax通信をする処理をPromiseを用いて実装すると以下のようになります。
ES2015では非同期通信をより簡単に行えるfetch
関数が追加され、現在はそちらが主流となっていますが、比較のため敢えてAjaxを使って実装しています。
// Ajaxの処理結果をPromiseでラップして返す関数
const ajaxPromise = (url) => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.responseType = "json";
xhr.addEventListener("loadend", () => {
if (xhr.status === 200) {
// 正常に完了したらresolve()で値を返し、fulfilledに移行させる
resolve(xhr.response);
}
else {
// エラーが発生したらreject()でrejectedに移行させる
reject(xhr.response);
}
});
xhr.send();
})
}
// .then()チェーンで順次実行する
ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1")
.then(res => {
console.log(res);
return ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
})
.then(res => {
console.log(res);
return ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
})
.then(res => {
console.log(res);
})
// エラーハンドリングはまとめて行う
.catch(err => {
console.log(err);
});
(備考)fetchの場合
fetch
を用いて実装した場合は以下のようになります。
fetch
関数はPromiseでラップされた値を返すため、そのままthen()
でつなぐことが可能です。
fetch("https://qiita.com/api/v2/users/BAITO0123/items?page=1")
.then(res => res.json())
.then(json => {
console.log(json);
return fetch("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
})
.then(res => res.json())
.then(json => {
console.log(json);
return fetch("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
})
.then(res => res.json())
.then(json => {
console.log(json);
})
.catch(err => {
console.log(err);
});
Promiseの問題
Promiseの登場によってcallback地獄からの脱却は叶いました。
がしかし、以下のような課題もあります。
- ひたすら
then()
とcatch()
でつなぐのが大変 - 取得結果を
then()
スコープ外の変数に代入しないと前の結果を参照できない
ES2017以降
async/awaitの登場
前述したようなPromiseの弱点を解消するべくES2017にて登場したのがasync/await
です。
これらはPromiseの糖衣構文であり、Promiseを用いた非同期処理をより簡潔に記述できるようにしたものです。
async function() {}
のように、function() {}
宣言にasync
キーワードを付けることで、自動的にPromiseでラップされた値を返す関数を宣言することができます。
async function内でawait
キーワードを用いることで、Promiseオブジェクトを返すような非同期関数の処理が完了するのを待ち、同期的に処理を記述することが可能となります。
ES2022からはasync function外でもawait
を用いた非同期関数呼び出しが可能となりました。(トップレベルawait)
先ほど定義したajaxPromise()
関数をawait
を用いて同期的な処理のように記述した結果が以下です。
const ajaxPromise = (url) => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.responseType = "json";
xhr.addEventListener("loadend", () => {
if (xhr.status === 200) {
resolve(xhr.response);
}
else {
reject(xhr.response);
}
});
xhr.send();
})
}
// ES2021までの呼び出し方
(async () => {
try {
// ajaxPromise()で取得したPromiseがfulfilledに移行するまで以降の処理が行われない
const result1 = await ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
console.log(result1);
const result2 = await ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
console.log(result2);
// => 常にresult1 → result2の順番で結果が出力される
}
catch (e) {
console.log(e);
}
})();
// ES2022以降の呼び出し方
try {
const result1 = await ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
console.log(result1);
const result2 = await ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
console.log(result2);
}
catch (e) {
console.log(e);
}
// fetchを使った場合
try {
const result1 = await (await fetch("https://qiita.com/api/v2/users/BAITO0123/items?page=1")).json();
console.log(result1);
const result2 = await (await fetch("https://qiita.com/api/v2/users/BAITO0123/items?page=1")).json();
console.log(result2);
}
catch (e) {
console.log(e);
}
then
やcatch
を用いたメソッドチェーンで繋げずに記述できるようになり、かなりスッキリと直感的に分かりやすい記述になっています。
また、取得結果を変数に格納するのも容易になっているほか、try...catch
を用いた通常のエラーハンドリングも可能になっています。
await
を用いることで、先ほど挙げた問題点が解消されているのがわかりますね。
そして応用へ・・・
Promise.all
引数に渡したPromiseオブジェクトの配列がすべてfulfilled
状態になった時にthen
の処理を実行します。
逆にPromise配列のうちどれか一つでもrejected
状態になったらcatch
の処理に移ります。
const result1 = ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
const result2 = ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
const result3 = ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
Promise.all([result1, result2, result3]).then(results => {
// どの順番で処理が完了しようとも、必ず引数の順番(result1 → result2 → result3の順)で処理が行われる
for (const result of results) {
console.log(result);
}
}).catch(err => {
console.log(err);
});
// 一つでもrejectされたらthenには入らない
const result1 = ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
const result2 = Promise.reject("rejected");
const result3 = ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
Promise.all([result1, result2, result3]).then(results => {
for (const result of results) {
console.log(result);
}
}).catch(err => {
console.log(err);
});
// Promise.all自体をawaitで待つ
const result1 = ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
const result2 = ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
const result3 = ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
try {
const results = await Promise.all([result1, result2, result3]);
for (const result of results) {
console.log(result);
}
}
catch (e) {
console.log(e);
}
Promise.all()にはPromiseの配列を渡す必要があるため、await
キーワードを引数に渡すと並列実行されません。
// result1はPromiseオブジェクトではないため、並列に実行されない(出力自体は正しく行われる)
const result1 = await ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
// result1の完了を待ってからPromiseオブジェクトが生成されてしまう
const result2 = ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
const result3 = ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
Promise.all([result1, result2, result3]).then(results => {
for (const result of results) {
console.log(result);
}
}).catch(err => {
console.log(err);
});
// 引数に文字列を渡しても一応出力される
Promise.all([result1, result2, result3, "Promiseではありません。"]).then(results => {
for (const result of results) {
console.log(result);
}
// => result1, result2, result3, "Promiseではありません。"が出力される
}).catch(err => {
console.log(err);
});
Promise.allSettled
ES2020で追加された関数で、引数に渡したPromiseオブジェクトがすべて完了(成否問わず)したタイミングでonFulFilled
の処理に入ります。
Promise.all
とは異なり、rejected
されたPromiseが存在したり、すべての結果がrejected
だったとしてもonFulFilled
の処理に入ることができるという特徴があります。
const result1 = ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
const result2 = Promise.reject("rejected!");
const result3 = ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
Promise.allSettled([result1, result2, result3]).then(results => {
for (const result of results) {
console.log(result);
// => { status: "fulfilled", value: [{...}]}
// => { status: "rejected", reason: "rejected!"}
// => { status: "fulfilled", value: [{...}]}
}
});
Promise.any
ES2021で追加された関数で、引数に渡したPromise配列のうちいずれか一つがfulfilled
状態になったタイミングでonFulFilled
の処理に移ります。
全てのPromiseがrejected
状態になったときはcatch
処理に移ります。
const result1 = ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
const result2 = ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
const result3 = ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
Promise.any([result1, result2, result3]).then(result => {
// result1, result2, result3のうち、一番最初にfulfilledとなったいずれか一つの結果をコンソールに表示する
console.log(result);
});
// awaitも併用可能
const result1 = ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
const result2 = ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
const result3 = ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
const result = await Promise.any([result1, result2, result3]);
Promise.race
引数に渡したPromise配列のうちいずれか一つが完了したタイミングでonFulFilled
またはcatch
の処理に移ります。
Promise.any
と異なり、fulfilled
、rejected
どちらの状態になったとしても処理に移ります。
const result1 = ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
const result2 = ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
const result3 = ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
Promise.race([result1, result2, result3]).then(result => {
// result1, result2, result3のうち、一番最初にfulfilled or rejectedとなったいずれか一つの結果をコンソールに表示する
console.log(result);
});
// awaitも併用可能
const result1 = ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
const result2 = ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
const result3 = ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
const result = await Promise.race([result1, result2, result3]);
for await of ...
ES2018で追加された機能で非同期な反復可能オブジェクトに対して非同期にfor of ...
ループを実行することが可能となります。
非同期な反復可能オブジェクトは前述したGenerator関数にasync
を付けることで生成できます。
1件ずつ順番に非同期処理をループしたい場合等に用いることができますが、逆に処理の実行順序が重要ではない場合にはPromise.all
やPromise.allSettled
を使用する方がよいかもしれません。
正直この機能を使うのに向いている処理が思いつかないので使用例があればぜひコメントで教えてください。
// async function* を用いて生成した非同期な反復可能オブジェクト
const asyncIterator = (
async function* () {
try {
yield ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
yield ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
yield ajaxPromise("https://qiita.com/api/v2/users/BAITO0123/items?page=1");
}
catch (e) {
console.log(e);
}
}
)();
// val = await asyncIterator.next(); のようなイメージ
for await (const val of asyncIterator) {
console.log(val);
}
結論
- 2023年現在、JavaScriptで非同期処理を行う場合は
async/await
が主流 - 簡単かつ同期的に処理を記述でき、非同期処理同士の実行順序をコントロールできてとても便利!
- 加えて
Promise
についても理解しておくと尚良し!
おわりに
さて、JavaScriptにおける非同期処理の歴史をひたすらにまとめてきましたがいかがでしたでしょうか?
JavaScriptの非同期処理は長きにわたって変化を遂げてきました。Ajaxが主流だったころに比べて現在のasync/awaitはかなり記述量が少なく、簡単かつ直感的に分かりやすくなっています。
ひたすらに書く機会の多いJavaScriptの非同期処理がここまで手軽に書けるようになったことに感謝ですね。
皆さんもasync/awaitを使うたびに感謝の気持ちを胸に刻みましょう。
それでは今回はこの辺で失礼します。最後まで読んでいただきありがとうございました!
よかったらいいねボタンやチャンネル登録(フォロー)をよろしくお願いします!!(Youtuber風)
参考