ES2015が備えるモダンな非同期処理

2017年3月28日(火)
太田 智彬
ES2015に追加された非同期処理の新しい記述方法について学ぶ。

今回は、ECMAScript2015(ES2015)で追加された新機能を用いてモダンな非同期処理を学んでいきます。慣れないうちは文法的に戸惑うかもしれませんが、理解してしまえば、従来よりもはるかに簡潔な記述で非同期処理を書けるようになるので、落ち着いて習得していきましょう。

Promise

Promiseとは非同期処理を抽象化したオブジェクト、およびそれを制御する仕組みのことを言います。抽象化したオブジェクトは「Promise Object」と呼ばれ、三種類の状態が存在します。

  • 処理が成功した状態 Fulfilled
  • 処理が失敗した状態 Rejected
  • 処理が完了していない状態 Pending

この状態を深く気にする必要はないのですが、この後に用語として出てきますので頭の片隅に置いておいてください。「Promise Object」が持つ状態を把握したところで、実際のコードに触れながら理解を深めていきましょう。まずは、この仕組みの登場以前のレガシーな非同期処理を見てみます。

リスト1:ES2015以前のレガシーな非同期処理の記述

setTimeout(() => {
  console.log('sleep')
}, 2000)

これをPromiseを用いて書き直してみましょう。新しい機能だからといって難しいことはありません。コールバック完了のタイミングで引数のresolveを実行することでFulfilledの状態にしてやり、その後「.then」を使ってコールバックの処理を記述するだけです。Promise とはその名のとおり、「約束」を表しています。約束が満たされた時は then を使って約束が満たされた後の処理を行うことができます。

リスト2:Promiseを用いた非同期処理

const sleep = (delay) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve()
    }, delay)
  })
}

sleep(2000).then(() => {
  console.log('done')
})

Promiseを使った書き方に直したものの、この例だとPromiseを使わない方が簡潔な気さえします。Promiseの効果を実感できる例として、コールバックの中でさらに非同期処理を行い、ネストが深くなる場合を見てみましょう。

リスト3:非同期処理のネスト(レガシーな記述)

setTimeout(() => {
  console.log('2000ms later')

  setTimeout(() => {
    console.log('4000ms later')
  }, 2000)
}, 2000)

ネストが一段深くなり、可読性が落ちました。これを、Promiseを用いて書き換えると下記のようになります。

リスト4:非同期処理をPromiseを用いて記述する

sleep(2000).then(() => {
  console.log('2000ms later')
  return sleep(2000)
}).then(() => {
  console.log('4000ms later')
})

いかがでしょうか。非同期処理なのにも関わらず、一連の処理がネストせずに同レベルで記述されているので、実行順序が非常に追いやすくなっているのがわかると思います。

さて、これまでエラー処理のないものを見てきましたが、Promiseはもちろんエラー処理も簡潔に記述できます。まずはいつものように、Promiseを使わない例からみていきましょう。

リスト5:エラー処理(レガシーな記述)

const url = '/api/area.json'
const responseType = 'json'

const request = new XMLHttpRequest()
request.onload = function () {
  if (this.status === 200) {
    successHandler(this.response)
  } else {
    errorHandler(new Error(this.statusText))
  }
}
request.onerror = function () {
  errorHandler(new Error(`XMLHttpRequest Error: ${this.statusText}`))
}

request.open('GET', url)
request.responseType = responseType
request.send()

これを「new Promise」で包んで抽象化します。と言っても実際にはerrorHandlerをrejectに置き換えてやるだけです。

リスト6:Promiseを用いたエラー処理の記述

const httpGet = (url, responseType = 'json') => {
  return new Promise(function (resolve, reject) {
    const request = new XMLHttpRequest()
    request.onload = function () {
      if (this.status === 200) {
        resolve(this.response)
      } else {
        reject(new Error(this.statusText))
      }
    }
    request.onerror = function () {
      reject(new Error(`XMLHttpRequest Error: ${this.statusText}`))
    }

    request.open('GET', url)
    request.responseType = responseType
    request.send()
  })
}

こうしてhttp requestの部分を抽象化してやることで、下記のようにエラー処理の記述も簡潔になります。

リスト7:簡潔に書けるエラー処理

httpGet('/api/area.json').then(data => {
  console.log(data)
}).catch(status => {
  console.error(`Error: status code ${status}`)
})

以上がPromiseの基本的な機能ですが、他にも便利なメソッド群が提供されているので紹介したいと思います。

Promise.all

引数で与えられた全てのPromiseを並列に実行し、全てが成功した時にはじめてresolveが呼ばれます。逆に言うと一つでも失敗するとrejectが呼ばれます。個別の完了時は気にせず、すべて完了したら特定の処理を行いたい場合や、可変長つまり数が決まっていない非同期処理などを記述する時に便利です。

リスト8:Promise.allの使用例

Promise.all([
  httpGet('/api/airplane'),
  httpGet('/api/ticket')
]).then(value => {
  gotoTravel(value)
})

Promise.race

引数で与えられたPromiseが一つでも成功したり失敗したりすると、resolveが実行されます。Promise.allは並列実行し全ての処理がresolveされたらthenで値が取れるのに対し、Promise.raceの場合、どれか一つがresolveされたらthenで値が取れる、という違いがあります。

Promise.raceはtimeoutの実装時に使えますが、どちらか一方がresolveされたとしてもそれまでの処理が中断されたりしないので、使用にはある程度注意が必要な機能です。

リスト9:Promise.raceの使用例

