はじめに

表題のモチベーションで書き上げた備忘録に加筆・修正したものを記事にしました。
記事を読んで下さった方の Promise, async/await の理解度が 1LV でもアップしてくれたら嬉しいなと思います。

Promise とは

  • Promise は非同期関数を扱うためのインターフェース
  • Promise は 悲運のピラミッド型コールバック (ネスト地獄)を根本的に解決してくれる
  • thenable と呼ばれる then メソッドを有するオブジェクトを resolve の第一引数に入れることで Promise オブジェクトへ変換することが可能
// thenable を Promise オブジェクトへ変換する
const converted = Promise.resolve({
  then: (onFulfilled) => return onFulfilled('be promise')
})
converted.then(value => console.log(value)) // be promise

async/await とは

  • async/await は Promise のシンタックスシュガーとして用いられる
  • async/await は Promise を同期的なコードのように書ける

Promise の書き方とメソッドの簡単な説明

Promise の最も基本的なコード

const promise = new Promise((resolve, reject) => resolve('解決した'))
const unResolvedPromise = new Promise((resolve, reject) => reject('エラー発生'))

promise
  .then((value) => value) // 解決した

unResolvedPromise
  .then((value) => value) 
  .catch((errorMessage) => new Error(errorMessage)) // error: エラー発生
  .then((value) => value) // エラー発生

then と catch

then と catch はプロミスにおける最重要事項の一つです。これらは promise を返すため、メソッドチェーンを繋げることができます。

then

Promise#then(onFulfilled, onRejected)

  • Promise に成功ハンドラ(onFulfilled) と失敗ハンドラ(onRejected) を付加する
  • 2つの引数を持ち、それらはそれぞれ Promise の状態が Fulfilled の時と Rejected の時のコールバック関数である
  • 返り値は promise

onFulfilled(value)

  • 一つの引数 value を持つ
  • value は promise にラップされた値など

onRejected(reason)

  • 一つの引数 reason を持つ
  • reason は rejected となった理由など

catch

Promise#catch(onRejected)

  • then(undefined, onRejected) のショートカットとして機能する
  • 返り値は promise
  • 引数の onRejected は then と参照

静的メソッド

Promise.resolve

下記コードのエイリアスです。渡した値で Fulfilled される promise オブジェクトを返します。

new Promise((resolve) => resolve(/* value */))

Promise.reject

下記コードのエイリアスです。

new Promise((resolve, reject) => reject(/* reason */))

Promise.all

  • 引数に Promise 関数の配列を取る実行
  • 返り値は Promise
  • 引数に取った Promise で一つでもエラーが発生した場合はその rejected されたエラーが返る
  • エラーが発生しなかった場合は「value にそれぞれの resolve の結果が入った配列」の Promise が返る
Promise.all(promises).then(allFulfilled, firstRejected)

Promise.race

  • 引数に Promise 関数の配列を取り実行する
  • 引数にとった Promise 関数のいずれかが fullFilled または rejected になった時点でその Promise を返す
Promise.race(promises).then(firstFulfilled, firstRejected)

Promise の仕様的なところ

async/await をちゃんと使えるようになるには Promise が内部でどう働いているかある程度わかっている必要があります。

Promise は状態をもつ

インスタンス化された promise のオブジェクトには以下の 3 つの状態が存在します。

  1. Fulfilled
  2. Rejected
  3. Pending
  • promise のオブジェクトの状態は不変である
  • 一度 Fulfilled か Rejected になったら Pending には戻らない
  • Fulfilled, Rejected は Settled(落ち着いた状態, 不変の状態)ともいう
  • Pending から Settled になったら状態はもとに戻らない

渡された関数の実行タイミング

  • すぐに実行されるのではなく、マイクロタスクのキューに入れられる
  • このキューは現在のイベントループの終わりに空になる

async/await の書き方

console.log(101) するコードを (1)then catch, (2)async/await の2パターン書きました。
doSomething 関数内でエラーが発生した場合は failureCallback にエラーが渡されてログに出すようにしています。

then と catch を使った場合

const doSomething = () => Promise.resolve(100)
const failureCallback = (e) => console.log(e)

doSomething()
  .then(v => v + 1)
  .then(v => console.log(v)) // 101
  .catch(failureCallback);

async/await を使った場合

// ※ 即時関数で囲っています
const doSomething = () => Promise.resolve(100)
const failureCallback = (e) => console.log(e)

(async () => {
  try {
    let result = await doSomething()
    result = result + 1
    console.log(result) // 101
  } catch (e) {
    failureCallback(e)
  }
})();

サンプルコード集

筆者の場合 Promise, async/await は説明やコードを読んでいるだけではいまいち理解できなかったので、実際にコードを書いて動かして理解を深めました。その過程で特に残しておきたい(忘れた時に後から見たい)と思ったコードを載せております。

古いコールバックを使った API をラップする

setTimeout をラップします。これはコールバックのAPIをラップしたもっともシンプルな例かもしれません。

