JavaScript の fetch() と Promise と async/await

2024/08/04に公開

はじめに

たとえばこんなコード。

fetch(url)
   .then((response) => response.json())
   .then((data) => {
       console.log(data); // 描画処理など
   })
   .catch((e) => {
       console.log(e); // エラー処理
   })

メソッドチェーンとクロージャーで構造がわかりにくいですが、分解するとこうなります。

let thenFunc1 = (response) => response.json();
let thenFunc2 = (data) => {
    console.log(data); // 描画処理など
}
let catchFunc = (e) => {
    console.log(e); // エラー処理
}

let a = fetch(url)
let b = a.then(thenFunc1)
let c = b.then(thenFunc2)
c.catch(catchFunc)

a, b, c の型はそれぞれ何しょうか?
質問を変えれば、then(), catch() は誰のメソッドでしょうか?

・・・

その前に、歴史のお話

最初のコードは、感覚的には、

let response = fetch(url);
let data = response.json();
console.log(data);

と書きたくなります。

しかし、fetch() 関数は非同期処理であり、
url の取得完了を待たず、次の行へ処理が進んでしまいます。

非同期処理の中で実行順を担保するため、かつては、コールバックがよく用いられました。

request(
    url1,
    function (response1) {
        request(
            url2 + response1.id,
            function (response2) {
                request(
                    url3 + response2.id,
                    function (response3) {
                        console.log('成功');
                    },
                    function () {
                        console.log('エラー3');
                    },
                );
            },
            function () {
                console.log('エラー2');
            },
        );
    },
    function () {
        console.log('エラー1');
    },
);

function request(url, successFunc, errorFunc) {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', url);
    xhr.responseType = "json";
    xhr.onreadystatechange = function() {
        if (xhr.readyState === 4 && xhr.status === 200) {
            successFunc(xhr.response);
        } else if (xhr.readyState === 4) {
            errorFunc();
        }
    };
    xhr.send();
}

上記の XMLHttpRequest は、JavaScript に古くから存在する、非同期通信の(APIを叩く)ためのオブジェクトですが、
上記のようにラッパー関数を作ってもなお、可読性・メンテナンス性の悪いコードに陥りがちでした。

俗に言う 「コールバック地獄」 と呼ばれる状態です。

そこで、非同期処理を制御しやすくするための機構として、Promise オブジェクトが登場しました。

さらに時代が進んで、async / await が登場し、非同期処理を同期的に扱うことができるようになり、現在ではこちらが主流ではありますが、
async / await は Promise を前提に動作するものですので、まずは Promise のお話をします。

Promise とは

先程のコードを、Promise を使って書き直してみます。

request(url1)
.then((response1) => {
    return request(url2 + response1.id)
})
.then((response2) => {
    return request(url3 + response1.id)
})
.then((response3) => {
    console.log("成功");
})
.catch(() => {
    console.log("エラー");
})

function request(url) {
    return new Promise(function (resolve, reject) {
        const xhr = new XMLHttpRequest();
        xhr.open('GET', url);
        xhr.responseType = "json";
        xhr.onreadystatechange = function() {
            if (xhr.readyState === 4 && xhr.status === 200) {
                resolve(xhr.response);
            } else if (xhr.readyState === 4) {
                reject();
            }
        };
        xhr.send();
    });
}

コードのネストがなくなり、かなりスッキリしました。
上記の request() 関数は、fetch() の原型と言ってよいと思います。

Promise オブジェクトは、コンストラクタに関数(クロージャ)を受け取ります。
その関数は2つの引数を受け取りますが、その引数はそれぞれ resolve, reject という関数です。

また、Promise オブジェクトは、then()、catch() などのメソッドを持っており、
どちらも引数に関数(クロージャ)を受け取ります。

コンストラクタに渡された関数は直ちに実行され、その中で resolve 関数が呼ばれると、
then() に渡された関数が実行されます。
同様に reject 関数が呼ばれると、catch() に渡された関数が実行されます。

・・・言葉で説明するとややこしいのですが、イメージとしてはこんな感じです(あくまでイメージです)。

// なんちゃってPromise
class Promise {
    thenFunc = () => {};
    catchFunc = () => {};
    fillayFunc = () => {};

    constructor(func) {
        func(this.resolve, this.reject);
    }

    resolve(arg) {
        this.thenFunc(arg);
        this.finallyFunc(arg);
    }

    reject(arg) {
        this.catchFunc(arg);
        this.finallyFunc(arg);
    }

    then(func) {
        this.thenFunc = func;
        return this;
    }

    catch(func) {
        this.catchFunc = func;
        return this;
    }