Promise.race([
  httpGet('/api/food/beef'),
  httpGet('/api/food/chicken')
]).then(menu => {
  eat(menu)
})

Promise.resolve

Promise.resolveには二つの役割があります。一つはresolve関数に与えた値をPromise化させることです。「jQuery.ajax()」を例にとって説明してみましょう。

リスト10:Promise.resolveの使用例(1)

const promise = Promise.resolve($.ajax('http://api.server/user'))
promise.then((value) => {
  console.log(value)
})

こちらのコードを実行すると、「const promise」には「Promise Object」が入っているのを確認できると思います。このように、.thenを持っているオブジェクト、つまり「Thenable Object」を、「Promise Object」に変換することができるのです。

そしてもう一つの役割として、FulfilledなPromise Objectを即座に返す機能が挙げられます。「Array.prototype.reduce」と組み合わせて非同期処理を直列実行する時や、非同期処理の中に同期処理を混ぜて書きたい時などに有効です。

リスト11:Promise.resolveの使用例(2)

const sleep = (delay) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(delay)
    }, delay)
  })
}

const tasks = [
  sleep(100),
  sleep(200),
  sleep(300)
]

const promise = tasks.reduce((prev, current) => {
  return prev.then(delay => {
    console.log(delay)
    return current
  })
}, Promise.resolve(0))
// 0
// 100 # 100ms later
// 200 # 300ms later

promise.then(delay => {
  console.log(delay)
})
// 300 # 600ms later

Promise.reject

「Promise.resolve」の対となる「Promise.reject」も存在し、RejectedなPromise Objectを即座に返します。

リスト12:Promise.rejectの使用例

Promise.reject(new Error("fail")).then(error => {
  // not called
}).catch(error => {
  console.log(error) // Error: fail
});

Async/await

Promiseの利便性は伝わったと思いますが、やはり.thenでネストするのは理想形とは言えません。そこで登場するのが、「Async/await」です。まずはサンプルコードをご覧ください。

リスト13:Async/awaitの使用例

const sleep = (delay) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(delay)
    }, delay)
  })
}

async function sleepAsync() {
  const sleep1 = await sleep(100)
  console.log(sleep1) // 100 # 100ms later
  const sleep2 = await sleep(500)
  console.log(sleep2) // 500 # 600ms later
}

sleepAsync()

いかがでしょうか。「async function」を定義し、その中で「await 式」を実行することで、あたかも同期処理を書くかのように記述することができます。もちろん下記のようにfor loop内での使用も可能です。

リスト14:ループ内でも使用可能

async function forAsync() {
  for (let i = 0; i < 3; i++) {
    const delay = await sleep(i * 100)
    console.log(delay)
  }
}
forAsync()

さらにasync functionは、即時関数やアロー関数など使って書くこともできます。

リスト15:async functionを即時関数で記述

(async function () {
  console.log(await sleep(1000))
  // 1000
})()

リスト16:async functionをアロー関数で記述

(async () => {
  console.log(await sleep(1000))
})()

これが実現できるのはawaitがPromiseの解決を待ってくれるからです。Promise が解決するとasync functionは処理を再開し、解決された値を返します。

また、async functionはPromiseを返します。下記の例を見ていただくとそれがわかるでしょう。

リスト17:async functionはPromiseを返す

async function sleepAsync() {
  const sleep1 = await sleep(100)
  const sleep2 = await sleep(500)
  return sleep1 + sleep2
}

const promise = sleepAsync()

promise.then(value => {
  console.log(value)
})
// 600 # 600ms later

また、「Async/await」のさらなる利点として、tryとcatch節でエラー処理をまとめられる点が挙げられます。

リスト18:Async/awaitを用いてtryとcatch節にエラー処理をまとめる

async function httpAsync() {
  let user
  try {
    user = await httpGet('/api/user.json')
  } catch (e) {
    user = await httpGet('/api/guest_user.json')
  }
  return user
}

httpAsync().then(data => {
  console.log(data)
})

ただし、「async function」内でのtry-catchを忘れた時はasync関数の外側でPromiseとしてcatchする必要があるので注意してください。

今回は以上で終了です。「Async/await」はNode.js v7.6.0でも使用可能となり、非同期処理のメインストリームとなっていく可能性が高いので、状況にもよりますが使用を積極的に検討していくと良いでしょう。

リクルートテクノロジーズ ITマネジメント統括部 ディベロップメント3部所属
東京都渋谷区出身。大規模サイトの構築やWebアプリケーションの開発を経て、現在はテクニカルディレクターとしてフロントエンドのチームリード/制作フロー改善に従事。著書:『エンジニアのためのGitの教科書』『ブレイクスルーJavaScript』(翔泳社)『現場で役立つCSS3デザインパーツライブラリ』(MdN)ほか。

連載バックナンバー

Web開発技術解説
第6回

ES2015のモジュール管理

2017/5/9
ES2015に追加されたモジュール管理の機能を紹介し、現行のブラウザから使用する方法を解説する。
開発言語技術解説
第5回

ES2015が備えるモダンな非同期処理

2017/3/28
ES2015に追加された非同期処理の新しい記述方法について学ぶ。

Think ITメルマガ会員登録受付中

Think ITでは、技術情報が詰まったメールマガジン「Think IT Weekly」の配信サービスを提供しています。メルマガ会員登録を済ませれば、メルマガだけでなく、さまざまな限定特典を入手できるようになります。

Think ITメルマガ会員のサービス内容を見る

他にもこの記事が読まれています