🕒

【JavaScript】非同期処理 まとめ

2020/11/23に公開
2

JavaScriptの躓きポイントの代表格である非同期処理(Promiseasync/await )について解説します。

非同期処理とは

非同期処理とは、あるタスクを実行をしている際に、他のタスクが別の処理を実行できる方式をいいます。

一般的に、データベースから値を取得する等、通信を伴う処理は通信状況によって取得まで時間がかかったり、値が返ってくる保証がない等、時間がかかる処理とされています。
このような時間のかかる処理を、シングルスレッドで実行すると、実行中は他のタスクを行うことができなくなります。

例えば、データベースから値を取得する処理が走った後に、ユーザがページスクロールをしたとします。その場合、DBから値を取得し終わるまで、ページはスクロールされず、ユーザからすると画面がフリーズしたと思われるかもしれません。

このような事態を防ぐため、非同期処理が存在します。
先ほどのページスクロールの例を図に表すと以下のようになります。
同期処理の場合は、ページスクロールをしてから実際にスクロールされるまでに待ち時間が発生していましたが、非同期処理ではDBから値を取得するタスクを一時中断して先にページスクロールを完了させた後、再度DBから値取得を行っています。

シングルスレッドでこれを実現させるには、何らかの非同期処理がきた場合は、その処理の完了を待たずに、次のタスクを実行する必要があります。
完了を待っていたら割込みタスクを実行できませんからね。
JavaScriptで非同期処理を実装する上で、非同期処理は処理の完了を待たないという特徴がとても大切になってくるのでここで覚えておきましょう。

JavaScript の非同期処理について

JavaScriptは歴史のある言語で、いくつものアップデートがされてきました。中でも、ES2015というバージョンからは大きな変化があり、非同期処理もまたES2015以降から新たな書き方が追加され、2017年にはさらに便利な非同期処理の書き方が追加されています。
ここでは、ES2015で追加されたPromise、ES2017で追加されたasyn/awaitについて解説していきます。
※JavaScriptの歴史について調べると、沼にハマることができるので、お時間のある方は一度沼にハマってみると面白いかもしれません。

Promise

Promise の基本理解

Promiseとは、非同期処理を行うためのものです。
まず、Promiseには以下3つの状態があります。

  • pending:非同期処理の実行中の状態を表す
  • fulfilled:非同期処理が正常終了した状態を表す
  • rejected:非同期処理が異常終了した状態を表す

取り合えずサンプルコードを実行してこれらの存在を確認してみましょう。
まずはPromiseの基本構文から。Promiseは以下のような式になります。

Promiseの基本構文
new Promise(function (resolve, reject){
  // 非同期処理
})

Promise()内のコールバック関数の引数にはそれぞれ、resolverejectが渡ります。
引数はそれぞれ以下の役割を持ちます。

  • resolve:非同期処理が正常終了したことを知らせるメソッド。returnの代わりに、resolve()と記述することで、非同期関数が正常終了したことを知らせる。
  • reject:非同期処理が異常終了したことを知らせるメソッド。returnの代わりに、reject()と記述することで、非同期関数が異常終了したことを知らせる。

上記ソースコード(Promiseの基本構文)をJavaScriptのコンソールで実行すると以下のような結果が得られます。

非同期処理の実行中を表す、pendingが表示されました。
これは、非同期処理が正常終了したことを表すresolveと異常終了したことを表すrejectのいずれも呼び出されていないため、非同期処理が完了していない、ということです。

そのため、非同期処理を呼び出す場合は必ず非同期処理が終了したことを教える必要があります。
非同期処理が完了したことを知らせるためには、// 非同期処理で処理が正常終了したら、returnの代わりにresolve()を実行し、異常終了した場合はreturnの代わりにreject()を実行します。
また、処理が成功したか失敗したかを検知できるように、try, catchで処理を囲みます。

Promiseのサンプルコード1
new Promise(function (resolve, reject){
  try {
    // 非同期処理
    
    // returnの代わりに正常終了したことを表すresolveを呼び出して、fulfilledを返す
    resolve();
  } catch (e) {
    // 異常終了時の処理
    
    // returnの代わりに異常終了したことを表すrejectを呼び出して、rejectedを返す
    reject();
  }
})

上記ソースコードをJavaScriptのコンソールで実行すると以下のような結果が得られます。

非同期処理が正常終了した状態を表すfulfilledが表示されました。

では、異常終了させた場合はどうなるでしょうか。