// setTimeout を Promise にする
const wait = ms => {
  return new Promise((resolve) => setTimeout(resolve, ms))
}
wait(1000).then(() => console.log('logged after 1000ms'))

複数の関数を直列に合成する

const composeAsync = (...funcs) => {
  return x => {
    return funcs.reduce((acc, val) => {
      return acc.then(val)
    }, Promise.resolve(x))
  }
}

// 使用例
const succ = n => n + 1
const multiply = x => y => x * y

const transformData = composeAsync(succ, succ, multiply(3))
transformData(2)
  .then(v => console.log(v)) // 12

async/await と Promise のコードを比較してみる

async function 内で console.log(100)、async function を呼ぶときに console.log(100) するコードで比較してみます。両コードの挙動は全く同じです。
async/await 使った方が圧倒的に書きやすく、またコードを見た時にどういう動きをしそうか、直感的に理解しやすいと思いました。

async/await

const ret100 = () => Promise.result(100)

const asyncFunc = async () => {
  let result = await ret100()
  result += await ret100()
  console.log(result) // log.1 200
  return result // Promise を返す
}
asyncFunc()
    .then(value => console.log(value)) // log2. 200

Promise

const ret100 = () => Promise.result(100)

const asyncFuncPromise = () => {
  let result = 0
  return ret100()
    .then(value => {
      result += value
      return ret100()
    })
    .then(value => {
      result += value
      console.log(result) // log.1 200
      return result
    })
}

asyncFuncPromise()
  .then(value => console.log(value)) // log.2 200

async/await のエラー処理の記述パターン

下記はサンプルコード内で使用している errorPromise 関数です。

const errorPromise = () => {
  return new Promise((resolve, reject) => {
    try {
      throw new Error('Some error occured')
      resolve('no error')
    } catch (err) {
      reject(err)
    }
  })
}

1. try/catch を使う

const asyncFuncTryCatch = async () => {
  try {
    const result = await errorPromise()
    console.log(result) // unreachable
  } catch (err) {
    console.log(err)
  }
}

asyncFuncTryCatch() // log. Some error occured

2. try/catch を使わない

const asyncFuncNoTryCatch = async () => {
  const result = await errorPromise()
    .catch(err => err)
  console.log(result)
}

asyncFuncNoTryCatch() // Some error occured

3. async function 内でエラーハンドリングしない

const asyncFuncHandleErrorOut = async () => {
  const result = await errorPromise()
  console.log(result) // unreachable
}

// async function を呼ぶ時にエラーハンドリング
asyncFuncHandleErrorOut()
  .catch(err => console.log(err)) // Some error occured

おまけ. Promise のエラーハンドリング

const asyncPromiseFunc = () => {
  return errorPromise()
    .then(value => value)
    .catch(err => err)
}

asyncPromiseFunc() // Some error occured

非同期関数を並列で実行させたいとき

非同期関数を並列で実行させたいときは Promise.all を使用する必要があります。

例えば、下記のようなコードがあり、 getFoodgetDrink を並列で実行し、両関数が完了した後に orderItems を実行したいとします。

const getFood = () => {
  return new Promise(resolve => {
    setTimeout(resolve, 1000, 'got food')
  })
}

const getDrink = () => {
  return new Promise(resolve => {
    setTimeout(resolve, 1000, 'got a drink')
  })
}

const orderItems = (...items) => {
  return new Promise(resolve => {
    setTimeout(() => {
      items.forEach((msg) => console.log(msg))
    }, 2000)
  })
}

NGパターン

下記コードは getFood > getDrink > orderItems のように直列で実行されてしまいます。

const myOrder = async () => {
  const msg1 = await getFood()
  const msg2 = await getDrink()
  orderItems(msg1, msg2)
}

myOrder()

OKパターン(1)

(getFood, getDrink) > orderItems のように並列で実行します。
NGパターン(1) 同様 getFood > getDrink > orderItems のように直列で実行されます。
@azu さん 指摘して頂きありがとうございます。)

※修正:20180501.1745
改めて確認したところ、(getFood, getDrink) > orderItems のように並列で実行されるようでした。
但し、 OKパターン(2) のように Promise.all を使用するのが一般的のようなので、そちらを使うのがベターと思われます。

 const myOrder = async () => {
  const msg1 = getFood()
  const msg2 = getDrink()
  orderItems(await msg1, await msg2)
}

myOrder()

OKパターン(2)

(getFood, getDrink) > orderItems のように getFoodgetDrink は並列で実行されます。一般的に使われているのはこちらのパターンのようです。

 const myOrder = async () => {
  const msg1 = getFood()
  const msg2 = getDrink()
  const msgs = await Promise.all([msg1, msg2])
  orderItems(...msgs)
}

myOrder()

おわりに

以上です。
async/await にしろ Promise にしろ、ドキュメントや記事(この記事のような)を読むだけでなく、自分で手を動かしトライ&エラーを繰り返すことって大事だなとあらためて思いました(どの技術の習得もそうですが・・)。

