【メモ】JSの非同期処理について学んだことまとめ


下記講座で学んだ非同期通信について、整理してみる

https://www.udemy.com/course/the-complete-javascript-course/

使用しているAPI:

前提知識

  • imgのloadやajax、timerは非同期であり、JSのcall stackではなくwebAPI environmentにて処理される

非同期処理のコールバック

  • web API environmentに登録される → 発火したらcallback queueへ移動、queue内の処理が実行される
  • call stackにglobal context以外実行中の処理が無ければ(さらにmicrotasks queueが無ければ)、callback queueの1つ目がcall stackに渡される(event loop)

promise

  • web API environmentに登録される → 発火したらmicrotasks queueへ移動(callback queueよりも優先される)
  • microtasks queueの処理が新たなmicrotasks(thenのchainなど)を作り出しても、callback queueより優先される

XMLHttpRequest

古い書き方であり、今はfetchを使う方法が主流

const request = new XMLHttpRequest();
request.open('GET', `https://restcountries.com/v3.1/name/mexico`);

// data = undefined(非同期でbackgroundで処理中のため、データは入らない)
// const data = request.send();

request.send();
console.log(request.responseText); // まだ何も返っていない

// データが返ってきたら発火
request.addEventListener('load', function () {
    // responseをjson形式にする
    const [data] = JSON.parse(this.responseText);
    console.log(data);

    // 取得したデータから隣国名を取得
    const [neighbour] = data.borders;

    // 上記で取得した隣国のデータを取得するには、今いるコールバック関数内で新しくrequestを投げることになる
    const request2 = new XMLHttpRequest();
    request2.open(
        'GET',
        `https://restcountries.com/v3.1/alpha/${neighbour}`
    );
    request2.send();
    
    // requestした隣国データの取得は新しいコールバック内にて取得、となり、所謂callback hellに陥る
    request2.addEventListener('load', function () {
        const data2 = JSON.parse(this.responseText);

        console.log(data2);
    });
});

fetchとpromise

  • fetchはpromiseを返す
  • promiseは値を受け取るプレースホルダーであり、fulfilled or rejectedいずれかの状態になる
  • fulfilledはthenの第1引数に入るコールバックの引数に入り、rejectedはthenの第2引数に入るコールバックの引数、もしくはcatchの第1引数に入るコールバックの引数で受け取れる
  • then/catchは常にpromiseをreturnし、戻り値はそのpromiseのfulfilled valueになる(次のthenのコールバックの引数で受け取れる)
  • thenでthrowするとrejectedとなり、throwした値をrejected valueとして受け取れる
  • fetchがrejectedを返すのは、no internet connection時のみ(responseのステータスコードが200でなくても)なので、response.ok(200ならtrue)などで明示的にthrowする
// fetchがpending promiseを返し、成功(fulfilled)すればthenのコールバックの引数でresponseを受け取れる
// no internet connectionでない限り、fetchはfulfilled valueを返す
fetch(`https://restcountries.com/v3.1/name/mexico`)
    .then((response) => {
        // 国が見つからなくてもfetchはfulfilledを返すので、手動でerrorを投げる
        // errorをthrowするとpromiseはrejectedを投げる(= catchで受け取れる)
        if (!response.ok) {
            throw new Error(`Country not found (${response.status})`);
        }

        // 受け取ったresponseはjson形式にする必要があるが、response.json()も非同期であるため、thenで受け取る必要がある
        return response.json();
    })
    // ここでjson形式のdataになる
    .then((data) => {
        console.log(data[0]);
        const [neighbour] = data[0].borders;

        // fetchの戻り値(promiseのfulfilled value)をreturnすることで、次のthenでfulfilled valueとして受け取る
        // ここでfetch().then()のようにするとcallback hellになるので、しないこと
        return fetch(`https://restcountries.com/v3.1/alpha/${neighbour}`); 
    })
    // neighbourのpromise処理
    .then((response) => {
        if (!response.ok) {
            throw new Error(`Country not found (${response.status})`);
        }

        return response.json();
    })
    .then((data) => console.log(data[0]))
    // いずれのpromiseのrejectedもcatchで受け取れ、コールバックの引数でrejected valueを受け取れる
    .catch((err) => {
        console.error(`${err} 💣💣💣`);
    })
    // fulfilled, rejectedに関係なく最後に呼ばれるので、共通処理に便利
    .finally(() => console.log('done'));

callback queueとmicrotask queue

  • microtask queueはcallback queueより優先される
  • promiseはmicrotask queueになる
