60
64

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

非同期APIをPromiseでラップしてasync/awaitで使う

Last updated at Posted at 2020-02-07

setTimeout と Web Speech API を Promise でラップする例を示します。

Promise とメソッドチェーンだけでは何がやりたいのか分かりにくいかもしれませんが、async/await もセットで考えることで狙いが分かりやすくなると思います。

※ Web Speech API の例は単純なので、触れたことがなくても分かると思います。

C# との比較については以下の記事を参照してください。

setTimeout

Promise の例としては定番です。

コールバック

非同期 API にコールバックを渡す処理をネストすると、いわゆるコールバック地獄になります。

setTimeout によって1秒後に1、その2秒後に2、その更に3秒後に3を表示する例です。

setTimeout(() => {
    console.log(1);
    setTimeout(() => {
        console.log(2);
        setTimeout(() => {
            console.log(3);
        }, 3000);
    }, 2000);
}, 1000);
実行結果
1
2
3

コールバックが深くなるのに加えて、待機時間と後続の処理との対応が分かりにくいです。

このように後続の関数(コールバック)で渡す書き方を継続渡しスタイル(CPS)と呼びます。詳細は以下の記事を参照してください。

Promise

setTimeout を Promise でラップします。resolve は成功したとき、reject は失敗したときのコールバックです。この例では失敗は考慮しないため reject は無視します。

resolve が呼ばれた後の処理を .then で渡します。

function wait(timeout) {
    return new Promise((resolve, reject) => setTimeout(resolve, timeout));
}

wait(1000).then(() => {
    console.log(1);
    wait(2000).then(() => {
        console.log(2);
        wait(3000).then(() => {
            console.log(3);
        });
    });
});

これだけだと setTimeoutwait().then に置き換わっただけで、あまりメリットはありません。(待機時間と後続の処理との対応が多少分かりやすくなったくらいでしょうか)

コールバックから Promise を返すように書き換えることができます。

wait(1000).then(() => {
    console.log(1);
    return wait(2000);
}).then(() => {
    console.log(2);
    return wait(3000);
}).then(() => {
    console.log(3);
});

コールバックのネストが .then によるメソッドチェーンに変わりました。ネストが深くなることはなくなり、多少読みやすくなりました。

最初のネストした wait().then の書き方と比較すれば、return で Promise を返してコールバックを抜けてから .then でつなぐことで、ネストをフラットにしていることが分かります。(そこが分からないと、どうやって読むのかピンと来ないかもしれません)

async/await

Promise を使いやすくするための糖衣構文です。wait の定義はそのままです。

function wait(timeout) {
    return new Promise((resolve, reject) => setTimeout(resolve, timeout));
}

(async function () {
    await wait(1000);
    console.log(1);
    await wait(2000);
    console.log(2);
    await wait(3000);
    console.log(3);
})();

同期的な処理のように記述することができます。await は「Promise の resolve が呼ばれるまで待つ」くらいに解釈すれば良いでしょう。awaitasync function(非同期関数)の中でしか使えないため、即時実行関数式にしています。

こうして見ると、Promise は非同期 API と async/await の間を取り持っていることが良く分かります。

※ async/await が糖衣構文だというのは、await の次の行から先がコールバックに変換されて .then で接続されることを意味します。厳密には少し違うのですが、Promise の中で resolve を呼ぶことは、変換されたコールバックを呼び出すことに相当します。このように後続の処理をコールバックに変換することを CPS 変換と呼びます。

resolve

ある程度慣れて来ると、「resolve が呼ばれれば Promise から抜ける」という感覚で捉えると見通しが良くなります。実際には resolve の後のコードも処理されるのですが、resolve の後には何も処理を書かない(またはすぐ return する)方が良いでしょう。

ここでは詳しく触れませんが、resolve は継続と呼ばれる概念に近いです。いくつか相違点がありますが、それを踏まえた上で継続だと思って使っています。

  • 継続は後続のコードを打ち切って即座に処理を移すが、resolve は後続のコードを実行してから非同期で await に戻る。
  • 継続は何度でも呼ぶことができるが、resolve を複数回呼んでも 2 回目以降は何も起きない。

co

async/await がない時代に、同じようなことをジェネレーターで実装した co というライブラリがあります。

co の簡易版を実装して比較します。(説明に必要な範囲に限定しています)

【参考】ES2017におけるasyncとgenerator、Promise、CPS、モナドの関係

function co(g) {
    let it = g();
    function f() {
        let result = it.next();
        if (!result.done) return result.value.then(f);
    }
    return f();
}

co(function* () {
    yield wait(1000);
    console.log(1);
    yield wait(2000);
    console.log(2);
    yield wait(3000);
    console.log(3);
});

async/await が模倣できていて面白いです。

co は async/await が正式にサポートされるまでのつなぎだったようで、今となっては使う必要はなさそうですが、発想は参考になります。また、Promise を検索すると co が現役だった当時の記事が出て来るため、async/await に読み替えられるということを知っておいても損はないでしょう。

