JavaScriptにおける非同期処理は一種の悪夢です。非同期処理は容易にコードを複雑化させ、品質の低下を招きます。そこでこの問題を解決するため、非同期処理を簡単に扱うことができる、Promiseやasync/awaitという機能が導入されました。この記事では、Promiseとasync/awaitを用いた非同期コードの単純化について簡単な解説をします。
Contents
Promise
実行順序がコード通りにはならない非同期処理
非同期処理とは何でしょうか。非同期な処理は、コードの順番通りには実行されません。どういうことか、簡単な例を見てみましょう。
1 2 |
setTimeout(() => console.log('hello'), 500); console.log('world!'); |
このコードでは500ミリ秒後に「hello」と表示し、その後に「world」を表示しようとしています。ですが、実際には「world」の後に「hello」が表示されます。
1行目のコードは確かに「hello」を表示することを命令していますが、あくまで500ミリ秒後のリクエストでしかありません。リクエストを終えた直後に2行目に移動し、実際には「world」が先に表示され、500ミリ秒が経過してから「hello」が表示されます。
これが非同期処理です。非同期処理では、実行順序はコード通りにはなりません。
コールバック関数で非同期処理の後に処理を行う
非同期処理では実行順序がコード通りにはならないとは言っても、実際には順序が重要な場合があります。最も代表的な例はXHR(XMLHttpRequest)です。XHRを利用すれば、例えば外部ファイルを読み込むことができますが、基本的には非同期処理になります。
ファイルの読み込みでは、「ファイルを読み込み終わった後にxxxする」という処理をしたいことが多いはずです。ですが、非同期処理となると、そのままコードを書いただけではなかなかうまく行きません。つまり、以下のコードは正常に動作しません。
1 2 3 4 |
const xhr = new XMLHttpRequest(); xhr.open('GET', 'foo.txt'); xhr.send(); console.log(xhr.responseText); |
このコードを実行すると、実際にはファイルの読み込みが終わる前にconsole.logが実行されてしまいます。非同期処理に関する実行順序を制御するには、何らかの工夫が必要になります。
非同期処理の実行順序を保つ最も簡単な方法としては、コールバック関数があります。非同期処理の対象にコールバック関数を登録しておき、後から実行してもらうことで、思った通りの順序で実行することが可能になります。
1 2 3 4 |
const xhr = new XMLHttpRequest(); xhr.open('GET', 'foo.txt'); xhr.addEventListener('load', (event) => console.log(xhr.responseText)); xhr.send(); |
addEventListenerはイベントに対応するコールバック関数を登録するメソッドです。XHRのイベントに対応した関数を登録することで、イベントが発生したときに関数を実行することができます。この例ではloadイベントが発生したときに、レスポンスの内容を表示するように指定しています。
コールバック地獄
コールバック関数は優れた解決法のように思えます。ですが現実はもう少し複雑です。たとえば「foo.txt」「bar.txt」「baz.txt」を順番にひとつずつ読み込むコードを考えてみましょう。素直に書くと……
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
const xhr = new XMLHttpRequest(); xhr.open('GET', 'foo.txt'); xhr.addEventListener('load', (event) => { const xhr2 = new XMLHttpRequest(); xhr2.open('GET', 'bar.txt'); xhr2.addEventListener('load', (event) => { const xhr3 = new XMLHttpRequest(); xhr3.open('GET', 'baz.txt'); xhr3.addEventListener('load', (event) => console.log('done!')); xhr3.send(); }); xhr2.send(); }); xhr.send(); |
ええと、正直言ってわけがわかりません。一体何をしているのでしょうか。頑張って少し綺麗なコードにしてみましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
function openFile(url, onload) { const xhr = new XMLHttpRequest(); xhr.open('GET', url); xhr.addEventListener('load', (e) => onload(e, xhr)); xhr.send(); } openFile('foo.txt', (event, xhr) => { openFile('bar.txt', (event, xhr) => { openFile('baz.txt', (event, xhr) => { console.log('done!'); }); }); }); |
これでもまだ複雑です。コードのネストが深く、処理を頭で追いにくくなっています。また、今後処理が増えた場合には、更にネストが深くなってしまいます。こういったコールバック関数の入れ子状態のことを俗に「コールバック地獄」と言います。コールバック地獄はコードの品質を低下させ、様々な問題を発生させます。
非同期処理を記述するPromise
今までは、コールバック地獄を解決する方法はほとんどありませんでした。しかし今は違います。今では、非同期処理を簡単に扱うために導入されたPromiseという仕組みを使うことで解決できます。Promiseは非同期処理を簡潔に記述することができ、コードが単純になります。まずは例を見てみましょう。
1 2 |
const promise = new Promise((resolve, reject) => resolve()); // Promiseを作成し、終了する promise.then(() => console.log('done!')); // Promiseが終了したら「done!」と表示する |
Promiseの仕組み自体は単純です。まずPromiseをnewすることによりPromiseオブジェクトを作成します。Promiseのコンストラクタには、実行したい処理を書いた関数を渡します。処理が済んだら、resolve関数を呼び出すことで終了を明示します。
そしてPromiseオブジェクトのthenメソッドに、Promise終了後に処理したい関数を渡します。これで「Promiseの実行が済んだ後にxxxする」という処理を書くことができます。
このPromiseを利用して非同期処理を記述してみましょう。
1 2 3 4 5 6 7 8 |
const promise = new Promise((resolve, reject) => { setTimeout(() => { console.log('hello'); resolve(); }, 500); }); promise.then(() => console.log('world!')); |
この例は、500ミリ秒後に「hello」と表示し、その後に「world!」を表示するコードになっています。
このコードのPromise内では、500ミリ秒後に「hello」を表示し、同時にresolve関数でPromiseを終了させています。Promiseがresolveされると、thenメソッドに登録した関数が呼ばれ、「world!」が表示されます。
Promiseを利用すると、このようにして、非同期処理の実行順序を簡潔に記述することができます。
Promiseを終了するresolve関数
resolve関数はPromiseを終了させます。resolve関数には値を渡すこともでき、戻り値のような役割を果たします。
1 2 |
const promise = new Promise((resolve, reject) => resolve('done')); // 「done」を結果として終了する promise.then((result) => console.log(result)); // 「done」と表示される |
resolve関数に渡した値は、thenメソッドで受け取ることができます。
resolve関数を利用した例として、XHRをPromiseで書いてみましょう。
1 2 3 4 5 6 7 8 |
const promise = new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('GET', 'foo.txt'); xhr.addEventListener('load', (e) => resolve(xhr.responseText)); xhr.send(); }); promise.then((response) => console.log(response)); |
この例ではXHRのレスポンスをresolve関数に渡しています。
Promiseをエラー終了するreject関数
reject関数はresolve関数とは違い、Promiseをエラー終了させます。reject関数にはresolve関数と同様に値を渡すことができます。
1 2 3 4 |
const promise = new Promise((resolve, reject) => reject('error')); promise.then(() => console.log('done')) // thenは実行されない .catch((e) => console.log(e)); // 「error」とだけ表示される |
reject関数が呼ばれた場合は、thenではなくcatchメソッドで受け取ります。Promiseがrejectされた場合、thenに登録した関数は呼ばれません。
thenチェーン
今までの例だけでは、Promiseの利点がほとんどわからないと思います。なので、もう少し複雑な例を考えてみましょう。500ミリ秒ごとに「hello」「world」「lorem」「ipsum」と表示するコードを書きたいとします。Promiseを使うと以下のように書くことができます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function printAsync(text, delay) { const p = new Promise((resolve, reject) => { setTimeout(() => { console.log(text); resolve(); }, delay) }); return p; } printAsync('hello', 500) .then(() => printAsync('world', 500)) .then(() => printAsync('lorem', 500)) .then(() => printAsync('ipsum', 500)); |
そうです。thenの後に更にthenをつなげて書くことができるのです。thenメソッドをチェーンすることで、複数の非同期処理を直列に書くことができます。
Promiseを使ってコールバック地獄を避ける
コールバック地獄に陥っていたXHRの例をもう一度見てみましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
function openFile(url, onload) { const xhr = new XMLHttpRequest(); xhr.open('GET', url); xhr.addEventListener('load', (e) => onload(e, xhr)); xhr.send(); } openFile('foo.txt', (event, xhr) => { openFile('bar.txt', (event, xhr) => { openFile('baz.txt', (event, xhr) => { console.log('done!'); }); }); }); |
何度見ても酷いコードです。これをPromiseを使って書きなおしてみましょう。すると以下のようになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function openFile(url) { const p = new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('GET', url); xhr.addEventListener('load', (e) => resolve(xhr)); xhr.send(); }); return p; } openFile('foo.txt') .then((xhr) => openFile('bar.txt')) .then((xhr) => openFile('baz.txt')) .then((xhr) => console.log('done!')); |
コードがずいぶん綺麗になりました。入れ子構造が消え去り、thenによるチェーンに置き換わることで、読みやすくなったはずです。また、ここから更に処理が増えたとしても、thenをつなげていくだけなので、これ以上複雑にはなりません。
Promiseを使うことで、コールバック地獄から逃れることができるのです。
複数のPromiseの終了を待つPromise.all
thenをチェーンして直列に書く他にも、Promise.allを利用して複数のPromiseの終了を待つこともできます。XHRの例をPromise.allを利用したものに書き換えてみます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function openFile(url) { const p = new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('GET', url); xhr.addEventListener('load', (e) => resolve(xhr)); xhr.send(); }); return p; } const promise = Promise.all([openFile('foo.txt'), openFile('bar.txt'), openFile('baz.txt')]); promise.then((xhrArray) => console.log('done!')) |
Promise.allにPromiseの配列を渡すことで、複数のPromiseの終了を待つPromiseを作ることができます。このPromiseの終了結果は配列になります。
async/await
Promiseを利用することで非同期処理を簡単に書けることはわかりました。しかし書き方が少し特殊です。そこで非同期処理をもっと簡潔に書けるように、async/awaitという機能が導入されました。
async関数とawait
awaitはPromiseを同期的に展開する(ように見せかける)機能です。Promiseの前にawaitを書くことで、Promiseの終了を待つことができます。
1 2 3 |
const promise = new Promise((resolve, reject) => resolve('hello, world!')); const hw = await promise; console.log(hw); // hello, world! |
awaitキーワードはPromiseの終了を待ち、その結果を展開します。つまりこのコードは、以下のコードと同じような動きをします。
1 2 3 4 |
const promise = new Promise((resolve, reject) => resolve('hello, world!')); let hw; promise.then((result) => hw = result) .then(() => console.log(hw)); |
awaitがPromiseにつけられた場合、Promiseが終了するまで、コードの実行は先に進みません。awaitを利用することで、非同期処理をまるで同期処理のように書くことができます。
ただし、awaitの使用には条件があります。asyncがついた関数の中でしか利用できないのです。つまり、トップレベルでのawaitの使用は不可能です。上の例を実際に動作する形に書き直すと以下のようになります。
1 2 3 4 5 6 7 |
async function helloWorld() { const promise = new Promise((resolve, reject) => resolve('hello, world!')); const hw = await promise; console.log(hw); // hello, world! } helloWorld(); |
async/awaitを使って同期的に書く
Promiseを利用したXHRのファイル読み込みの例をもう一度見てみましょう。「foo.txt」「bar.txt」「baz.txt」を順番通りにひとつずつ読み込むコードです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function openFile(url) { const p = new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('GET', url); xhr.addEventListener('load', (e) => resolve(xhr)); xhr.send(); }); return p; } openFile('foo.txt') .then((xhr) => openFile('bar.txt')) .then((xhr) => openFile('baz.txt')) .then((xhr) => console.log('done!')); |
Promiseを使っているので簡単にはなっていますが、Promise特有の書き方をしているので、すこしとっつきにくさがあります。これをasync/awaitを使って書きなおしてみます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
function openFile(url) { const p = new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('GET', url); xhr.addEventListener('load', (e) => resolve(xhr)); xhr.send(); }); return p; } async function loadAllFiles() { const xhr1 = await openFile('foo.txt'); const xhr2 = await openFile('bar.txt'); const xhr3 = await openFile('baz.txt'); console.log('done!'); } loadAllFiles(); |
非常に同期的なコードになりました。Promiseだけを使ったコードより、更に読みやすくなっています。Promiseとasync/awaitを組み合わせることで、非同期処理を非常に単純に書くことができるようになるのです。