TypeScript 非同期関数

非同期処理は、現代のソフトウェア開発において非常に重要な概念であり、開発者はメインスレッドをブロックすることなく複数のタスクを同時に実行できるアプリケーションを作成できるようになります。

従来の同期プログラミングでは、アプリケーションはタスクを次々に実行し、各タスクが完了しないと次のタスクが開始できません。特に、ネットワークリクエスト、ファイルI/O、データベースクエリのような長時間実行されるタスクを実行する場合、アプリケーションのパフォーマンス低下につながる可能性があります。

TypeScriptには、コールバック、Promise、async/awaitなど、非同期処理のための組み込みメカニズムがいくつか用意されています。

まずは簡単な非同期処理の例を見てみましょう。

console.log("A");

setTimeout(() => {
    console.log("B");
}, 1);

console.log("C");

// A
// C
// B

3行目では、setTimeout関数を使ってタイマーをセットしています。setTimeoutに渡された関数は、タイマーが経過するまで実行されません。この場合、タイマーは1ミリ秒に設定されています。つまり、setTimeoutに渡された関数は、現在の同期コードの実行が終了した後に実行されるタスクのキューに追加されることになります。

同期コードの実行が終了すると、JavaScriptエンジンは、タスクキューに実行が必要なタスクがあるかどうかをチェックします。この場合、setTimeout関数によって、タスクキューに実行が必要なタスクが追加されています。タイマーが経過したため、setTimeout に渡された関数は、実行可能な状態になりました。

したがって、このコードの出力は、"A", "C", "B"となります。

上記の例のようにTypeScript(JavaScript)では時間のかかる処理を行う場合、その処理の完了を待つことはなく、その次の処理がすぐに呼ばれ実行されます。処理が終わってから次の処理を行う同期処理とは対照的です。

TypeScript コールバック

コールバックは引数として別の関数に渡される関数のことです。自身の関数の処理が終わったのちに引数として渡された関数を呼び出します。簡単な例を見てみましょう。

function printDelayed(str: string, callback: () => void) {
    console.log("Starting");
    setTimeout(() => {
        console.log(str);
        callback();
    }, 1000);
}

function printFinish() {
    console.log("Finished");
}

printDelayed("Performing a process", () => printFinish());

// Starting
// Performing a process
// Finished

上記のような簡単な例であれば大きな問題は起こらないでしょう。しかし、複数の非同期処理を同時に実装しようとした時には記述が複雑になりコードの階層が深くなり、扱いが難しくなってしまいます。

console.log(0);
setTimeout(function(){
    console.log(1);
    setTimeout(function(){
        console.log(2);
        setTimeout(function(){
            console.log(3);
            setTimeout(function(){
                console.log(4);
                setTimeout(function(){
                    console.log(5);
                }, 1000);
            }, 1000);
        }, 1000);
    }, 1000);
}, 1000);

// 0
// 1
// 2
// 3
// 4
// 5

上記のような例は、俗にコールバック地獄やコールバックピラミッドと呼ばれたりします。コールバックを用いて複数の非同期処理を行うと非常に見通しが悪いコードになってしまいます。

TypeScript Promise

Promise(プロミス)は非同期処理の抽象化を行い、それらを組み合わせるた処理を行うことを可能にする手法です。Promiseを使用した関数は次の3つの状態を持つことができます。

  • pending(待機): 処理が実行中である状態
  • resolve(成功): 処理が成功し、値を返すことができる状態
  • rejected(失敗): 処理が失敗し、エラーを返すことができる状態

成功時の処理はthenのコールバック関数として、失敗時の処理はcatchのコールバック関数をして指定できます。

function fetchData(status: string): Promise<string> {
    return new Promise((resolve, reject) => {
        if (status === '200') {
            // 処理が成功した場合はresolveを呼び
            resolve("Fetched data");
        } else {
            // 処理が失敗した場合はrejectを呼ぶ
            reject(new Error("Failed to fetch data"));
        }
    });
}

fetchData("200") // Fetched data
    .then((data) => {
        console.log(data);
    })
    .catch((error) => {
        console.log(error)
    });