コードや文章の指摘、質問も頂けると助かります。
再度までお読み頂きありがとうございました。

参考資料

Next steps

2014contribution

@SotaSuzuki OKパターン1ですが、恐らく並列ではなく直列で処理されると思います。
getFoodgetDrinkの実行自体はすぐ行われますが、orderItemsが呼び出されるのは、
getFood()のPromiseが解決 -> getDrink()のPromiseが解決 -> orderItems()を呼び出すという順番になります。
(関数の引数は左から順に処理されので左が解決されるまでは右へ移動しない。single var patternでのカンマ区切りの処理と同じです。)
https://qiita.com/SotaSuzuki/items/f23199e864cba47455ce#ok%E3%83%91%E3%82%BF%E3%83%BC%E3%83%B31

await の理解度をもう1段階上げる - Qiita 2018-05-01 11-35-55.png

OKパターン1は意味的には次のように書いたのと同じです。

const getFood = () => {
  return new Promise(resolve => {
    setTimeout(resolve, 5000, 'got food')
  })
}

const getDrink = () => {
  return new Promise(resolve => {
    setTimeout(resolve, 1000, 'got a drink')
  })
}

const orderItems = (...items) => {
  return new Promise(resolve => {
    setTimeout(() => {
      items.forEach((msg) => console.log(msg))
    }, 2000)
  })
}

const myOrder = async () => {
  const msg1 = getFood()
  const msg2 = getDrink()
  const ret1 = await msg1;
  const ret2 = await msg2
  orderItems(ret1, ret2);
}

myOrder()

なので、次のような感じになるかなと思います。

  • OKパターン1: getFood -> getDrink > orderItems
  • OKパターン2: (getFood, getDrink) > orderItems
2014contribution

2. try/catch を使わない

await が抜けている気がします。

const errorPromise = () => {
  return new Promise((resolve, reject) => {
    try {
      throw new Error('Some error occured')
      resolve('no error')
    } catch (err) {
      reject(err)
    }
  })
}

const asyncFuncNoTryCatch = async () => {
  const result = await errorPromise()
    .catch(err => err)
  console.log(result)
}

asyncFuncNoTryCatch() // Some error occured
615contribution

OKパターン1でもgetFoodとgetDrinkは並列に動きますね。
Promise内部のコールバック関数は即座に実行されます。
その後のawaitで直列になります。

710contribution

@azu さん
指摘して頂きありがとうございます。
上のOKパターン(1)について自分でも試してみたところ直列で実行されているようでした。
指摘して頂いた該当箇所を修正いたしましたので時間がある時にご確認頂けますと幸いです。

710contribution

@kurogelee さん
コメントありがとうございます。
getFoodとgetDrinkは並列に動くようですが、その後 orderItems 内で引数を処理する際に直列になるようですね。
例としてあまり良いものでは無いかもしれませんね、該当箇所は消してもよい気がしてきました。

710contribution

@azu さん
再度試してみたところ、以前の OKパターン(1)のような書き方でも並列で実行されるようだったので、記事を再度修正しております。

どうやら await なしで関数を実行したときに fullFilled の状態まで処理が進むため、orderItems を実行したときの引数を処理する時に待つ必要がなくなるのだと思われます。

33contribution

重箱の隅ですが…

Promise ベースの関数(thenable な関数という)同士ならメソッドチェーンでつなげて書ける

若干曖昧な表現なので、下記仕様のように

“thenable” is an object or function that defines a then method.

then メソッドがあるオブジェクトまたは関数とした方が良いのではないかと思います。
更にチェーンで繋ぐには Promise.resolve に渡す必要もあります。
↓こんな感じでしょうか。

let thenable = {
  then: (onFulfilled) => setTimeout(() => onFulfilled("thenable"), 300)
};
Promise.resolve(thenable).then(result => console.log(result));

あと、
const msgs = await Promise.all(msg1, msg2)

const msgs = await Promise.all([msg1, msg2])
でないとエラーになります。

615contribution

"コールバックは現在の JavaScript イベントループの実行よりも前には呼び出されない" についてですが
Promiseオブジェクトを作成する際に渡すコールバックについては、現在のイベントループ中に実行されるようです。
ts-node/Node.jsで以下を実行すると数字の順番通りに表示されました。

promise.ts
async function method() {
    return 1;
}
function main() {
    console.log(1);
    new Promise(async resolve => {
        resolve();
        console.log(2);
        await method();
        console.log(4);
    }).then(value => {
        console.log(5);
    });
    console.log(3);
}
main();
710contribution

@chuntaro さん
指摘頂きありがとうございます!件の箇所は曖昧さを回避するよう修正いたしました。
(Promise.all の引数もありがとうございます)

710contribution

@kurogelee さん
コメントありがとうございます。すぐにコールバック呼ばれてますね。
私もきちんと理解できているわけではなかったため、この "コールバックは現在の JavaScript イベントループの実行よりも前には呼び出されない" という箇所は差し控えることにします。

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.