// logが表示される順序は、test start → test end → promise1 → promise2 → setTimeout
// promiseが実行されている間はcallback queueは実行されないため、
// 下の例ではpromise2の処理に時間がかかり、完了後setTimeoutが呼ばれるため、実際には0秒で実行されない
console.log('test start');

setTimeout(() => console.log('0 sec timer'), 0);

Promise.resolve('Resolved promise 1').then((res) => console.log(res));

Promise.resolve('Resolved promise 2').then((res) => {
    for (let i = 0; i < 300000000; i++) {}
    console.log(res);
});

console.log('test end');

Promiseオブジェクトの基本

  • new Promiseはpromiseを返す
  • resolveを返すとfulfilledになり、rejectを返すとrejectedになる
  • resolve / rejectの引数が、それぞれfulfilled value / rejected valueになる
const lotteryPromise = new Promise(function (resolve, reject) {
    console.log('lottery draw is happening');

    setTimeout(() => {
        Math.random() >= 0.5
            ? resolve('You WIN')
            : reject(new Error('You lost your money'));
    }, 2000);
});
// pending promiseが入り、2秒後にresolve or rejectedが来る
lotteryPromise
    .then((res) => console.log(res))
    .catch((err) => console.error(err.message));

即時でresolve / rejectを返す方法

Promise.resolve('success!').then((x) => console.log(x));
Promise.reject(new Error('problem!')).catch((x) => console.error(x));

promiseのresolve / rejectを他のfunctionと組み合わせる(geolocation APIの例)

// 下記をpromiseで書き換える(promisify)
// getCurrentPositionは第1引数が位置情報取得成功時に実行されるコールバック、第2引数は失敗時に実行されるコールバック(非同期処理)
navigator.geolocation.getCurrentPosition(
    (position) => console.log(position),
    (err) => console.error(err)
);

new Promiseを返すようにすれば、getPosition().then()...のように繋げられる

const getPosition = function () {
    return new Promise(function (resolve, reject) {
        navigator.geolocation.getCurrentPosition(
            // 成功時はresolveでthenに繋げ、失敗時はrejectでcatchに繋げる
            (position) => resolve(position),
            (err) => reject(err)
        );
    });
};

getPosition().then((pos) => console.log(pos));

上記をさらにシンプルに書く

const getPosition = function () {
    return new Promise(function (resolve, reject) {
        navigator.geolocation.getCurrentPosition(resolve, reject);
    });
};

getPosition().then((pos) => console.log(pos));

async / await

  • async functionは全てバックグラウンドで処理し、戻り値はpromiseになる
  • async内の非同期処理(promiseがreturnされる部分)でawaitを使用すると、fulfilled valueが入るまで処理を止められる(execution blockが発生し、順番に実行される)
  • main thread内ではないので、call stackはblockされない
  • then/catchの様な機能は無いため、try/catchでエラーを拾う
  • async内のtry/catchは、returnを返せば(もしくは何も返さなければ)fulfilled, throwすればrejectedの挙動になる
async function whereAmI() {
    try {
        // awaitしているので、fulfilled valueが入ってから次へ行く
        // 失敗した場合はrejectedとなり、catchへ行く
        // await書かないと処理が進んでposにはpromiseが入るので、pos.coordsがerrorになる
        const pos = await new Promise((resolve, reject) =>
            navigator.geolocation.getCurrentPosition(
                resolve,
                reject
            )
        );

        const { latitude: lat, longitude: long } = pos.coords;
        console.log(lat, long);

        // reverse geocoording
        const resGeo = await fetch(
            `https://geocode.xyz/${lat},${long}?geoit=json`
        );

        if (!resGeo.ok)
            throw new Error(`Problem getting locaiton data`);

        const dataGeo = await resGeo.json();
        console.log(dataGeo);

        // 国情報を取得
        const res = await fetch(
            `https://restcountries.com/v3.1/name/${dataGeo.country}`
        );

        if (!res.ok)
            throw new Error(`Problem getting country data`);

        const data = await res.json();
        console.log(data);

        // promiseをreturnする
        return `You are in ${dataGeo.city}, ${dataGeo.country}`;
    } catch (err) {
        console.error(`${err}💣💣💣`);

        // catchしたerrorを(returnでは無く)throwしない限り、このwhereAmIが返すpromiseはfulfilledになる
        throw err;
    }
}

async functionの戻り値はpromiseなので、そのままではpromiseが入ってしまう

const city = whereAmI();
console.log(whereAmI(city)); // Promise {<pending>}

方法①:then/catchで受け取る

whereAmI()
    .then((res) => console.log(res))
    .catch((err) => console.error(err));

