Subterranean Flower

Promiseとasync/awaitでJavaScriptの非同期処理をシンプルに記述する

Author
古都こと
ふよんとえんよぷよぐやま!!!

JavaScriptにおける非同期処理は一種の悪夢です。非同期処理は容易にコードを複雑化させ、品質の低下を招きます。そこでこの問題を解決するため、非同期処理を簡単に扱うことができる、Promiseasync/awaitという機能が導入されました。この記事では、Promiseasync/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を組み合わせることで、非同期処理を非常に単純に書くことができるようになるのです。