Promise異常終了
new Promise(function (resolve, reject){
  try {
    // 非同期処理
    
    // 異常終了させる
    throw "異常終了";

    // returnの代わりに正常終了したことを表すresolveを呼び出して、fulfilledを返す
    resolve();
  } catch (e) {
    // 異常終了時の処理
    
    // returnの代わりに異常終了したことを表すrejectを呼び出して、rejectedを返す
    reject();
  }
})


非同期処理が異常終了した状態を表すrejectedが表示されました。

実際に非同期処理を走らせてみる

先ほど、非同期処理は処理の完了を待たない、ということをお伝えしました。
Promiseは非同期処理のため、new Promise内のコールバック関数の処理が完了する前に、その後の処理が終了してしまうことになります。
こちらもサンプルコードで処理の流れを確認してみましょう。

Promiseのサンプルコード2
new Promise(function (resolve, reject){
  try {
    // 1秒後に"非同期処理"とコンソールに出力
    setTimeout(()=>{
      console.log("非同期処理")
      
      // returnの代わりに正常終了したことを表すresolveを呼び出して、fulfilledを返す
      resolve()
    }, 1000)
  } catch (e) {
    // 異常終了時の処理
    
    // returnの代わりに異常終了したことを表すrejectを呼び出して、rejectedを返す
    reject()
  }
})

上記コードを実行すると以下のような実行結果になります。

setTimeout内でresolve()を呼び出しているにもかかわらず、ログにはpendingと表示され、その後"非同期処理"のログが出力されました。

これが非同期処理は処理の完了を待たないという特徴です。非同期処理内で処理の完了を伝えるresolve()reject()を呼んだとしても、それを待つことなく次の処理に進んでしまいます。

非同期処理の特徴を理解したところで、次は処理の完了の待ち方についてみていきます。

非同期処理の完了を待つには

それでは、どのようにして非同期処理の完了を待てば良いのかについて解説していきます。
Promiseに限らず、JavaScriptの非同期処理は、その戻り値に対してthenメソッドとcatchメソッドが利用できます。
それぞれの意味は以下の通りです。

  • then():非同期処理が正常終了した際に呼ばれるメソッド
  • catch():非同期処理が異常終了した際に呼ばれるメソッド

これをみると、先ほど確認したPromiseの3つの状態との関係性が分かると思います。
resolveは非同期処理が正常終了したことを表すもので、then()は非同期処理が正常終了した際に呼ばれるメソッド(関数)です。
つまり、非同期処理が処理中(pending)から、正常終了(resolve)に変化したら、続いてthen()が呼ばれるというです。
catch()はその逆で、非同期処理が処理中(pending)から、異常終了(reject)に変化したら、呼ばれるということになります。

こちらもサンプルコードで処理の流れを確認してみましょう。

Promiseのサンプルコード3
function asyncFunction() {
  return new Promise((resolve, reject) => {
    try {
      // 1秒後に"非同期処理"とコンソールに出力
      setTimeout(() => {
        console.log("非同期処理")
        // returnの代わりに正常終了したことを表すresolveを呼び出して、fulfilledを返す
        resolve();
      }, 1000)
    } catch (e) {
      // 異常終了時の処理

      // returnの代わりに異常終了したことを表すrejectを呼び出して、rejectedを返す
      reject();
    }
  })
}

asyncFunction().then(() => {
  console.log("resolve後の処理");
}).catch(e => {
  console.log("reject後の処理");
})

上記コードを実行すると以下のような実行結果になります。

setTimeout内の"非同期処理"というログ出力のあとにthen()メソッド内の"resolve後の処理"というログが出力されていることがわかります。

このように、非同期処理の完了後に他の処理を行いたい場合は非同期処理.then().catch()といった形に、メソッドチェーンでthen()catch()を続けてやればOKです。

非同期で処理した後に、その値を取得する

非同期処理の完了の待ち方はわかりましたが、非同期処理中に取得した値を活用したい場合はどうすればよいかについて解説していきます。

実はthen()メソッドとcatch()メソッドはそれぞれ引数を取ることができます。
then()catch()に値を渡すには、処理を完了したことを知らせるresolve()reject()で引数を渡すことで、任意の値を送ることができます。
これもサンプルコードを見てみましょう。

thenとcatchに引数を渡す
function asyncFunction() {
  return new Promise((resolve, reject) => {
    try {
      // 1秒後に"非同期処理"とコンソールに出力
      setTimeout(() => {
        console.log("非同期処理")
        const num = 1
        resolve(num);
      }, 1000)
    } catch (e) {
      // 異常終了時の処理

      // returnの代わりに異常終了したことを表すrejectを呼び出して、rejectedを返す
      reject(e);
    }
  })
}

