JavaScriptの非同期処理(コールバック地獄/Promise/Async関数)

JavaScriptで非同期処理が重要な理由

シングルスレッド

JavaScriptはシングルスレッドで動作する。そのため一度に実行できるタスクは1つだけとなる。 JavaScriptは、レイアウト・再フロー・ガベージコレクションなどと同じスレッドで実行される。

そのため、JavaScript関数がスレッドを占有すると、ページの反応が悪くなるという問題が発生する。 この問題を非同期関数を用いて緩和する。

参考

非同期関数

非同期処理はコードを順番に処理するが、1つの非同期処理が終わるのを待たずに次の処理を行う。 これにより複数の処理を並列に実行している。

イベントループ

JavaScriptエンジン(v8など)は、非同期関数をイベントループを用いて実行する。 JavaScriptエンジンは、主に以下の3つによって構成されている。

Javascriptエンジン

WebAPIs

ブラウザに搭載されている各種API(DOM, Ajax, timerなど)

イベントキュー/タスクキュー

FIFOで、Web APIから受け取ったCallback関数を保存する

イベントループは以下の流れで非同期処理を実現する。

  1. コールスタックとイベントキューを監視し、コールスタックが空になったら、イベントキューの作業を順番にコールスタックに移動させる。
  2. JavaScriptがメモリ上に展開され、コールスタックで実行される。
  3. Web APIsから提供されているAPIを呼び出すと、Web APIsの実行環境で処理が実行する。
  4. 非同期関数の呼び出しの場合、Web APIsの実行環境内で、条件を満たすまで待機し、条件を満たすとイベントキューに格納される。

参考

非同期関数の例

たとえば、処理を一時停止させる場合はsetTimeout関数を利用して実現する方法がある。

// setTimeout('コールバック関数', 'タイムアウト時間')

function callback(){
    console.log('test')
}

非同期処理が複数重なると、コールバック地獄になり、ネストが深く処理が追いづらくなる。

setTimeout(callback, 1)
setTimeout(() => {
    console.log(1)
    setTimeout(() => {
        console.log(2)
        setTimeout(() => {
            console.log(3)
        }, 3)
    }, 2)
}, 1)

この問題をPromiseを用いることで緩和する。

参考

Promiseオブジェクト[ES2015]

PromiseはES2015で導入された非同期処理の状態や結果を表現するビルトインオブジェクトである。 非同期処理はPromiseのインスタンスを返し、そのPromiseインスタンスには状態変化をした際に呼び出されるコールバック関数を登録できる。

使い方

// asyncPromiseTask関数は、Promiseインスタンスを返す
function asyncPromiseTask() {
    return new Promise((resolve, reject) => {
        // ここが非同期処理
        // 成功時はresolve関数を呼ぶ
        // 失敗時はrejectを関数呼ぶ
    });
}
// thenメソッドで、Promiseがresolve(成功)、reject(失敗)したときに呼ばれるコールバック関数を登録できる。
asyncPromiseTask().then(()=> {
    // 成功したときの処理
}).catch(() => {
    // 失敗したときの処理
});

非同期関数では関数を実行してもすぐには結果がわからない。 そのため、Promiseという非同期処理の状態をラップしたオブジェクトを返し、その結果が決まったら登録しておいたコールバック関数へ結果を渡すという仕組みとなっている。

引数の省略

Promiseのthenメソッドは成功と失敗のコールバック関数の2つを受け取るが、どちらの引数も省略できる。

成功のみのパターン
// `then`メソッドで成功時のコールバック関数だけを登録
asyncPromiseTask().then(() => {
    console.log("成功時のコールバック");
});
失敗のみのパターン
// 非推奨: `then`メソッドで失敗時のコールバック関数だけを登録
errorPromise("thenでエラーハンドリング").then(undefined, (error) => {
    console.log(error.message);
});
// 推奨: `catch`メソッドで失敗時のコールバック関数を登録
errorPromise("catchでエラーハンドリング").catch(error => {
    console.log(error.message);
});

Promiseの状態

Promiseインスタンスには、内部的に次の3つの状態が存在する。

一度でもFulfilledかRejectedとなったPromiseインスタンスは、それ以降別の状態には変化しない。 そのため、以下のような挙動となる。

const promise = new Promise((resolve, reject) => {
    setTimeout(() => {
	 	// 非同期でresolveする
        resolve();
        // すでにresolveされているため無視される
        reject(new Error("エラー"));
		// 二度目以降のresolveやrejectは無視される
		resolve();
    }, 16);
});
promise.then(() => {
    console.log("Fulfilledとなった");
}, (error) => {
    // この行は呼び出されない
});

Promiseチェーン

複数の非同期処理を順番に扱いたい場合に利用するのがPromiseチェーンである。 thenやcatchメソッドは常に新しいPromiseインスタンスを返すため、thenメソッドの返り値にさらにthenメソッドで処理を登録できる。

// Promiseインスタンスでメソッドチェーン
Promise.resolve()
    // thenメソッドは新しい`Promise`インスタンスを返す
    .then(() => {
        console.log(1);
    })
    .then(() => {
        console.log(2);
    });

Promiseの問題点

Promiseには以下のような問題点が存在する。

この問題をAsync関数を利用することで緩和する。

参考

Async関数[ES2017]

Async関数は必ずPromiseインスタンスを返す関数である。関数の前にasyncをつけることで定義できる。

async function doAsync() {
    return "値";
}

// 以下と同等の意味
function doAsync() {
    return Promise.resolve("値");
}

await式

Async関数内ではawait式というPromiseの非同期処理が完了する(FulfilledになるかRejectedになる)まで待つ構文が利用できる。 await式を使うことで非同期処理を同期処理のように扱えるため、Promiseチェーンで実現していた処理の流れを読みやすく書ける。

async function asyncMain() {
    // PromiseがFulfilledまたはRejectedとなるまで待つ
    await Promiseインスタンス;
    // Promiseインスタンスの状態が変わったら処理を再開する
}

await式がエラーをthrowするため、try catchが使えるようになる。

async function asyncMain() {
    try {
        const value = await Promise.reject(new Error("エラーメッセージ"));
    } catch (error) {
        console.log(error.message);
    }
}

参考

Tags: