この記事は「非同期処理について」の補足記事になります。
併せてそちらもご覧ください。
概要
ここではJavaScriptを用いた基本的な非同期処理の書き方について紹介します。なお、今回は非同期処理の代表例であるsetTimeout 関数を用いて解説していきます。
といってもいきなりモダンなコードを見ても理解しにくいのでJavaScriptの非同期処理の書き方について、歴史的な遷移を交えながら見ていきましょう。
コールバック地獄
まずはJavaScriptに非同期処理が登場した初期の書き方を紹介します。
例として、以下のコードを実行すると最初に処理A、Bが実行され、その1秒後にsetTimeout内の処理が行われます。同期処理であれば上から順に実行されていく(A→1秒経過→Bと処理される)わけなので、しっかりと非同期処理を行えていることが確認できます。
console.log('A');
setTimeout(() => {
console.log('1秒経過');
}, 1000);
console.log('B');
// A
// B
// 1秒経過
【補足:setTimeout関数について】
setTimeout 関数について少しだけ補足します。setTimeout は指定した秒数(ms単位)で非同期処理を実施することができる関数です。
例えば以下のコードを実行すると上から順番に”async”、”sync”と実行されるのではなく、先に同期処理である”sync”が実行されされた1秒後に”async”が実行されます。
setTimeout(() => {
console.log('async');
}, 1000);
console.log('sync');
// sync
// async
では非同期処理で1秒経過して処理が実行された後に、さらに別の処理を1秒後に実行したいとなった場合はどのように実行すれば良いでしょうか?
例えば下のようなコードで実行を行うと同期処理Bが終了した直後に1秒経過の処理が2回同時に実行されてしまい意図した通りには動作してくれません。
console.log('A');
setTimeout(() => {
console.log('1秒経過');
}, 1000);
setTimeout(() => {
console.log('1秒経過');
}, 1000);
console.log('B');
// A
// B
// 1秒経過
// 1秒経過
このように非同期処理が終わった後に別の非同期処理を実行させたいときは非同期処理を入れ子構造で実行する必要があります。下のコードのように実行すると意図した通りに1秒経過の処理が実行された1秒後に次の処理が実行されます。
console.log('A');
setTimeout(() => {
console.log('1秒経過');
setTimeout(() => {
console.log('1秒経過');
}, 1000);
}, 1000);
console.log('B');
// A
// B
// 1秒経過
// 1秒経過
ではこれに加えてさらに1秒経過後に新たに処理を実行したいとなった場合はどうすればよいでしょうか?これは単純にネストを深くしていけば解決できます。
console.log('A');
setTimeout(() => {
console.log('1秒経過');
setTimeout(() => {
console.log('1秒経過');
setTimeout(() => {
console.log('1秒経過');
}, 1000);
}, 1000);
}, 1000);
console.log('B');
// A
// B
// 1秒経過
// 1秒経過
// 1秒経過
このようにネストを深くしていくことで非同期処理を順番に扱うことができるようになります。
しかし、このコードの書き方は1つ重大な欠点を抱えていました。それはネストが深くなるにつれて急激に可読性が低下するという点です。このコードの書き方はコールバック地獄と呼ばれ、特に保守や修正時には大きな負担としてのしかかっていました。
Promise
コールバック地獄を解消するために新たに開発されたのがPromiseオブジェクトになります。まずPromiseについての説明を行い、その後先ほどのコールバック地獄のコードをPromiseを用いた場合にはどうなるのかを紹介していきます。
Promiseオブジェクト
Promiseとはひとことで言うと「将来完了するかもしれない処理を管理するオブジェクト」となります。つまりPromiseとはまだ終わっていない処理の結果を扱う仕組みと言えます。そして、Promiseでは処理がまだ終わっていないのか、はたまた完了したのかに応じて次の3つのいずれかの状態を取るようになっています。
| 状態 | 意味 |
|---|---|
| 待機 (pending) | 処理がまだ完了していないことを表す |
| 履行 (fulfilled) | 処理が成功したことを表す |
| 拒否 (rejected) | 処理が失敗したことを表す |
そしてこのPromiseオブジェクトでは非同期処理はもちろん、同期処理も扱うことができます(Promiseオブジェクトが宣言された=非同期処理、ではないので注意)。
まずは理解のしやすい同期処理を使ってPromiseの3つの状態を実際に動かしながら、その特性を確認していきましょう。
下のコードのように単純にPromiseオブジェクトをインスタンス化してその内容を見てみます。するとPromiseの状態はpending となりました。具体的な処理が定義されておらず完了も何もないので待機状態として扱われていることがわかります。
const promise = new Promise(() => {});
console.log(promise);
// [[PromiseState]]: "pending"
次にfulfilled 、rejected についての動作も確認してみましょう。下のコードのようにブール型を返す処理をPromiseオブジェクトで扱ってみます。
まずPromiseの状態を明示するために必要なresolve とreject メソッドをコールバックで用意します(Promiseのインスタンス時にアロー関数で2つ引数を定義すると自動でこの2つのメソッドを定義してくれます)。そして具体的な処理を書いて末尾に成功したときのものであればresolve() 、失敗したときのものであればreject() と記述します。こうすることで記述したときの処理が完了したときの状態をfulfilled かrejected のどちらにするかを指定できます。
今回のコードであれば処理が成功したとき(proc()=true)であればPromiseの状態をfulfilled にしたいので、条件式でtrueであることを確認したのちresolve メソッドを呼び出しています。こうすることで実際にコードを動かすとPromiseがfulfilled の状態になることがわかります。逆の場合も、reject メソッドを呼び出すことでコードを動かしたときにはPromiseがrejected 状態になっていることが見てわかります。
const promise = new Promise((resolve, reject) => {
const result = proc();
if (result === true) {
resolve();
} else {
reject();
}
});
console.log(promise);
function proc() {
// 処理
return true;
}
// ▸ proc() → trueのとき
// [[PromiseState]]: "fulfilled"
// ▸ proc() → falseのとき
// [[PromiseState]]: "rejected"
なお、ここで1つ注意すべき点があります。それはPromise内の処理でresolve またはreject メソッドを記述しなかった場合は処理が終わっていてもpending のままになってしまう、ということです。
const promise = new Promise((resolve, reject) => {
let result = 1 + 2;
console.log(result);
});
console.log(promise);
// 3
// [[PromiseState]]: "pending"
上記のコードのようにresolve 、reject のどちらも使わずに実行すると計算処理は完了して表示されているにも関わらずPromiseは待機状態となってしまいます。
そのためPromise実装時にはresolve またはreject を用いて記述した処理が終了したときの状態をどう扱うのかを明示する必要があります。
thenとcatch
Promiseにはpending 、fulfilled 、rejected の3つの状態がありresolve 、reject メソッドによりPromise内の処理が完了したときの状態を指定できるということがわかりました。実はこのPromiseの状態をうまく使うことである処理が完了したら次の処理を実行させる、といった制御が可能になります。それがthen とcatch です。
先ほどの例ではproc関数の結果がtrueであればfulfilledを、falseであればrejectedの状態にするようコードを書いていました。今回はproc関数の処理がtrue、即ち成功したら”ok”と出力を行い、false、即ち失敗したら”error”と出力を行うようコードを追加してみましょう。
const promise = new Promise((resolve, reject) => {
const result = proc();
if (result === true) {
resolve('ok');
} else {
reject('error');
}
});
promise
.then((value) => console.log(value))
.catch((value) => console.log(value));
function proc() {
return true;
}
// ▸ proc() → trueのとき
// ok
// ▸ proc() → falseのとき
// error
上記のようにコードを書いて実行するとproc関数がtrueであればokを、falseであればerrorを出力します。この動作でカギとなるのがthen /catch メソッドです。
then メソッドはPromiseがfulfilled 状態になったときに、catch メソッドはrejected 状態になったときにそれぞれ呼び出されます。つまりこのthen またはcatch メソッド内に記述した処理は呼び出し元のPromiseが完了状態(履行 or 拒否状態)となったときに実行されます。
なのでproc関数の結果が、
true → Promiseは履行状態 → thenメソッド内の関数が実行される
false → Promiseは拒否状態 → catchメソッド内の関数が実行される
という流れになります。
※ ok, errorの表示に関しては今回はresolve またはreject メソッドの引数に一度渡して表示する形で実装しましたがもちろん、以下のように引数として渡さずにthen /catch に直接関数を書いても動作します。
const promise = new Promise((resolve, reject) => {
const result = proc();
if (result === true) {
resolve();
} else {
reject();
}
});
promise
.then(() => console.log('ok'))
.catch(() => console.log('error'));
Promiseのまとめ
長くなってきたのでPromiseの使い方についてこれまで学んできたことをまとめます。
まず、Promiseとは処理が完了したかそうでないかを管理するオブジェクトであり処理の状況に応じて次の3つの状態になるのでした。
① 待機状態(pending):処理がまだ完了していない状態
② 履行状態(fulfilled) :処理が成功して終了した状態
③ 拒否状態(rejected):処理が失敗して終了した状態
そしてPromise内で記述した処理についてその処理が終了したときに成功したとみなすのか、失敗したとみなすのかはresolve /reject メソッドを用いて定義を行うのでした。
処理に関して、Promiseの状態がfulfilled /rejected に遷移したら次の処理の実行を開始したいという時にはthen /catch メソッドを用いてその中に処理を関数で記述を行うのでした。
Promiseと非同期処理
ここまでPromiseについて詳しく見てきましたが、実はPromiseをうまく使うことで非同期処理の欠点であったコールバック地獄を解消することができるのです。先ほどのコールバック地獄のコードをPromiseを使って書くと下記のようになります。
console.log('A');
promise = new Promise((resolve) => {
setTimeout(() => {
console.log('1秒経過');
resolve();
}, 1000);
});
promise
.then(() => {
return new Promise((resolve) => {
setTimeout(() => {
console.log('1秒経過');
resolve();
}, 1000);
});
})
.then(() => {
return new Promise((resolve) => {
setTimeout(() => {
console.log('1秒経過');
resolve();
}, 1000);
})
});
console.log('B');
// A
// B
// 1秒経過
// 1秒経過
// 1秒経過
非同期処理で書いていますが、基本の原理はPromiseを同期処理で書いていたときと同じです。順に説明していきます。
まず最初にPromiseオブジェクトに実行させたい非同期処理を書きます。そしてここが1つポイントなのですが、setTimeout内の処理(ここではconsole.log('1秒経過') )が完了した時点でresolve メソッドを呼び出し履行状態にします。こうすることで1つ目のsetTimeoutの処理が完了したらthen メソッドにより2つ目のsetTimeoutの処理が呼び出されます。
そして2つ目のsetTimeout処理も最初と同様にPromiseオブジェクトを用意して処理を記述して末尾にresolve メソッドつける、といった具合で実装していきます。2つ目のsetTimeout処理が完了するとPromiseが履行状態になり次のthen メソッドに記載された3つ目のsetTimeout処理実行されていく…という流れで前の非同期処理が終わったら次の処理を実行するという動作を実装することができます。
少し余談になりますが、現在のコードだと都度Promiseをインスタンス化していますが内容を見ると非同期部分の処理がほぼ同じなのでリファクタリングしてみます。
新たにsleep 関数を定義してその中にPromiseのインスタンス化と指定した秒数でsetTimeoutが実行されるようにしました。こうすることでコードの可読性をさらに向上できます。
console.log('A');
const sleep = ((milliseconds) => {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`${milliseconds/1000}秒経過`);
resolve();
}, milliseconds);
});
})
sleep(1000)
.then(() => sleep(1000))
.then(() => sleep(1000));
console.log('B');
ここでコードに注目してみると非同期処理を呼び出すときにネスト構造からthen メソッドを積み重ねた、いわば同期処理に近い書き方に変わっていることがわかると思います。このようにPromiseを使用すると従来のコールバック地獄を解消し、可読性を大幅に向上させることができる、というのがPromiseを扱う大きなメリットです。
このPromiseの登場は革命的と呼ばれるほど画期的な仕組みでJavaScriptの非同期処理における重大な転換点と評価を受けることもあります。しかし、現在ではこのPromiseを用いたコーディング方法からさらに一歩発展した手法が用いられることが多いです。それが現在、非同期処理において主流で用いられているasync /await です。
async / await
現在ではPromiseの書き方からさらに少し発展したasync /await を用いて非同期処理を書くのが主流になっています。まずは簡単にPromiseの書き方について再度見ていきます。
const sleep = ((milliseconds) => {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`${milliseconds/1000}秒経過`);
resolve();
}, milliseconds);
});
})
sleep(1000)
.then(() => sleep(1000))
.then(() => sleep(1000));
上のコードは今まで見てきたコードのうち、非同期処理の部分だけを取り出してきたものです。非同期処理の呼び出し箇所を見てみるとthen メソッドがプロミスチェーンで(数珠つなぎに)書かれていることがわかります。従来のコールバック地獄から比べれば格段にコードの見通しは良くなりましたがそれでもまだ普通のコードと比べると少し可読性や書きやすさに劣ります。
そこでさらにコードを書きやすく、そして読みやすくしようとして生まれたのがasync /await になります。async /await ではPromiseの考え方を生かしつつ、非同期処理の呼び出し方をよりわかりやすく改良したものです。
まずは一つ、今までのコードをasync /await に置き換えて実例を詳しく見ていきましょう。
console.log('A');
const sleep = ((milliseconds) => {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`${milliseconds/1000}秒経過`);
resolve();
}, milliseconds);
});
})
async function asyncCall() {
await sleep(1000);
await sleep(1000);
await sleep(1000);
}
asyncCall();
console.log('B');
// A
// B
// 1秒経過
// 1秒経過
// 1秒経過
書き換えたコードを見てわかる通り、基本的な使い方はPromiseと大きな変わりはないことがわかると思います。Promiseを用いた非同期処理の動作と完了時のPromiseの状態をresolve /reject で指定する部分はほぼ変わりありません(関数で定義するようにしただけ)。
変わっている部分は非同期処理の呼び出しの方になります。まず非同期処理を扱うときには一つ呼び出し用の関数を設けて先頭にasync をつけます。そしてPromiseオブジェクトを返す関数に対してawait をつけると、Promiseオブジェクトが履行状態になるまで待ってから処理の実行を行います(つまりawait とPromiseのthen は書き方こそ違えど内部的な処理は同じ!)。
ここで再度コードを見てみると、async /await をつけていること以外は非同期処理の呼び出し方が同期処理と同じように書けていることがわかると思います。従来のthen のようにプロミスチェーンをつなげて書く必要がなく、より直観的で自由度の高い書き方になっていることが見てわかると思います。これがasync /await の大きなメリットです。
【補足:then/catchとtry/catch】
Promiseの書き方のところでPromiseの状態が履行状態であればthen 、拒否状態であればcatch に記述した関数がそれぞれ実行されると紹介しましたが、async /await ではtry /catch を用いることで同様に実装できます。
▸Promiseのthen/catchを用いた場合
const promise = new Promise((resolve, reject) => {
const result = proc();
if (result === true) {
resolve('ok');
} else {
reject('error');
}
});
promise
.then((value) => console.log(value))
.catch((value) => console.log(value));
function proc() {
return true;
}
▸async/awaitでtry/catchを用いた場合
const task = () => {
return new Promise((resolve, reject) => {
const result = proc();
if (result === true) {
resolve('ok');
} else {
reject('error');
}
});
}
async function asyncCall() {
try {
const value = await task();
console.log(value);
} catch (value) {
console.log(value);
}
}
function proc() {
return true;
}
asyncCall();
まとめ
ここでは非同期処理の書き方について見てきました。現在のJavaScriptにおける非同期処理はPromiseをベースとして同期処理のような形式で記述ができるasync /await を用いてコーディングが行われることが多いです。なので非同期処理の動作に合わせてPromiseの挙動についても知っておくと実践の場での理解がしやすくなると思います。