asyncFunction().then((num) => {
  console.log(`引数で受け取った値:${num}`);
}).catch(e => {
  console.log(`引数で受け取った値:${e}`);
})

上記コードを実行すると以下のような実行結果になります。

このように、処理の完了をお知らせするついでに引数に渡したいものを入れることで、then()catch()で受け取ることができます。

以上がPromiseの基本的な使い方になります。
ここまでで、理解できていない部分がある方は、先に進まずにゆっくりとPromiseについて理解をしてください。

async / await

async / await の基本理解

それでは、続いてES2017で導入されたasync / awaitについてみていきましょう。
といっても、Promiseを理解したら、async / awaitの理解はそんなに難しくありません。
なぜなら、async / awaitPromiseの糖衣構文だからです。
糖衣構文とは、

プログラミング言語において、読み書きのしやすさのために導入される書き方であり、複雑でわかりにくい書き方と全く同じ意味になるものを、よりシンプルでわかりやすい書き方で書くことができるもののこと

つまり、Promiseを簡単に書けるようにしたものが、async / awaitということです。

async

まずはasyncについてみていきましょう。
asyncは、非同期関数を作るものです。基本構文は以下の通りです。
function宣言の前にasyncとつけるだけです。

async
async function asyncFunction() {}

async functionの特徴は以下の通りです。

  • async functionは呼び出されるとPromiseを返す。
  • async functionが値をreturnした場合はその値をresolveする。
  • async functionが例外をthrowした場合はその値をrejectする。

ここでPromiseを返す???と思った方、Promiseとは3つの状態を持つオブジェクトのことでしたね。

  • pending:非同期処理の実行中の状態を表す
  • fulfilled:非同期処理が正常終了した状態を表す
  • reject:非同期処理が異常終了した状態を表す

実行したらpendingとか出力していたあれのことです。

ここで、Promiseasync functionでどのように書き方が異なるのか、サンプルコードで見てみましょう。

Promiseとasync functionの比較
function promiseFunction() {
  return new Promise((resolve, reject) => {
    try {
      resolve("resolve")
    } catch (e) {
      reject("reject")
    }
  })
}

// アロー関数で書くとこうなります
// const asyncFunction = async () => {
async function asyncFunction() {
  try {
    return "resolve"
  } catch (e) {
    throw "reject"
  }
}

どうでしょうか。async functionの方がreturn new Promise()がいらなくなったので、見た目がスッキリしていると思います。
Promiseがなくなったことで、resolverejectが引数に取れなくなったので、returnthrowがその代わりとなっていますね。

await

それでは、続いてawaitについてみていきましょう。
awaitは非同期処理の完了を待つためのものです。
もう少し具体的に説明すると、Promiseの結果(resolveもしくはreject)が返されるまで待機する(処理を一時停止する)演算子のことです。
ただし、ここで重要な注意点として awaitasync functionの中でしか使えない、という制約があります。

こちらもPromiseとの比較をみていきましょう。

Promiseとasync / awaitの比較
function promiseFunction() {
  return new Promise((resolve, reject) => {
    try {
      resolve("resolve")
    } catch (e) {
      reject("reject")
    }
  })
}

async function asyncFunction() {
  try {
    return "resolve"
  } catch (e) {
    throw "reject"
  }
}

promiseFunction().then(txt => {
  console.log(txt);
}).catch(e => {
  console.error(e);
})

async function main() {
  const txt = await asyncFunction()
  console.log(txt);
}
main()

少し比較しづらくなりましたが、awaitthen()catch()を置き換えている、ということがわかりますね。
非同期関数に対してawaitを使うことでresolve()reject()で返される値を待つことができます。

async / await の動作確認でよくやるミス

async / awaitを理解しようとサンプルコードを書いているときに、よくある(?)躓きポイントです。ここで引っかかると非同期処理がよくわからなくなると思ったので載せておきます。
まずは該当のサンプルコードをご覧ください。

async/await よくあるミス
async function asyncFunction() {
  try {
    setTimeout(()=>{
      return "resolve"
    }, 1000)
  } catch (e) {
    throw "reject"
  }
}

async function main() {
  const txt = await asyncFunction()
  console.log(txt);
}
main()

async / awaitPromiseの糖衣構文なのだから、これでOKでしょ!と思って実行すると