【参考】ES7 async/awaitと、coがPromiseベースになっていたこと

もしasync/awaitが実装されれば、coからそのまま乗り換えることも可能になるだろう。

Web Speech API

setTimeout では失敗を考慮しませんでしたが、Web Speech API を例に失敗のあるケースを示します。

Web Speech API によってブラウザでテキストの読み上げを行います。読み上げは非同期的に行われますが、終了を待たずに次々指示しても、キューに溜まって順番に処理されます。

function speak(lang, text) {
    let u = new SpeechSynthesisUtterance(text);
    u.lang = lang;
    speechSynthesis.speak(u);
}

speak("en", "Hello, world!");
speak("fr", "Bonjour, monde !");
speak("ja", "こんにちは、世界!");

連続で読み上げるだけであれば終了を待つ必要はありませんが、もし途中で失敗しても無視して次に進んでしまいます。

コールバック

成功した時だけ次に進むようにするため、終了を待ってコールバックで次の読み上げを指示します。

function speak(lang, text, end, error) {
    let u = new SpeechSynthesisUtterance(text);
    u.lang = lang;
    u.onend = end;
    u.onerror = error;
    speechSynthesis.speak(u);
}

speak("en", "Hello, world!", () => {
    speak("fr", "Bonjour, monde !", () => {
        speak("ja", "こんにちは、世界!", () => {
        }, e => console.log(e));
    }, e => console.log(e));
}, e => console.log(e));

失敗した時は例外を表示します。同じ例外処理を何度も書いています。

※ 一般に e => console.log(e) のように引数を別の関数に取り回すだけのラムダ式は、引数を省略して console.log とも書けます(これをポイントフリースタイルと呼びます)。今回は async/await に書き換える際に e が出て来るため、引数を明示した形で進めます。

Promise

setTimeout では無視した reject を失敗時のコールバックとして使います。失敗時の処理は .catch で与えます。

まずはネストのまま書き換えます。

function speak(lang, text) {
    return new Promise((resolve, reject) => {
        let u = new SpeechSynthesisUtterance(text);
        u.lang = lang;
        u.onend = resolve;
        u.onerror = reject;
        speechSynthesis.speak(u);
    });
}

speak("en", "Hello, world!").then(() => {
    speak("fr", "Bonjour, monde !").then(() => {
        speak("ja", "こんにちは、世界!").then(() => {
        }).catch(e => console.log(e));
    }).catch(e => console.log(e));
}).catch(e => console.log(e));

これをフラットにします。何もしない最後の .then を省略して、失敗時の処理を1つにまとめます。

speak("en", "Hello, world!")
    .then(() => speak("fr", "Bonjour, monde !"))
    .then(() => speak("ja", "こんにちは、世界!"))
    .catch(e => console.log(e));

かなりすっきりしました。

最初の speak だけ書き方が異なりますが、ダミーの Promise から始めることで揃えられます。

new Promise((resolve, reject) => resolve())
    .then(() => speak("en", "Hello, world!"))
    .then(() => speak("fr", "Bonjour, monde !"))
    .then(() => speak("ja", "こんにちは、世界!"))
    .catch(e => console.log(e));

ダミーの Promise を生成する簡単な方法があります。

Promise.resolve()
    .then(() => speak("en", "Hello, world!"))
    .then(() => speak("fr", "Bonjour, monde !"))
    .then(() => speak("ja", "こんにちは、世界!"))
    .catch(e => console.log(e));

【参考】Promiseを複数組み合わせる時の基本パターン(直列、並列、分岐)

最初にダミーのPromise.resolve()を入れるようにしています。

async/await

メソッドチェーンを非同期の即時実行関数式で書き換えます。speak の定義は Promise と同じため省略します。

(async function () {
    try {
        await speak("en", "Hello, world!");
        await speak("fr", "Bonjour, monde !");
        await speak("ja", "こんにちは、世界!");
    } catch (e) {
        console.log(e);
    }
})();

trycatch によって同期的な処理に近い見た目で記述できました。

co

参考までに co 版を書いておきます。

co(function* () {
    yield speak("en", "Hello, world!");
    yield speak("fr", "Bonjour, monde !");
    yield speak("ja", "こんにちは、世界!");
}).catch(e => console.log(e));

まとめ

Promise で成功と失敗のコールバックに振り分けてラップすることで、async/await によって表記が簡略化できます。

参考

Promise の詳細は MDN の記事が参考になります。「古いコールバック API をラップする Promise の作成」のセクションは今回の記事と関係があります。

Web Speech API

Web Speech API の使い方は以下の記事を参照してください。

Promise と組み合わせた応用的な使い方は以下を参照してください。

モナド

コールバックから async/await への書き換えは、Haskell での >>= を明示した書き方から do ブロックへの書き換えに対応します。

JavaScript のジェネレーターでモナドを実装している記事です。co の方式とも密接な関係があります。

60
64
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
60
64

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?