JavaScriptの非同期処理
プログラムは基本的に上から順番に1行ずつ実行されます。これを同期処理と呼びます。
同期処理中に時間がかかる通信やファイルの読み書きなどの処理があると、その分だけサイト全体の関係ない部分が止まってしまいます。
このような問題を防ぐために、非同期処理を使います。
今回はJavaScriptで非同期処理を行う方法をご紹介します。
非同期処理とは
同期処理は上から順番にひとつずつ実行されます。一人しか入れないお店に並んでいるお客さんのようなイメージですね。
非同期処理はボタンが押された時など必要な時に実行されます。お店から特別待遇を受けていて並んでいる列の一番前に割り込めるという感じでしょうか。
ここで重要なのが、並んでいる列は同じということです。
長居するお客さんが先に入っていれば、列の一番前に割り込んだとしても対応に時間がかかります。
非同期処理をする方法のひとつにsetTimeoutという関数があります。これは第一引数に実行したい関数を、第二引数に何ミリ秒後に実行するかを指定する関数です。
// 10ミリ秒後にbobと出力する
setTimeout(() => { console.log("bob"); }, 10);
ですが、setTimeoutを実行した後に、時間がかかる処理を実行すると、指定したミリ秒後に関数が実行されない可能性があります。
// waitMsecミリ秒待つ
function sleep(waitMsec) {
const start = new Date();
while (new Date() - start < waitMsec);
}
const startTime = Date.now();
// 10ミリ秒後に呼び出されるまでの経過時間を出力してほしい
setTimeout(() => {
const endTime = Date.now();
console.log(endTime - startTime + "ミリ秒経過");
}, 10);
console.log("start");
sleep(1000); // 時間がかかる処理
console.log("end");
このコードを実行すると私の環境では1002ミリ秒経過と出力されました。
非同期処理は、あたかも並行に処理しているように見える処理方法です。
非同期処理を使う
非同期処理をするためにJavaScriptにはPromiseというオブジェクトが用意されています。Promiseを使うと非同期処理が簡単に書けます。
const first = new Promise((resolve, reject) { // 同期処理
resolve();
});
const second = first.then(() => { // 非同期処理
console.log("非同期処理");
});
Promiseをコンストラクタ関数として使うとき、引数をふたつ持つ関数が必要です。
これは非同期ではなく同期処理として即座に実行されます。関数内では、resolveかrejectのどちらかひとつを呼び出す必要があります。
このふたつの関数はコンストラクタで渡した関数が正常に実行できたかどうかを判断するために使われます。
・resolveを呼び出すと成功
・rejectを呼び出す又はエラーが発生すると失敗
どちらの関数が実行されたかによって、生成されるオブジェクト(上の例ではfirstに代入される)の状態が変化します。
そして一度どちらかに変化したオブジェクトの状態は変化しません。
const first = new Promise((resolve, reject) {
resolve(); // 成功状態に確定
reject(); // 成功状態で確定しているため失敗状態にならない
});
const second = first.then(() => {
console.log("非同期処理");
});
then関数は自分(今回の例ではfirst)の状態が成功状態になり次第、第一引数で受け取った関数を非同期で実行します。そして戻り値として新たに生成されたPromiseオブジェクトを返します。今回はsecondにそれが代入されています。
今回の例で言うsecondの状態はthen関数の第一引数で決まります。
・then関数が正常に処理を終える 成功
・then関数の戻り値として失敗状態のPromiseオブジェクトを返す 失敗
・then関数内でエラーが発生 失敗
Promiseの状態
Promiseの状態はプロパティではアクセスできません。あくまでも、そういうものが存在するという認識で考えましょう。複雑になってくるので、何度も読み返したり別サイトを調べたりしてみてくださいね。
まずPromiseが持つ状態とその名前を紹介します。
・初期状態(以下の二つの状態ではない) Pending
・関数が成功した状態 Fulfilled
・関数が失敗した状態 Rejected
・状態が確定(FulfilledまたはRejectedのどちらかの状態) Settled
const first = new Promise((resolve, reject) { // 同期処理
resolve(); // Fulfilledに確定
reject(); // Fulfilledで確定しているためRejectedにならない
});
const second = first.then(() => {
console.log("非同期処理");
});
Promiseがnewで生成されるときの状態はPendingです。即座に渡された関数が実行され、今回の例だとresolve関数が実行されるので、状態がFulfilledになります。その後、reject関数が呼ばれますが、現在の状態はSettledのためFulfilledから変化しません。
次にthen関数が呼ばれると、FulfilledのPromiseをもとにPendingのPromiseが生成されます。
Fulfilledがもとになっているため、then関数の第一引数が非同期で実行されます。そして、その処理結果に応じてPromiseの状態が決まります。指定された関数が終わるまでの間はPendingです。今回の例では関数の処理が終わるとsecondの状態はFulfilledになります。
失敗時の処理
PromiseがRejectedになった場合の処理の書き方を以下に示します。
const first = new Promise((resolve, reject) {
reject();
});
// この時点でfirstはRejected
const second = first.then(() => {
// Fulfilledだった場合の処理 今回は実行されない
},() => {
console.log("Rejected");
});
thenの第二引数にRejectedのときに実行したい関数を指定します。
RejectedのPromiseに対してthen関数を呼び出すと、Fulfilledではないため第一引数は実行されず、第二引数が非同期で実行されます。
このときもFulfilledと同じように、指定した関数が終了するまでsecondはPendingになり、関数が終了するとFulfilledかRejectedの状態に変化します。
先ほどの例以外にも以下のように書けます。
const first = new Promise((resolve, reject) {
reject();
});
// この時点でfirstはRejected
const second = first.then(() => {
// Fulfilledだった場合の処理
});
const third = second.catch(() => {
console.log("Rejected");
});
catch関数でRejectedだった場合の関数を指定しています。
catch関数を使った方法でも処理の流れは変わりません。
連続して非同期処理を行う
今までの例では説明のために、わかりやすくなるよう書き方を少し変えていました。実際はもっと簡単に書けます。
今までコンストラクタ関数を使っていた最初の部分ですが、特別な処理の必要がない場合、以下のように書けます。
// FulfilledのPromiseを取得
const first = Promise.resolve();
また、前回までは一回ずつに変数に格納していましたが、Aの関数が終了したらB、Bの関数が終了したらCというように連続して関数を非同期で実行したい場合は以下のように書くのがベターです。
Promise.resolve().then(() => { // 非同期で実行
// Aの関数
}).then(() => { // Aの処理が終了して、Fulfilledになり次第実行
// Bの関数
}).then(() => { // Bの処理が終了して、Fulfilledになり次第実行
// Cの関数
});
この書き方はthen関数の戻り値がPromiseであるためできる書き方でメソッドチェーンなどと呼びます。
エラーが起きた場合の処理も追加してみましょう。
Promise.resolve().then(() => { // 非同期で実行
// Aの関数
}).then(() => { // Aの処理が終了して、Fulfilledになり次第実行
// Bの関数
}).then(() => { // Bの処理が終了して、Fulfilledになり次第実行
// Cの関数
}).catch(() => { // Rejectedになり次第実行
// エラー時の処理 今回の説明ではDと呼ぶ
});
末尾にcatch関数を追記してみました。
Aでエラーが起こった場合、Promiseの状態はRejectedになります。本来Bの処理を実行したいですが、Fulfilledではないため実行できません。CはBの結果によって決まるため、同様に実行できません。
Aでエラーが起こった場合、CとBを飛ばしてcatch関数で指定したDが非同期で実行されます。
次にBでエラーが起こった場合、同様にCを飛ばしてDが非同期で実行されます。Cでエラーが起こった場合はあいだにthenがないので、そのままDを非同期で実行します。
A、B、Cで何もエラーが起こらなかった場合、RejectedにならないためDは実行されません。
どうやら末尾にcatch関数を書くとあいだにあるthenで指定した関数を飛ばしてしまうようです。
次は以下の例を見てみましょう。
Promise.reject().then(() => { // 非同期で実行
// Aの関数
}).catch(() => { // Aの処理が終了して、Rejectedになり次第実行
// Dの関数
}).then(() => { // Aの処理が終了して、Fulfilledになり次第実行
// Bの関数
}).then(() => { // Bの処理が終了して、Fulfilledになり次第実行
// Cの関数
});
catch関数をAの後に書いてみました。何もエラーが起こらなかった場合、A、B、Cと順番に処理されて終わりです。
また、BかCでエラーが発生してもDは実行されません。
そしてAでエラーが発生した場合、もちろんDが実行されます。Dの関数内でエラーが起こらなかった場合、状態がFulfilledになるため次のBが実行されます。そしてBの結果によってCが実行されるという形です。
ではAが実行されない場合を考えてみましょう。
Promise.resolve関数で最初のPromiseオブジェクトを作成していましたが、Rejected状態のPromiseオブジェクトを作成することもできます。
Promise.reject().then(() => { // 非同期で実行
// Aの関数
}).catch(() => { // Aの処理が終了して、Rejectedになり次第実行
// Dの関数
}).then(() => { // Aの処理が終了して、Fulfilledになり次第実行
// Bの関数
}).then(() => { // Bの処理が終了して、Fulfilledになり次第実行
// Cの関数
});
こうすることで、最初にRejectedのPromiseが作成されるため、Aが実行されません。RejectedのためDが実行されその後結果によってBとCが実行されます。
処理結果の受け渡し
ファイルを非同期で読み込んだ時、次の非同期の処理としてファイルの内容を使いたいけれど、エラー時の処理は別にしたいなど、いろいろなパターンに対応するため、処理結果の受け渡しについてみていきましょう。
new Promise((resolve, reject) => { // 同期処理
resolve(10); // 数値10を次の関数に渡す。
}).then((n) => { // 非同期処理
console.log("A: " + n) // A: 10
});
// 省略した書き方
Promise.resolve(10).then((n) => { // 非同期処理
console.log("A: " + n); // A: 10
});
resolve関数の引数として渡したい値を指定すると、その関数の結果がFulfilledになった場合、then関数で指定した関数に渡されれます。
rejectの場合もthen関数をcatch関数に変えると同様です。
new Promise((resolve, reject) => { // 同期処理
reject(10); // 数値10を次の関数に渡す。
}).catch((n) => { // 非同期処理
console.log("A: " + n) // A: 10
});
// 省略した書き方
Promise.reject(10).catch((n) => { // 非同期処理
console.log("A: " + n); // A: 10
});
もしエラーが発生した場合は、スローされたエラーオブジェクトも引数で指定したように設定されます。
new Promise((resolve, reject) => { // 同期処理
throw new Error("エラー発生");
}).catch((err) => { // 非同期処理
console.log(err.message)
});
次は連続して非同期処理をしたい場合を考えてみましょう。
そこで問題になるのはコンストラクタで指定する関数以外で、引数によって受け取れていたresolveやrejectが存在しないことです。
new Promise((resolve, reject) => {
resolve(10);
}).then((n) => {
console.log("A: " + n); // A: 10
// resolve(n)したいけど変数resolveがない
}).then((n) => {
console.log("B: " + n); // B: undefined
});
非同期で処理したい関数間でデータを受け渡すときは、ひとつの方法として戻り値を使います。
new Promise((resolve, reject) => {
resolve(10);
}).then((n) => {
console.log("A: " + n); // A: 10
return n; // 次の関数に渡す
}).then((n) => {
console.log("B: " + n); // B: 10
});
また戻り値にPromiseオブジェクトを指定した場合は特殊な動きをします。説明のためメソッドチェーンを使わずにコードを示します。
const testFunc = () => {
console.log("A: " + n);
};
const promiseOrig = Promise.resolve(testFunc);
const promise = new Promise((resolve, reject) => {
resolve(10);
});
const promise2 = promise.then((n) => {
return promiseOrig; // Promiseオブジェクトを返す
});
const promise3 = promise2.then((n) => {
console.log(n == promiseOrig); // false
console.log(n == testFunc); // true
});
promise2の処理でpromiseOrigを返しているのに、promise3ではpromiseOrigは来ていません。
これはthenやcatchで指定した関数の戻り値でPromiseオブジェクトを指定すると、自身がそのPromiseオブジェクトとほぼ同等になるという性質のせいです。
これは意図的に関数の結果を失敗(PromiseをRejected)にしたいときに役立ちます。
Promise.resolve().then(() => {
const file = // ファイル読み込みなど;
if(file) { // 失敗かどうかの判定 (ファイル内に指定の記述が存在するかなど)
return Promise.reject(new Error("失敗")); // 意図的にエラーを出したい
}
return file; // 成功時の値
}).catch((err) => {
console.log(err.message)
}).then((file) => {
console.log(file);
})
このように書くことで、エラーを出したいと言う意図が明確になります。
Promise内のエラーは自動的にcatchされるのでthrowでも同じような処理の流れを表現できますが、コードのミスによって発生したバグか、スローされた意図的なエラーかの見分けがつきにくくなります。
たとえばChromeの開発者向けの機能にはエラーがスローされたら自動的にプログラムを一時停止し、何が問題か確認できる機能があります。これを有効にしていた場合、先述したように見分けられない可能性があります。
複数の非同期処理を扱う
サーバーとの通信を想定時間内にしたい、通信中に想定時間を超えたなら処理を中断したいというプログラムはどうすれば表現できるでしょうか。
通信処理とは別に想定する時間を超えたかどうか判定する必要があります。
// 説明用のオブジェクト
const request = {
send: function() {
// 通信処理
}
abort: function() {
// send関数中断処理
}
}
Promise.resolve().then(() => { // 非同期処理
const response = request.send(); // 想定時間を超えたら中断したい
return response;
}).then((response) => {
console.log(response); // 中断した場合実行したくない
});
このコードを例に考えていきましょう。
今回問題となるのは想定時間後にabortをどうやって呼ぶかという点です。まず思いつくのは、setTimeout関数が使えそうです。
setTimeoutは第一引数に「第二引数で指定した時間後に実行する関数」を受け取ります。ただ、以下のようにsetTimeoutで指定した関数内でPromiseオブジェクトをreturnしても、そのオブジェクトは取り出せません。
// 説明用のオブジェクト
const request = {
send: function() {
// 通信処理
}
abort: function() {
// send関数中断処理
}
}
// 想定時間
const time = 1000;
Promise.resolve().then(() => { // 1.非同期処理
setTimeout(() => { // 1とは別の非同期処理
if(/* 通信処理が終わっているなら */) {
return ; // 時間内に処理が終わっているため何もしない
}
// 通信処理を中断する
request.abort();
return Promise.reject() // NG
}, time);
const response = request.send(); // 想定時間を超えたら中断したい
return response;
}).then((response) => {
console.log(response); // 中断した場合実行したくない
}).catch((err) => {
// タイムアウト時の処理もここで書けそう
});
setIntervalの引数として指定した関数内でreturnしても値が取り出せない理由はreturnする関数が違うからです。then関数の引数で指定した関数から直接retrunする必要があります。
どうやらreturnする場所が間違っていたようです。そこで以下のように変えてみます。
// 説明用のオブジェクト
const request = {
send: function() {
// 通信処理
}
abort: function() {
// send関数中断処理
}
}
// 想定時間
const time = 1000;
Promise.resolve().then(() => { // 1.非同期処理
let result = null;
setTimeout(() => { // 1とは別の非同期処理
if(result !== null) {
return ; // 時間内に処理が終わっているため何もしない
}
// 通信処理を中断する
request.abort();
result = Promise.reject(new Error("タイムアウト"));
}, time);
const response = request.send(); // 想定時間を超えたら中断したい
if(result === null) { // タイムアウトしてなければ結果を格納
result = response;
}
return result;
}).then((response) => {
console.log(response); // 中断した場合実行したくない
}).catch((err) => {
// タイムアウト時の処理
});
resultという変数に結果を入れて、それをreturnするようになっています。
複雑になってきました。今回は簡略化して書いているので、実際の処理だともっと複雑かもしれません。
複雑なコードは理解しにくくてバグが起こりやすそうです。
もっとシンプルに書くために、とりあえず非同期処理を完全に分けてみます。
// 説明用のオブジェクト
const request = {
send: function() {
// 通信処理
}
abort: function() {
// send関数中断処理
}
}
// 想定時間
const time = 1000;
// 通信用の非同期処理
Promise.resolve().then(() => { // 非同期処理
const response = request.send(); // 想定時間を超えたら中断したい
return response;
}).then((response) => {
console.log(response); // 中断した場合実行したくない
});
// タイムアウト用の非同期処理
new Promise((resolve, reject) => { // 同期処理
setTimeout(() => { // 非同期処理
abort(); // 通信の中断
reject(new Error("タイムアウト"));
}, time)
}).catch((err) => {
// タイムアウト時の処理
});
こちらのほうが通信用の非同期処理はシンプルになりました。
ただ、このコードは致命的なバグがあります。通信が中断されるとき、もしsend関数内でエラーがスローされずにnullを返すという仕様になっていた場合、正常に関数が処理された扱いになり、その後の非同期処理でnullが出力されてしまいます。
通信が中断された場合、通信後に行いたい処理は動かないでほしいです。
このようなときPromise.raceという関数が使えます。
これは引数として、Promiseの配列を指定して実行する関数です。戻り値として、ひとつのPromiseを返します。指定されたPromiseいずれかひとつの状態がSettledになると、自身(Promise.raceの戻り値)もそれと同じ状態になり、他のPromiseを待たずにthen関数またはcatch関数で指定した関数が実行されます。
// 説明用のオブジェクト
const request = {
send: function() {
// 通信処理
}
abort: function() {
// send関数中断処理
}
}
// 想定時間
const time = 1000;
// 通信用の非同期処理
const sendPromise = Promise.resolve().then(() => { // 非同期処理
const response = request.send(); // 想定時間を超えたら中断したい
return response;
});
// タイムアウト用の非同期処理
const timeoutPromise = new Promise((resolve, reject) => { // 同期処理
setTimeout(() => { // 非同期処理
abort(); // 通信の中断
reject(new Error("タイムアウト"));
}, time)
});
Promise.race([sendPromise, timeoutPromise]).then((response) => {
console.log(response); // 中断した場合実行したくない
}).catch((err) => {
// タイムアウト時の処理
});
こうすることで、通信後の処理とタイムアウト時の処理のどちらかを実行させることができます。
またPromise.raceはいずれかひとつのPromiseの処理が終了すれば自身の状態も確定します。それと違って、Promise.allという関数を使うと、与えられた全てのPromiseがFulfilledになれば自身もFulfilledになり、反対にいずれかひとつがRejectedになると自身もRejectedになります。
これはすべての通信結果を用意したうえで処理を行いたい場合などに使います。
まとめ
今回は非同期処理にて使うPromiseについてご紹介しました。
・同期処理は上から順に実行される。
・非同期処理は必要な時に割り込んで実行される。
・非同期処理はPromiseが使える
・Promiseは状態によって次の動作が決まる
・Promiseはメソッドチェーンで書ける
・Promiseで非同期で実行する関数間のデータのやり取りも可能
・Promiseで複数の非同期も扱える