Aikの技術日記

技術的な進捗とか成果とかを細々と投稿するブログです。時々雑記も。

JSの非同期処理の仕組みをまとめてみる Part2

※本編は続きものとなっている&Part1から話が繋がっております。
Part1はこちら

はじめに

こんにちは、筆者です。

前回の記事では「JSの非同期処理の仕組みをまとめてみる」と題し、

  • JavaScriptの非同期処理の種別
  • JavaScriptにおける非同期処理について
  • 非同期処理その1setTimeoutについて

…を見ていきました。
今回は「非同期処理その2Promiseについて」をまとめていこうかと。

それではいきましょう٩( ‘ω’ )و

非同期処理その2Promiseについて

非同期処理の仕組みの話…の前に、まずはPromise自体の簡単な説明から。
※既にご存知な場合はPromiseの非同期処理の仕組みからお読みください

Promiseとは

Promiseとは何か、MDNさんでは冒頭にこう書かれています:

Promise オブジェクトは、非同期処理の完了 (もしくは失敗) の結果およびその結果の値を表します。
〜中略〜

プロミス (Promise) は、作成された時点では分からなくてもよい値へのプロキシーです。
非同期のアクションの成功値または失敗理由にハンドラーを結びつけることができます。
これにより、非同期メソッドは結果の値を返す代わりに、未来のある時点で値を提供するプロミスを返すことで、同期メソッドと同じように値を返すことができるようになります。

Promiseは下記の3つの状態を持ちます:
・待機 (pending): 初期状態。成功も失敗もしていません。
・履行 (fulfilled): 処理が成功して完了したことを意味します。
・拒否 (rejected): 処理が失敗したことを意味します。
参考: Promise - JavaScript | MDN

上記をコードを元に解説していくとこんな感じです:

function foo(){
  console.log("foo");
  // 状態が履行(fulfilled)になった=処理が成功したということをPromise側に伝達
  // この処理の後に`resolve`で指定されたコールバック関数が実行される
  // `resolve`には引数を1つ指定可能、コールバック関数に伝達できる
  // もしこれが`reject('foo')`であれば、処理が失敗したということをPromise側に伝達することになる
  // なお、本関数を直接実行すると「Promiseの世界の中にいる`resolve`が存在しない」ためエラーとなる
  resolve("test");
}
// Promiseを作成(関数の定義と同じで、この段階では中身の処理は実行されない)
const myPromise = new Promise(foo);

// ここでPromise内部の処理を実行
// `.then`でPromise処理が完了した後(状態が待機(pending)以外の時)に実行したい処理を記す事ができる
//   →第1引数:処理成功時(状態が履行(fulfilled))に実行したい処理
//   →第2引数:処理失敗時(状態が拒否(rejected))に実行したい処理
// Promise内部の処理では`resolve`を返しているので、この後に`handleResolvedA`が実行される
myPromise
  .then((result)=>{console.log(result+"success!")}, ()=>{console.log(result+"failed!")});
console.log("test");

// 出力は`test`→`foo`→`testsuccess!`となる

これまでお話ししたsetTimeoutと大きく異なる点としては、「秒数を指定して遅延というのができない」点です。
ただし、上記コードにある様にPromise.thenをくっつけることで「Promise内部で処理された関数を実行した後の処理」を設定できます。
これがPromiseの強みとなっています。

また.then自体もPromiseオブジェクトなので、ES6以前ではよく見かけた下記の様なコールバック地獄を:

doSomething(function(result) {
  doSomethingElse(result, function(newResult) {
    doThirdThing(newResult, function(finalResult) {
      console.log('Got the final result: ' + finalResult);
    }, failureCallback);
  }, failureCallback);
}, failureCallback);
// コード引用: https://developer.mozilla.org/ja/docs/Web/JavaScript/Guide/Using_promises#%E9%80%A3%E9%8E%96

下記の様にすっきりと書く事ができます。

doSomething()
.then(function(result) {
  return doSomethingElse(result);
})
.then(function(newResult) {
  return doThirdThing(newResult);
})
.then(function(finalResult) {
  console.log('Got the final result: ' + finalResult);
})
// `.then`の第2引数しか受け付けないバージョン
// この場合だと、上記実行中のどこかでエラーとなった場合に下記が実行される
.catch(failureCallback);

実際の現場では、API通信処理にこのPromiseが使われる事が多いかなと。
※具体的な使用例: Twitter APIを使ってツイート情報を取得し、その取得データをconsole.log()で出したい

API通信は「API通信が成功した後のデータ」をもとに後続の処理が実行されるので、Promiseの様に「〇〇の処理をした後の処理を定義できる」のと相性がいいのです。
また、API通信失敗時もrejectを返却&.catchでエラー時の処理を書くこともできるので最高です。

実際のコード例はこんな感じかなと:

// fetchは元々Promiseオブジェクトを返す処理
// API通信が成功すれば(400エラーや500エラーも含む)`resolve`、ネットワークのエラーやリクエストが妨害された際は`reject`を返す
fetch('https://hoge.fuga')
  // 成功時: APIデータをConsole上に出す
  .then(response => console.log(response.json()))
  // 失敗時: エラーデータをConsole上に出す
  .catch(err => console.log(err))

余談:.thenにコールバック関数を指定しないとどうなるか 下記の様に、.thenにコールバック関数を指定しなくても:

const promise1 = new Promise((resolve, reject) => {
  resolve('Success!');
});

promise1.then();

JavaScriptエラーとはなりませんし、処理も正常終了します。
これについてはMDNさんの記述を参考に:

片方または両方の引数が省略されたり、関数ではないものが渡されたりした場合、 then にはハンドラーが不足しますが、エラーは発生しません。
Promise が状態 (fulfillment (完了) または rejection (拒否)) を受け入れるに当たって then が呼び出された際に、 then がハンドラーを持たない場合は、 then が呼び出された元の Promise の最後の状態を受け入れた、追加のハンドラーのない新しい Promise が生成されます。
参考: Promise.prototype.then() - JavaScript | MDN

ちなみに…。
.catchについてもコールバック関数を指定しないでの実行は可能かどうかやって見たところ、特にエラーは出ませんでした。

サンプルコード:

const promise1 = new Promise((resolve, reject) => {
  throw 'Uh-oh!';
});
promise1.catch();

Promiseオブジェクト自体はしっかり「状態が拒否(rejected)」になっていたので、おそらく.thenと同様だと思いますが…。
MDNさんの公式ページにその様な言及はなかったため、そうとは確証はできないかもです。

…ちょっと長くなってしまいましたが、Promiseの説明はここまで。
お次はいよいよPromiseの非同期処理の仕組みについて見ていきます。

Promiseの非同期処理の仕組み

今回もサンプルコードをもとに処理を追っていきます。

setTimeout(function() { // (A)
    console.log('A');
}, 0);
// ①
Promise.resolve().then(function() { // (B)
    console.log('B');
}).then(function() { // (C)
    console.log('C');
});
// ②
console.log('D');
// ③
// コード引用: https://meetup-jp.toast.com/896

setTimeoutも入った上記サンプルコードですが、出力される順序は「D->B->C->A」となります。
setTimeoutの待機時間は0ですが、Promiseの方が早く実行されます…この理由はPromiseが「マイクロタスク」というものを使用するからです。

順に説明していきます。
まず、前回記事のおさらいから…。

(1)の時点でsetTimeout自体はすぐに実行され、Timer側で「コールバック関数をタスクキューに入れる」という処理が実行されます。
※0秒を指定しているので、この処理はすぐに実行されます

そして(2)の時点ですが…。
Promiseは即座にresolveされるので、Promiseの状態は既に履行(fulfilled)となっています。
あとはその後のコールバック関数を実行するだけですが、このコールバック関数はマイクロタスクキューというタスクキューとは別のキューに格納されます。

その後(3)まで到着し、console.log('D');が即座に実行され…。
setTimeoutだけであればお次はconsole.log('A');が実行されます…が。

イベントループはタスクキューよりもマイクロタスクキューを優先して処理するため、先にPromise側の処理が実行されます。
その処理実行後に再度イベントループが発動した際も、イベントループは引き続きマイクロタスクキューを優先するため.then内部の処理が実行され…。
最後にタスクキューに格納されているsetTimeout側の処理が実行され、処理は終了となります。

この「イベントループはタスクキューよりもマイクロタスクキューを優先して処理する」というのが、Promiseの方がsetTimeoutより早く実行されるメカニズムとなります。

小咄:マイクロタスクとマクロタスク

本記事でPart1より話しているタスクキューについてですが…。
マイクロタスクのお話が出ている記事では、タスクキューは「マクロタスクキュー」と呼ばれていました。
従来のタスクキューと区別するためにそう呼ばれているのかなと…。

ES6以前のお話や広義の意味でのお話をするなら「タスクキュー」という言葉で通用しそうですが、それ以降ではきちんと「マイクロタスク」と「マクロタスク」というふうに用語を切り分けた方が良さそうです。

小咄:以前のPromiseはブラウザによって処理順序が異なった

Promiseについて調べていると、興味深い記述があったので引用させていただきます:

原文では、ブラウザ毎にプロミスの呼び出し手順が異なると指摘されていますが、その理由は、プロミスがECMAScriptに定義されているのに対し、マイクロタスクはHTMLスペックに定義され、両方の関連付けが明確でないためです。
ECMAScriptはES6からプロミス向けにジョブキュー(Job Queue)という項目が追加されましたが、HTMLスペックのマイクロタスクとは別の概念です。)
しかし、最近のLiving Standard状態であるHTMLスペックを見ると、JavaScriptのジョブキューをどのようにイベントループと連動するかについての項目が含まれています。
また現在では、ほとんどのブラウザで問題が修正されていることを確認できます。