    finally(func) {
        this.finallyFunc = func;
        return this;
    }
}

先程のコード(一部抜粋・改変)と照らしみると・・・

let a = request(url1);

let b = a.then(
    // 成功時処理
    function (response1) {
        return request(url2 + response1.id)
    }
);

b.catch(
    // エラー時処理
    function () {
        console.log("エラー");
    }
);

function request(url) {
    return new Promise(
        // メイン処理
        function (resolve, reject) {
            // 略(通信処理)
            // 成功時
            resolve(xhr.response);
            // 失敗時
            reject();
        }
    );
}

まず、request() の中で Promise がインスタンス化され、
コンストラクタに渡された function (メイン処理)が、即時開始されます。
しかし、メイン処理内の通信には時間がかかるため、
メイン処理が完了する前に a.then() が実行されます。

a には、request() の戻り値である Promise オブジェクトが格納されています。
then() の引数に渡された function (成功時処理)が、 Promise の thenFunc に格納されます。

then() も Promise オブジェクト(自分自身)を返すため、b にも Promise オブジェクトが格納されています。
続いて b.catch() が実行され、引数の function (エラー時処理)が、Promise の catchFunc に格納されます。

そうこうしているうちに、メイン処理内の通信が終わり、resolve() が実行されます。
Promise のメイン処理の resolve には Promise の resolve() メソッドが代入されていますので、
thenFunc に格納された、成功時処理が実行されます。

もし、通信に失敗した場合は、同様に reject() が実行され、catchFunc に格納された、エラー時処理が実行されます。

このようにして、本来非同期な処理を、then() や catch() を使って時系列潤に記述できるような仕組みが実現されています。

上記のなんちゃって Promise では再現できていませんが、
実際には複数の .then() をチェーンすると、Promise はネストされていき、内側から順に解決(resolve)されていきます。
そして、そのどこかでエラー(reject)が発生すると、一気に1番外側の .catch() まで飛ぶようになっています。
(作った人すごい。)

改めて fetch()

最初のコードに戻りましょう。

fetch(url)
   .then((response) => response.json())
   .then((data) => {
       console.log(data); // 描画処理など
   })
   .catch((e) => {
       console.log(e); // エラー処理
   })

fetch() 関数は、Promise を返す非同期通信処理です。
JSer が前述のように非同期通信の扱いに四苦八苦していたある日、
JavaScript の新機能として追加されました。

通信が完了すると、Promise が resolve され、
resolve の引数には、通信のレスポンス(Responseオブジェクト)が渡されます。

つまり、Responseオブジェクトが、then() の引数である下記クロージャに渡ってきます。

(response) => response.json()

Responseオブジェクト の json() メソッドもまた、Promise を返します。
レスポンスボディをJSONデコードし、変換に成功すると、resolve されます。
resolve の引数には、JSONデコードされたオブジェクトが渡ります。

こうして、オブジェクト化されたレスポンスが、次の then() のクロージャに渡ってくることになります。

(data) => {
    console.log(data); // 描画処理など
}

なお、then() には Promise を返す処理しか渡せないのかといえば、そんなことはなく、
Promise 以外を返す処理が渡された場合、その返り値を引数として、直ちに次の then() の処理が実行されます。

async と await

ちなみに、こちらのコードは

fetch(url)
   .then((response) => response.json())
   .then((data) => {
       console.log(data); // 描画処理など
   })
   .catch((e) => {
       console.log(e); // エラー処理
   })

await を使用して、

try {
    let response = await fetch(url);
    let data = await response.json();
    console.log(data); // 描画処理など
} catch (e) {
    console.log(e); // エラー処理
}

のように書くことができます。

本来 Promise を返す関数に、await をつけて呼び出すと、
Promise が resolve() されるまで処理が待機され、resolve() の引数に渡された値が、
戻り値として返されます。

また、 Promise が reject() された場合には、例外がスローされます。

async 関数内でしか使えないという制約はありますが、
await により、Promise ベースの処理をさらに直列的に記述できるようになります。

なお await はあくまで Promise ありきの記法ですので、
Promise を返さない関数に付与しても、挙動は何らかわりません。
たとえば

await setTimeout(() => {}, 1000);

などとしても、1秒待つこと無く次の行の処理へ移ります。

ちなみに、async を付与された関数は、自動的に、Promise を返すようになります。

おわりに

fetch() 関連の処理をレビューするにあたって、Promise をうまく説明できなかったので、
改めて整理してみました。

fetch() や async/await の登場により、
非同期処理がずいぶんシンプルに記述できるようになりましたが、
使いこなすためには、改めて Promise の理解が欠かせないなと思いました。

Discussion