ES2015が備えるモダンな非同期処理
今回は、ECMAScript2015(ES2015)で追加された新機能を用いてモダンな非同期処理を学んでいきます。慣れないうちは文法的に戸惑うかもしれませんが、理解してしまえば、従来よりもはるかに簡潔な記述で非同期処理を書けるようになるので、落ち着いて習得していきましょう。
Promise
Promiseとは非同期処理を抽象化したオブジェクト、およびそれを制御する仕組みのことを言います。抽象化したオブジェクトは「Promise Object」と呼ばれ、三種類の状態が存在します。
- 処理が成功した状態 Fulfilled
- 処理が失敗した状態 Rejected
- 処理が完了していない状態 Pending
この状態を深く気にする必要はないのですが、この後に用語として出てきますので頭の片隅に置いておいてください。「Promise Object」が持つ状態を把握したところで、実際のコードに触れながら理解を深めていきましょう。まずは、この仕組みの登場以前のレガシーな非同期処理を見てみます。
setTimeout(() => { console.log('sleep') }, 2000)
これをPromiseを用いて書き直してみましょう。新しい機能だからといって難しいことはありません。コールバック完了のタイミングで引数のresolveを実行することでFulfilledの状態にしてやり、その後「.then」を使ってコールバックの処理を記述するだけです。Promise とはその名のとおり、「約束」を表しています。約束が満たされた時は then を使って約束が満たされた後の処理を行うことができます。
const sleep = (delay) => { return new Promise((resolve, reject) => { setTimeout(() => { resolve() }, delay) }) } sleep(2000).then(() => { console.log('done') })
Promiseを使った書き方に直したものの、この例だとPromiseを使わない方が簡潔な気さえします。Promiseの効果を実感できる例として、コールバックの中でさらに非同期処理を行い、ネストが深くなる場合を見てみましょう。
setTimeout(() => { console.log('2000ms later') setTimeout(() => { console.log('4000ms later') }, 2000) }, 2000)
ネストが一段深くなり、可読性が落ちました。これを、Promiseを用いて書き換えると下記のようになります。
sleep(2000).then(() => { console.log('2000ms later') return sleep(2000) }).then(() => { console.log('4000ms later') })
いかがでしょうか。非同期処理なのにも関わらず、一連の処理がネストせずに同レベルで記述されているので、実行順序が非常に追いやすくなっているのがわかると思います。
さて、これまでエラー処理のないものを見てきましたが、Promiseはもちろんエラー処理も簡潔に記述できます。まずはいつものように、Promiseを使わない例からみていきましょう。
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に置き換えてやるだけです。
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の部分を抽象化してやることで、下記のようにエラー処理の記述も簡潔になります。
httpGet('/api/area.json').then(data => { console.log(data) }).catch(status => { console.error(`Error: status code ${status}`) })
以上がPromiseの基本的な機能ですが、他にも便利なメソッド群が提供されているので紹介したいと思います。
Promise.all
引数で与えられた全てのPromiseを並列に実行し、全てが成功した時にはじめてresolveが呼ばれます。逆に言うと一つでも失敗するとrejectが呼ばれます。個別の完了時は気にせず、すべて完了したら特定の処理を行いたい場合や、可変長つまり数が決まっていない非同期処理などを記述する時に便利です。
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されたとしてもそれまでの処理が中断されたりしないので、使用にはある程度注意が必要な機能です。
Promise.race([ httpGet('/api/food/beef'), httpGet('/api/food/chicken') ]).then(menu => { eat(menu) })
Promise.resolve
Promise.resolveには二つの役割があります。一つはresolve関数に与えた値をPromise化させることです。「jQuery.ajax()」を例にとって説明してみましょう。
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」と組み合わせて非同期処理を直列実行する時や、非同期処理の中に同期処理を混ぜて書きたい時などに有効です。
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を即座に返します。
Promise.reject(new Error("fail")).then(error => { // not called }).catch(error => { console.log(error) // Error: fail });
Async/await
Promiseの利便性は伝わったと思いますが、やはり.thenでネストするのは理想形とは言えません。そこで登場するのが、「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内での使用も可能です。
async function forAsync() { for (let i = 0; i < 3; i++) { const delay = await sleep(i * 100) console.log(delay) } } forAsync()
さらにasync functionは、即時関数やアロー関数など使って書くこともできます。
(async function () { console.log(await sleep(1000)) // 1000 })()
(async () => { console.log(await sleep(1000)) })()
これが実現できるのはawaitがPromiseの解決を待ってくれるからです。Promise が解決するとasync functionは処理を再開し、解決された値を返します。
また、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節でエラー処理をまとめられる点が挙げられます。
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でも使用可能となり、非同期処理のメインストリームとなっていく可能性が高いので、状況にもよりますが使用を積極的に検討していくと良いでしょう。