プロミスA+スペック文書を見ると、実装時に一般タスク、マイクロタスクの両方とも使用できていると書かれています。
実際にプロミスが初めてJavaScriptに導入された時点では、プロミスをどのような手順で実行するかについて多く議論されたようです。
しかし前述したように、現在ではプロミスをマイクロタスクと定義しても無理がなさそうです。)
マイクロタスクか一般タスクかによって実行されるタイミングが異なるため、両方を正しく理解し、区別して使用することが重要です。
例えば、マイクロタスクが継続して実行される場合、一般タスクであるUIレンダリングが遅延する現象が発生することもあるでしょう。
参考: JavaScriptとイベントループ | NHN Cloud Meetup

なお、上記に書かれている「ブラウザ毎にプロミスの呼び出し手順が異なる」という話題ですが…。
こちらの記事の真ん中くらいに当時の各ブラウザごとの違いが載ってます: Tasks, microtasks, queues and schedules - JakeArchibald.com
※引用文にあった「原文」というのも、上記記事のことを指します

筆者も見てみましたが、各ブラウザで恐ろしいほどに異なっていたので…。
きちんと整理されて統一化されたのは安堵しかないですね…。

まとめ&おわりに

本記事では、Promiseでの非同期処理の仕組みについてまとめました。
今回も色々話してしまいましたので、軽くまとめておきます:

  • PromisesetTimeoutとは異なる形で非同期処理を記述可能
  • PromisesetTimeoutよりも早く実行される
  • Promiseの処理はタスクキュー(マクロタスクキュー)よりもイベントループでの参照優先度が高い「マイクロタスクキュー」に入れられる

また、余談となりますが…。
Promiseの様に時が来たらマイクロタスクに処理が突っ込まれるのは他にもあります。

例えばMutationObserverもマイクロタスクに処理が突っ込まれるものの様です。
この辺りの理解が深まれば「JavaScriptでなんかうまく動かない…」という時に参考になるかもしれません。

また、なんと「マイクロタスクキューに直接処理を登録する」なんてこともできちゃいます。こんな感じ:

queueMicrotask(() => {
  log("The microtask has run.")
});
// コード参照: https://developer.mozilla.org/ja/docs/Web/API/HTML_DOM_API/Microtask_guide#examples

詳しくはこちらの記事を参照に: Using microtasks in JavaScript with queueMicrotask() - Web API | MDN

…とまぁ色々語りましたが、ひとまず本記事の本編はここまでとしたいと思います。
自分自身のJavaScriptの理解も数段深まった、良いテーマでございました…_:(´ཀ`」∠):

それでは|д゚*)

参考記事

今回の記事も下記の記事をもとに、自分なりに理解しまとめたものになります。
もし本分野を深掘りしたい場合は、下記の記事がとても参考になるかと思います。

余談:async,awaitについて

本題から逸れてしまうと思い言及を避けましたが…。
Promiseについて話すのであれば、async,awaitもせっかくだし言及したいなと思い余談として設けました。

async,awaitはざっくりいうと「Promiseを簡単に使えるためのもの」です。
例えば下記のPromiseの処理は:

fetch('coffee.jpg')
.then(response => {
  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }
  return response.blob();
})
.then(myBlob => {
  let objectURL = URL.createObjectURL(myBlob);
  let image = document.createElement('img');
  image.src = objectURL;
  document.body.appendChild(image);
})
.catch(e => {
  console.log('There has been a problem with your fetch operation: ' + e.message);
});
// コード引用: https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Asynchronous/Async_await

async,awaitを使うと、こんなふうに超直感的に書けちゃいます。

async function myFetch() {
  let response = await fetch('coffee.jpg');

  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }

  let myBlob = await response.blob();

  let objectURL = URL.createObjectURL(myBlob);
  let image = document.createElement('img');
  image.src = objectURL;
  document.body.appendChild(image);
}

myFetch()
.catch(e => {
  console.log('There has been a problem with your fetch operation: ' + e.message);
});
// コード引用: https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Asynchronous/Async_await

なお、async,awaitモダンブラウザであれば大概使用できますが…。
微妙に古いバージョンだと「Promiseは使えるのにasync,awaitは使えない」バージョンのブラウザもあるのでお気をつけて。

※詳しくはこちら参照:

とりあえず使い方だけ覚えたい場合は、下記を押さえておけば問題ないです:
※筆者はこの理解でasync,awaitを使ってました

  • asyncは関数につける接頭詞として使え、この中ではawaitが使える
  • awaitasync関数かPromiseを返す関数の前に使用できる接頭詞
  • awaitをつけると、それ以降の処理はawaitの行が完了した後に実行される

…悪い点としては、先にasync,awaitを覚えちゃうとPromiseの理解が覚束なくなる所でしょうか…。
実際筆者も先にこれらを覚えた口であり、ぶっちゃけ本記事をまとめるまでPromiseの理解が浅いままでした…orz

async,awaitについて詳しく知りたい場合は、下記の記事をご参考に:
Making asynchronous programming easier with async and await - Learn web development | MDN