方法②:さらにasync functionで囲み、awaitで受け取る

  • thenと混在させずに済む(callbackも増やさずに済む)
  • funcitonを増やしたく無ければ、下記のように即時実行関数を使用するのもあり
(async function () {
    try {
        // whereAmIがreturnならfulfilledが、throwならrejectedが返ってくる
        const city = await whereAmI();
        console.log(`fulfilled:${city}`);
    } catch (err) {
        console.error(`rejected:${err}`);
    } finally {
        // try catchの結果に関わらず実行される
        console.log('finished');
    }
})();

Promise.all()

  • promiseを同時並行で実行する
  • promiseをarrayで受け取り、新しい1つのpromiseを返す
  • 全てfulfilledなら、それぞれのfulfilled valueのarrayをreturnする
  • 引数の内1つでもrejectされたら、新しいpromiseもrejectされる
(async function () {
    // 1つ終わったら次、となるので、時間がかかる
    // const res1 = await fetch(`https://restcountries.com/v3.1/name/italy`);
    // const res2 = await fetch(`https://restcountries.com/v3.1/name/egypt`);
    // const res3 = await fetch(`https://restcountries.com/v3.1/name/mexico`);

    const res = await Promise.all([
        fetch(`https://restcountries.com/v3.1/name/italy`),
        fetch(`https://restcountries.com/v3.1/name/egypt`),
        fetch(`https://restcountries.com/v3.1/name/mexico`),
    ]);
})();

Promise.race()

  • promiseを引数に取り、最初に処理が返ったpromiseを取得
  • そのpromiseのfulfilled value or rejected errorが全体のPromise.race()の状態として機能する
(async function () {
    const res = await Promise.race([
        fetch(`https://restcountries.com/v3.1/name/italy`),
        fetch(`https://restcountries.com/v3.1/name/egypt`),
        fetch(`https://restcountries.com/v3.1/name/mexico`),
    ]);
})();

Promise.allSettled()

状態に関わらず全てのpromiseを返す(allの方は一つでもrejectされたら中断)

(async function () {
    const res = await Promise.allSettled([
        Promise.resolve('success'),
        Promise.reject('error'),
        Promise.resolve('success2'),
    ]);
})();

Promise.any()

fulfilledとなる最初のpromiseを取得(rejectは無視)

(async function () {
    const res = await Promise.any([
        Promise.reject('error'),
        Promise.resolve('success'),
        Promise.resolve('success2'),
    ]);
})();

asyncのreturnの気をつけるべき挙動

asyncはpromiseをreturnする

loadAll(['img/img-1.jpg', 'img/img-2.jpg', 'img/img-3.jpg']);

async function loadAll(imgArr) {
    try {
        /**
         * 前提:
         * ・mapのコールバックではcreateImageをawaitしているので、createImageのfulfilled valueであるimg Elementがコールバックの戻り値になりそうだが、
         *   コールバック自体がasync functionかつawaitされていないので、実際にはimgsにはpromiseが返る
         * ・またそもそもコールバック自体がawaitされていないということは、コールバックの実行を待たずloadAll内の処理が進むということ
         *
         * 流れ:
         * 1.上記の通りimgsには(createImageでは無く)async コールバックのpromiseが入り、下記のPromise.all()まで先に処理が進む
         * 2.Promise.all()がawaitしているので、imgs(mapのそれぞれのコールバックのpromise)がそれぞれreturn valueを返すまで待つ
         * 3.それぞれのコールバックは、さらにcreateImageのreturnをawaitしており、img Elementがreturnされると、それをfulfilled valueとしてPromise.all()にreturnする
         */
        const imgs = imgArr.map(async (img) => await createImage(img));

        // 実際は、上記コールバックでasync/awaitする意味は無いので、こうなる
        const imgs = imgArr.map((img) => createImage(img));
        console.log(imgs);

        /**
         * 1.Promise.allがimgsにpromiseを返す
         * 2.引数の3つのpromise(mapのコールバック)全てがresolveを返したら、Promise.allもresolveを返す
         * 3.それぞれのfilfilled value(imgタグ)のarrayをreturnしimgsElにセットされる
         */
        const imgsEl = await Promise.all(imgs);

        imgsEl.forEach((img) => img.classList.add('parallel'));
    } catch (err) {
        console.error(err);
    }
}

function createImage(imgPath) {
    return new Promise(function (resolve, reject) {
        const img = document.createElement('img');
        img.src = imgPath;

        img.addEventListener('load', () => {
            imgContainer.append(img);
            resolve(img);
        });
        img.addEventListener('error', () =>
            reject(new Error('Image not found'))
        );
    });
}