console.logの結果がundefindとなってしまいました。
awaitは非同期処理に対して、resolverejectが返されるまで待ってくれるはずなのに、このサンプルコードでは待ってくれませんでした。
一体どうしてでしょうか。

これは、JavaScriptのコールバック関数に関する話が絡んできます。
setTimeoutは引数にコールバック関数をとり、その中身を第2引数で指定したミリ秒後に実行します。

setTimeoutの基本構文
setTimeout(コールバック関数, 遅らせるミリ秒)

つまり、上記コードでsetTimeoutのコールバック関数はasync functionではなく、通常の関数になっているため、returnresolveではなく、通常の関数のreturnになってしまっているのです。

じゃあ、setTimeout(async () => {})とすればいいじゃないか、と思う方もいらっしゃるかもしれません。

async/await よくあるミス2
async function asyncFunction() {
  try {
    setTimeout(async ()=>{
      return "resolve"
    }, 1000)
  } catch (e) {
    throw "reject"
  }
}

async function main() {
  const txt = await asyncFunction()
  console.log(txt);
}
main()

しかし、これもまたNGなコード例です。
何故かというと、setTimeoutのコールバック関数を非同期関数にし、その中でreturnすなわちresolveをしても、その戻り値の送り先はsetTimeoutを呼び出している関数自身になります。(今回の場合はasyncFunctionが該当します)
そのため、asyncFunctionsetTimeoutからの戻り値を何かしらの形(変数など)で受け取っていないので、結果としてresolveは消失してしまっているのです。

それならば、return setTimeoutにすればいいじゃないか!
こういうことですね。

async/await よくあるミス3
async function asyncFunction() {
  try {
    return setTimeout(async ()=>{
      return "resolve"
    }, 1000)
  } catch (e) {
    throw "reject"
  }
}

async function main() {
  const txt = await asyncFunction()
  console.log(txt);
}
main()

残念ながらこれもまたNGな例です。
setTimeoutメソッドは戻り値としてtimerIdを返します。このtimerIdclearTimeoutメソッドへ渡すことで、タイムアウトを取り消すことができるものです。
参考:MDN - SetTimeout

そのため、setTimeoutのコールバック関数内でいくらreturnをしたところで、そのreturnはどこへ渡るでもなく消えてしまうのです。

つまり、async / awaitの検証で処理を遅らせるために、setTimeout()を用いることはできない、ということです。
一方でPromiseではresolveで戻り値を返すので、関数のreturnと認識されずに済む、という訳です。

以上、JavaScriptのややこしい小話でした。

then, catch, await

ここで、then()catch()Promiseに対してのみ使えて、awaitasyncに対してのみ使えるのか?と疑問に思う方が居るかもしれません。
async / awaitPromiseの糖衣構文という事からも想像ができますが、お互いに利用可能です。
こちらもサンプルコードで例をみていきましょう。

promise async/await mix ver
function promiseFunction() {
  return new Promise((resolve, reject) => {
    try {
      resolve("resolve")
    } catch (e) {
      reject("reject")
    }
  })
}

async function asyncFunction() {
  try {
    return "resolve"
  } catch (e) {
    throw "reject"
  }
}

// promiseFunction() から asyncFunction()に入れ替え
asyncFunction().then(txt => {
  console.log(txt);
}).catch(e => {
  console.error(e);
})

async function main() {
  // asyncFunction() から promiseFunction()に入れ替え
  const txt = await promiseFunction()
  console.log(txt);
}
main()

このように、先ほどのサンプルコードを入れ替えたものでも無事に動くことが確認できます。

以上がasync / awaitPromiseの糖衣構文というお話でした。
極論JavaScriptで非同期処理を扱いたい場合は、Promiseasync / awaitのどちらかが使えれば問題ないということです。

ここまで覚えていればJavaScriptにおける非同期処理は問題なく利用することができますが、さらに余力のある方は次に説明する非同期処理を並列で行うPromise.allについても理解しておくと、よりスッキリとしたコードが書けると思います。

非同期処理を並列で行う Promise.all について

Promise.all()メソッドはPromiseオブジェクトの配列を受け取り、全てのPromiseオブジェクトがresolveされたタイミングでthenが呼び出されます。

Promise.all の基本構文

Promise.allは、上記でも説明した通り、配列内の全てのオブジェクトがresolveされたタイミングでthenメソッドが呼ばれます。

Promise.all
Promise.all([taskA, taskB]).then(() => {})

