新しいことにはウェルカム

技術 | 電子工作 | ガジェット | ゲーム のメモ書き

JavaScriptのasync/awaitをもう少しちゃんと理解する

今までJavaScriptのasync/awaitを、理解が曖昧なまま何となく使っていて、うまく行かない時はPromiseを使ったりしていました。

しかし、最近はasync/awaitが使われているのをよく目にするようになってきたため、もう少しちゃんと理解しないといけないかなぁと思いはじめ、その理解メモです。

async/awaitの基本

まずは基本から。async/awaitを使う目的は、非同期処理を同期処理っぽく、順番に記述できるところでしょうか。

基本的な機能は下記のようなところです。

  • Promiseを返す関数をawaitで呼び出すと、次の処理には移らず、Promiseが返るまで待っていてくれる。
  • awaitは、PromiseからPromiseに格納した実値を取り出して返してくれる。
  • Promiseを返す関数がrejectした場合は、awaitは例外を返す。
  • awaitの例外はtry/catchのcatchで受ける。
  • awaitを使う関数はasync関数でなければいけない。

コードにすると下記のようになります。よく見るパターンです。

// Promiseを返す関数
function test_promise(v){
    return new Promise((resolve, reject)=>{
        if(v){
            resolve("OK");
        } else {
            reject("ERROR");
        }
    });
}

(async ()=>{
    try{
        const ret = await test_promise(true);
        console.log(ret);    // 「OK」と表示される
    }catch(err){
        console.log(err);    // 「ERROR」と表示される
    }
})();

async関数とは?

async関数とは要約すると「Promiseを返す関数の別の書き方」ってとこでしょうか。

  • return ~が、Promiseでいうところのresolve(~)に相当する。
  • throw ~が、Promiseでいうところのreject(~)に相当する。

例えば先程のPromiseを返す関数test_promise()をasyncで書き直すと下記のようになります。

async function test_async(v){
    if(v){
        return "OK";
    } else {
        throw "ERROR";
    }
}

async関数の注意点

じゃあPromiseを全てasync関数に置き換えらるかというと残念ながら無理です…。

async関数の中で非同期関数を呼び、その中からPromise・throwを返すことができません。

例えば

function test_promise(v){
    return new Promise((resolve, reject)=>{
        setTimeout(()=>{
            if(v){
                resolve("OK");
            } else {
                reject("ERROR");
            }
        }, 1000);
    });
}

のようなPromiseを返す関数を

async function test_async(v){
    setTimeout(()=>{
        if(v){
            return "OK";    // test_async()の戻り値じゃない
        } else {
            throw "ERROR";
        }
    }, 1000);
}

と書いてもうまくいきません。

無理やりasyncでも成立するようにさせると下記のようになります。

async function test_async(v){
    try{
        const ret = await new Promise((resolve, reject)=>{
            setTimeout(()=>{
                if(v){
                    resolve("OK");
                } else {
                    reject("ERROR");
                }
            }, 1000);
        });
        return ret;
    }catch(err){
        throw err;
    }
}

もはや、Promiseでいいじゃんといった感じです。

async関数の中では、awaitを使い逐次処理していき、最後に明示的にreturn/throwで結果を返すのが、一番オーソドックスな使い方のようです。

asyncの中で使う関数全てがPromiseを返してくれるのだったなら、awaitだけでシンプルに書けるので、asyncを使うかPromiseを使うかはケースバイケースかなと思います。

Array.map()の中で非同期処理を行う

配列の各要素を使って非同期処理を行い、全ての処理が終わるまで待ってもらい、処理結果を配列で受け取るやり方です。このパターンはちょいちょい見かけます。

方法

  • mapから呼び出す関数をasyncにする。
  • async関数では実行結果をreturnで返す(async関数なので、returnで返された値はPromiseとなって返される)。
  • mapから生成された配列をPromise.allに格納する。
  • Promise.allをawaitで受ける。

コードにすると下記のようになります。

function test_promise(v){
    new Promise((resolve, reject)=>{
        setTimeout(()=>{
            resolve(v*10);
        }, 1000);
    });
}

(async ()=>{
    try{
        const array = [1, 2, 3];
        const ret = await Promise.all(
            array.map( async (v)=>{
                return test_promise(v);
        } ));
        console.log(ret);    // [10, 20, 30] と表示される
    }catch(err){
        console.log(err);
    }
})();

async/awaitがどう動作しているか見ていく前に、Promise.all()についておさらいを…

Promise.all()についておさらい

  • Promise.all()は、Promiseの配列を引数に取る。
  • 全てのPromiseが成功した場合は、then(val)で受けて、引数(val)は結果の配列となる。
  • 1つでも失敗した場合は、catch(err)で受けて、引数(err)は最初の失敗のreject(err)の値が入る。

以上を踏まえてコードを振り返ると…

  1. array.map()は各要素に対して非同期処理を実行し、その実行結果をPromiseとして返すので、array.map()はPromiseの配列となる。
  2. Promise.all()で上記のPromiseの配列を受ける。
  3. 全ての処理が成功した場合は、awaitがPromiseの配列を、結果の配列へと展開する
  4. 1つでも処理が失敗になった場合は、awaitで例外が投げられて、catchで受ける。

の一連の処理が行われ、結果の配列がawaitから返され、retへと代入されます。

asyncによりPromiseの配列ができるところと、Promiseの配列から次の処理に移らないようawaitで足止めし、かつ、Promiseから値に展開するために、配列をPromise.all()で受けるところがポイントですね。

Array.forEach()の中で非同期処理を行う

Array.map()がうまくいったので、Array.forEach()でもうまくいくかも!?と思いたいところですが、結論から言うとできないです。

例えば、

function test_promise(v){
    return new Promise((resolve, reject)=>{
        setTimeout(()=>{
            console.log(v);
            resolve();
        }, 1000);
    });
}

(async ()=>{
    try{
        const array = [1, 2, 3];
        await array.forEach( async (v)=>{
            await test_promise(v);
        });
        console.log("END");
    } catch(err){
        console.log(err);
    }
}

の結果は

1
2
3
END

とはならず

END
1
2
3

になります。

意図した挙動にするには、Array.forEach()は諦めて、地道にfor/ofを使うしかないです。

(async ()=>{
    try{
        const array = [1, 2, 3];
        for(const v of array){
            await test_promise(v);
        }
        console.log("END");
    } catch(err){
        console.log(err);
    }
}

もしくは、前述のArray.map()を、Array.forEach()代わりに使ってもできますね。

(async ()=>{
    try{
        const array = [1, 2, 3];
        await Promise.all( array.map( async (v)=>{
            await test_promise(v);
            return;
        }));

        console.log("END");
    } catch(err){
        console.log(err);
    }
}

「await Promise.all(〜)」を抜けた時点で、「return」が完了、つまり、「async(v)=>{}」内の処理が完了しています。

しかも、非同期で実行されるので、1000msで全てが完了します。一方「for/of」の方は、1つ1つawaitで完了を待って実行するので、全て完了するのに3000msかかります。

ザックリですが、以上である程度async/awaitは使えるようになるのではないでしょうか。

感想

async/awaitを使えばPromiseは必要無くなるかと思ったのですが、そうはいかないようですね。

awaitで全体の見通しが良くなるのですが、並列処理できるところをついついawaitで逐次処理にしてしまって、速度が遅くなることもありますね。