JavaScriptにおける非同期処理は一種の悪夢です。非同期処理は容易にコードを複雑化させ、品質の低下を招きます。そこでこの問題を解決するため、非同期処理を簡単に扱うことができる、Promiseやasync/awaitという機能が導入されました。この記事では、Promiseとasync/awaitを用いた非同期コードの単純化について簡単な解説をします。
Promise
実行順序がコード通りにはならない非同期処理
非同期処理とは何でしょうか。非同期な処理は、コードの順番通りには実行されません。どういうことか、簡単な例を見てみましょう。
setTimeout(() => console.log('hello'), 500);
console.log('world!');
このコードでは500ミリ秒後に「hello」と表示し、その後に「world」を表示しようとしています。ですが、実際には「world」の後に「hello」が表示されます。
1行目のコードは確かに「hello」を表示することを命令していますが、あくまで500ミリ秒後のリクエストでしかありません。リクエストを終えた直後に2行目に移動し、実際には「world」が先に表示され、500ミリ秒が経過してから「hello」が表示されます。
これが非同期処理です。非同期処理では、実行順序はコード通りにはなりません。
コールバック関数で非同期処理の後に処理を行う
非同期処理では実行順序がコード通りにはならないとは言っても、実際には順序が重要な場合があります。最も代表的な例はXHR(XMLHttpRequest)です。XHRを利用すれば、例えば外部ファイルを読み込むことができますが、基本的には非同期処理になります。
ファイルの読み込みでは、「ファイルを読み込み終わった後にxxxする」という処理をしたいことが多いはずです。ですが、非同期処理となると、そのままコードを書いただけではなかなかうまく行きません。つまり、以下のコードは正常に動作しません。
const xhr = new XMLHttpRequest();
xhr.open('GET', 'foo.txt');
xhr.send();
console.log(xhr.responseText);
このコードを実行すると、実際にはファイルの読み込みが終わる前にconsole.logが実行されてしまいます。非同期処理に関する実行順序を制御するには、何らかの工夫が必要になります。
非同期処理の実行順序を保つ最も簡単な方法としては、コールバック関数があります。非同期処理の対象にコールバック関数を登録しておき、後から実行してもらうことで、思った通りの順序で実行することが可能になります。
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」を順番にひとつずつ読み込むコードを考えてみましょう。素直に書くと……
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();
ええと、正直言ってわけがわかりません。一体何をしているのでしょうか。頑張って少し綺麗なコードにしてみましょう。
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は非同期処理を簡潔に記述することができ、コードが単純になります。まずは例を見てみましょう。
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を利用して非同期処理を記述してみましょう。
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関数には値を渡すこともでき、戻り値のような役割を果たします。
const promise = new Promise((resolve, reject) => resolve('done')); // 「done」を結果として終了する
promise.then((result) => console.log(result)); // 「done」と表示される
resolve関数に渡した値は、thenメソッドで受け取ることができます。
resolve関数を利用した例として、XHRをPromiseで書いてみましょう。
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関数と同様に値を渡すことができます。
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を使うと以下のように書くことができます。
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の例をもう一度見てみましょう。
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を使って書きなおしてみましょう。すると以下のようになります。
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を利用したものに書き換えてみます。
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の終了結果は配列になります。
(2019/11/27追記) Promiseコードレシピ集
新しい記事「PromiseによるJavaScript非同期処理レシピ集」を書きました。この新しい記事ではPromiseを使ったコーディングパターンについて記述しています。併せて読んでみてください。
async/await
Promiseを利用することで非同期処理を簡単に書けることはわかりました。しかし書き方が少し特殊です。そこで非同期処理をもっと簡潔に書けるように、async/awaitという機能が導入されました。
async関数とawait
awaitはPromiseを同期的に展開する(ように見せかける)機能です。Promiseの前にawaitを書くことで、Promiseの終了を待つことができます。
const promise = new Promise((resolve, reject) => resolve('hello, world!'));
const hw = await promise;
console.log(hw); // hello, world!
awaitキーワードはPromiseの終了を待ち、その結果を展開します。つまりこのコードは、以下のコードと同じような動きをします。
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の使用は不可能です。上の例を実際に動作する形に書き直すと以下のようになります。
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」を順番通りにひとつずつ読み込むコードです。
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を使って書きなおしてみます。
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を組み合わせることで、非同期処理を非常に単純に書くことができるようになるのです。