こちらもサンプルコードをみてみましょう。

Promise.allサンプルコード
function promiseFunction() {
  return new Promise((resolve, reject) => {
    try {
      setTimeout(() => {
        resolve("resolve")
      }, 1000)
    } catch (e) {
      reject("reject")
    }
  })
}

async function asyncFunction() {
  try {
    return "resolve"
  } catch (e) {
    throw "reject"
  }
}

const asyncTaskA = promiseFunction()
const asyncTaskB = asyncFunction()

Promise.all([asyncTaskA, asyncTaskB]).then(() => {
  console.log(`全てのタスク完了!`);
})

このように、全ての非同期処理が完了するまで待機することができるので、いくつかのDBから値をとってきて、両方の値を利用して処理Aを行う、といったシチュエーションでとても役立つメソッドです。

ここで注意すべき点として、いずれかの非同期処理が1つでもrejectしてしまうと、Promise.allthenが呼び出されない、という点です。
こちらもサンプルコードをみていきましょう。

rejectを発生させる
function promiseFunction() {
  return new Promise((resolve, reject) => {
    try {
      setTimeout(() => {
        throw "意図的なエラー"
        resolve("resolve")
      }, 1000)
    } catch (e) {
      reject("reject")
    }
  })
}

async function asyncFunction() {
  try {
    return "resolve"
  } catch (e) {
    throw "reject"
  }
}

const asyncTaskA = promiseFunction()
const asyncTaskB = asyncFunction()

Promise.all([asyncTaskA, asyncTaskB]).then(() => {
  console.log(`全てのタスク完了!`);
})


このように、いずれかの非同期処理が失敗すると、Promise.allthenが呼ばれずに、"全てのタスク完了!"というログが出力されていないことがわかります。

これはこれでありがたい仕様ですが、たとえ1つがrejectされても続行してほしい!というシチュエーションもあるかと思います。
そんな時に役立つのがES2020で導入されたPromise.allSettled()です。

Promise.allSettled

Promise.allSettledPromise.allと比べて、いずれかのタスクがrejectを起こしても、その後のthenが呼ばれるメソッドです。

こちらもサンプルコードをみていきましょう。

Promise.allSettled
function promiseFunction() {
  return new Promise((resolve, reject) => {
    reject("reject")
  })
}

async function asyncFunction() {
  try {
    return "resolve"
  } catch (e) {
    throw "reject"
  }
}

const asyncTaskA = promiseFunction()
const asyncTaskB = asyncFunction()

Promise.allSettled([asyncTaskA, asyncTaskB]).then(() => {
  console.log(`全てのタスク完了!`);
})


このように、promiseFunction()rejectを返したとしても、"全てのタスク完了!"というログが出力されていることがわかります。

Promise.race

Promise.raceは2つ以上ののPromiseのうちの1つがresolveまたはrejectするとすぐに、そのPromiseの値または理由で解決または拒否する Promise を返します。

Promise.race
const promise1 = new Promise((resolve, reject) => {
  setTimeout(resolve, 500, 'one');
});

const promise2 = new Promise((resolve, reject) => {
  setTimeout(resolve, 100, 'two');
});

Promise.race([promise1, promise2]).then((value) => {
  console.log(value);
});

このように、Promiseの中で最初に解決されたpromise2で返されている値がログが出力されていることがわかります。

Promise.any

続いてES2021で追加されたPromise.anyです。
Promise.receでは最初にresolveもしくはrejectされた値を返しますが、Promise.anyでは最初にresolveされた値を返します。

Promise.any
const pErr = new Promise((resolve, reject) => {
  reject("Always fails");
});

const pSlow = new Promise((resolve, reject) => {
  setTimeout(resolve, 500, "Done eventually");
});

const pFast = new Promise((resolve, reject) => {
  setTimeout(resolve, 100, "Done quick");
});

Promise.any([pErr, pSlow, pFast]).then((value) => {
  console.log(value);
})

このように、最初にreject("Always fails");が完了しますが、Promise.anyでは最初にresolveされた値を返すので、Done quickが返されています。

以上、JavaScriptの非同期処理についての解説でした。

Discussion

スリーコードスリーコード

ぶ厚いJavaScript参考書を読んだり、有料のWEB記事を読んでも非同期通信・Promiseについて全然理解できなかった人間です。
ですが、この記事はとてもわかりやすくて理解できました。
ありがとうございます。

てんてるてんてる

嬉しいお言葉ありがとうございます!
お役に立てたようで何よりです😆