fetchData("400") // Failed to fetch data
    .then((data) => {
        console.log(data);
    })
    .catch((error) => {
        console.log(error)
    });

Promiseを返す関数fetchData()を定義しています。このPromiseは、入力された状態に応じて"Fetched data"という文字列で解決するか、"Failed to fetch data"というエラーで拒否を行います。

fetchData()関数は、statusが文字列"200"に等しい場合、この関数は resolve()関数を呼び出してPromiseを解決し、文字列"Fetched data"を返します。statusが"200"以外の場合、この関数は"Failed to fetch data"というメッセージを含むエラーオブジェクトでreject()関数を呼び出すことによりPromiseを拒否します。

最初の呼び出しでは、Promiseが解決され、then()メソッドが呼び出され、文字列"Fetched data"がコンソールに記録されます。2回目の呼び出しでは、Promiseが拒否されるので、catch()メソッドが呼び出され、"Failed to fetch data"というエラーがコンソールに記録されます。

それでは、コールバック地獄として紹介したコードをPromiseを使って書き直してみましょう。

function delay(ms: number) {
    return new Promise((resolve) => {
        setTimeout(resolve, ms)
    });
}

delay(1000)
    .then(() => {
        console.log(1);
        return delay(1000);
    })
    .then(() => {
        console.log(2);
        return delay(1000);
    })
    .then(() => {
        console.log(3);
        return delay(1000);
    })
    .then(() => {
        console.log(4);
        return delay(1000);
    })
    .then(() => {
        console.log(5);
    });

msを引数として受け取り、新しいPromiseオブジェクトを返すdelayという関数が定義しています。そして、指定されたミリ秒が経過した後に、setTimeout関数を使ってPromiseのresolve関数を呼び出しています。

残りのコードではPromiseのthenメソッドを使用して、このdelay関数を複数回連続して呼び出しています。各thenブロックでは、delay関数を1000ミリ秒遅延させて再度呼び出すことを返します。これにより各console.log文は、前の文の1秒後に出力され、各出力の間に遅延が生じます。

TypeScript asyncとawait

async/awaitは、TypeScriptで非同期コードを書くための構文で、非同期コードの読み書きを容易にします。

async/awaitを使うと、メインスレッドをブロックすることなく、同期コードのような見た目と振る舞いの非同期コードを書くことができるようになります。asyncキーワードは、関数を非同期として宣言するために使用します。awaitキーワードは、Promiseのような非同期処理の結果を待つために使用します。

それでは1秒ごとに1から5までを出力する関数をasyncとawaitを使って書いてみましょう。

// 遅延処理の関数
function delay(ms: number): Promise<void> {
    return new Promise<void>(resolve => setTimeout(resolve, ms));
}

async function printNumbers(): Promise<void> {
    for (let i = 1; i <= 5; i++) {
        // awaitはasync関数の中でしか使用できません。
        await delay(1000); // 1秒待つ
        console.log(i);
    }
}

printNumbers();

最初に定義されたdelay関数は、msを引数として受け取り、指定された遅延時間の後に解決されるPromiseを返します。これはsetTimeout()関数を用いて実現され、引数としてコールバックresolve関数と遅延時間msが渡されます。遅延時間が経過すると、resolve()関数が呼び出され、Promiseが解決されます。

2番目に定義されているのは非同期関数printNumbers()で、asyncキーワードは、この関数がPromisesを使用する非同期関数であることを示しています。awaitキーワードは、非同期関数の内部でPromise(この場合はdelay関数が返すPromise)と共に使用されます。Promiseが解決するのを待ってから次の行に進みます。この場合、1秒待ってからiの現在値をコンソールに記録し、ループが完了するまでこの処理を繰り返すことになります。

このようにasyncとawaitを使用することで非同期処理がコンパクトに記述できるようになります。

この記事を書いた人

著者の画像

Jeffry Alvarado

Ex-Facebook Engineer 大学ではコンピュータサイエンスを専攻し、在学中に複数のインターンシップを経験。コンピュータサイエンスが学習できるプラットフォームRecursionを創業し、CTOとしてカリキュラム作成、ソフトウェア開発を担当。


ツイート