🐈

JS基礎いろいろーPromise前編

2022/03/05に公開約15,600字

前回のイベントループでも言及したが、JSがシングルスレッド非同期の言語なので、非同期処理がまさにjsの中核になっている部分です。それを理解するために、イベントループはもちろん、今主流になっているPromiseについて詳しく書きたいと思います。

長くなったため、前編の非同期処理の方法と比較、後編の自作Promise実装に分けました。

Promise概要

Promiseを簡単に言えば、jsで非同期処理を担う、ES6より導入されたグローバルに使えるオブジェクトです。

let p = new Promise(fn)

言葉の意味で考えると、「約束」とのことなので、将来的に起こることについて扱う、やや特殊なオブジェクトです。今とりあえず約束をしますが、将来的にどれかのタイミングで、結果が分かるようになります。Promiseを、その約束の遂行結果が分かるまでの身代わり・プレースホルダーと考えても良いでしょう。

Promiseには、executorというコールバック関数(例のfn)が必要となります。このコールバック関数には、resolverejectとの二つの関数を引数としています(名前は自由だが慣習的に)。

さらに、Promiseには、3つの状態が存在します。

  • pending: 初期化の状態、resolveとrejectが呼び出されるまで、Promiseオブジェクトがずっとこの状態。この状態では、何か非同期のタスクをレジストすることが多い。
  • resolved/fulfilled: 約束が果たされて、resolve(解決)された状態。初期化時にレジストされた非同期タスクが完了し、resolve関数が実行されると、pending状態からresolvedへ遷移。
  • rejected: 約束が果たされず、rejected(却下・拒否)された状態。非同期タスクに何かエラーが出て、reject関数が実行されると、pending状態からresolved状態へ遷移。

pendingから、resolvedまたはrejectedまでの状態遷移は一方通行となり、一回変わると、戻れることはもちろん、他の状態(resolved->rejected, rejected->resolved)への変化もできません。

Promiseを使う際に、thencatchfinallyといったメソッドをチェイニングしていきます。いずれも、コールバック関数を引数としています。resolve関数が呼び出されるときに、thenの実行に入り、reject関数が呼び出されるときに、catchの実行に入れいます。then/catchの実行が終わった後、最終的にfinallyのコールバックが実行されます。


new Promise(function(resolve, reject) {
  resolve()
  reject()
}).then(function() {
  console.log('then run')
}).catch(function() {
  console.log('catch run')
}).finally(function() {
  console.log('finnaly run')
})

new Promise(function(resolve, reject) {
  reject()
  resolve()
}).then(function() {
  console.log('then run')
}).catch(function() {
  console.log('catch run')
}).finally(function() {
  console.log('finnaly run')
})

new Promise(function(resolve, reject) {
}).then(function() {
  console.log('then run')
}).catch(function() {
  console.log('catch run')
}).finally(function() {
  console.log('finnaly run')
})

Promiseの中身を覗いた方がわかりやすいかもしれません。

Promise概要については以上となります。メソッドチェインの注意点についてまた後の節で説明します。

JS非同期処理の歩み

ここでは、Promise出現までと後の、jsにおける非同期処理の実現の仕方について紹介します。

コールバック地獄

Promiseのない時代では、JSで非同期処理のコードを書くときに、コールバック関数のみとなっています。例えば:

setTimeout(function () {
  console.log('callback run')
}, 1000)

これだけでは、大した問題にはならないのですが、非同期処理のパターンというのは、よく次のステップでは前のステップの処理結果を必要とすることが多いです。例えば、サーバーからユーザーの情報を取得し、それに基づいてさらにユーザーのショッピングカートのデータを取得とか。ただ一回のコールバックでは解決にならない場面が多いのです。

この非同期のステップが重なっていくと、コールバックinコールバックという横ピラミッド構造になってしまいます。例えば:


callApi('/api/1...', function (result) {
  callApi('/api/2...', result.userId, function (result) {
    callApi('/api/3...', result.cartKey, function (result) {
      callApi('/api/4...', result.productIds, function (result) {
        result.products.forEach(function (product) {
          //...
        })
      })
    })
  })
})

この目的も、非同期処理という「順番がわからない」処理を、「順番がわかる」ようにするためにあります。ただ、コードの美しさに致命傷があるので、淘汰される運命には逃れません。

コールバックと非同期処理

コールバック地獄がこれでわかりました。ここでよくある一つの誤解というのは、コールバック=非同期ということです。正確に言えば、非同期を達成するためには必ずコールバックでやらないといけないのですが、コールバック関数自身はむしろ同期コードになります。一番簡単な例で言えば:

function main(fn) {
  fn()
}

main(function () {
  console.log('callback run')
})

上記のコードは非同期など何も入っていません。純粋な同期コードです。ではなぜ非同期処理はコールバックの形でなければならないのか。これは、jsがシングルスレッドで実行することと関係しています。次の例でみてみると:

var count = 0
setTimeout(function () {
  count = 10
}, 1000)

var d1 = new Date().getTime()
var d2 = new Date().getTime()  

while(d2-d1<2000){
  d2 = new Date().getTime()
}

console.log(count)

仮にcountという変数に対して、一秒後に値を変更する操作を入れるとします。whileループで、2秒間スレッドをブロックしましたが、結果的にcountは0のままです。イベントループについて分かればすぐに読み取れますが、setTimeoutがマクロタスクとなり、中身の関数がタスクキューで、同期コードの実行が終わるまで待たないといけないからです。

つまり、非同期の結果を同期コードの中から取得することができません。非同期の結果を取得し、それを持って処理を進めるには、非同期同士で調整しなければなりません。上記の例では、console.log(count)に対して、setTimeoutに入れて、1秒以上のタイマーを設定すると、反映されるようになります。

setTimeout(function () {
  console.log(count)
}, 2000)

ここのsetTimeoutを含め、すべての非同期処理に扱われる関数は、コールバック関数を引数としています。コールバック関数の形によって、ワーカースレッドに処理してもらい、タスクキューの順番が決められ、最終的に非同期を「実行順番が分かる」同期コードのように調整しているのです。

Promiseのメソッドチェイン

さて、コールバックの話に戻りますが、美しさを求めるが故に、より人間の美意識に会うコードの形が誕生されました。それはメソッドチェインの構造です。

Promiseで独創的なものではありませんが、様々な言語にはもちろん、シェルコマンドのパイプライン機能とか、実質チェイン構造になっています。
JSで言えば、jQueryにもすでに実装されていて、よく使われています。ORMのクエリービルダー機能も、同じ考え方です。

var msgEl = `<div>...</div>`
$(".message-area")
  .append(msgEl)
  .hide()
  .slideDown(500, 0)
  .fadeIn(1000, 0);

Promiseにも、thencatchfinallyとの3つのメソッドがあり、コールバックではなく、インテンドがきれいに並べるチェインの形になります。ここで一つ長いチェイン例を見て、Promiseのメソッドチェインの特徴について説明します:

console.log('create new promise')

let p = new Promise((resolve, reject)=>{
  console.log('constructor callback')
  resolve('promise value')
})

p.then(res=>{
  console.log(res) // promise value
}).then(res=>{
  console.log(res) // undefined
  return 123
}).then(res=>{
  console.log(res) // 123
  return new Promise((resolve)=>{
    resolve(456)
  })
}).then(res=>{
  console.log(res) // 456
  return 'return value'
}).then()
  .then('string')
  .then(res=>{
  console.log(res) // return value
  throw new Error('error occurred!')
}).then(res=>{
  console.log(res) // 出力なし
}).catch(err=>{
  console.error(err.message); // error occurred!
  return 'error!'
}).then(res=>{
  console.log('after catch ' + res) // after catch error!
  return 789
}).finally((res)=>{
  console.log(res) // undefined
  console.log('done!') // done!
})

console.log('chain ends')

出力結果に基づいてわかることは:

  • Promiseオブジェクトのコンストラクターに渡す関数が、同期のコードとなります。create new promiseの直後に、constructor callbackがログされます
  • resolve関数の引数は、次のthenのコールバック関数の引数として渡されます
  • thenのコールバック関数のリターン値は、次のthenのコールバック関数の引数として渡されます
  • thenでPromise以外のデータをリターンしても、そのまま次のthenで受け取ることができます
  • thenでPromiseオブジェクトをリターンすると、そのPromiseのresolveの引数が、次のthenのコールバック関数の引数として渡されます
  • thenに何も渡さない、もしくは関数以外に渡すと、エラーとなりません
  • 前回正常にリターンされた結果が、不正なthenを無視して、次のコールバック関数を引数とするthenまで保持・渡されます
  • エラーをスローすることで、catchのコールバックが実行し、エラーオブジェクトが引数として渡されます
  • エラーが起こると、チェインが終了となり、次にthenがあっても実行されません
  • catchの後にthenがあると、catchコールバックのリターン値がそのthenに渡されます
  • finallyが最後に実行しますが、前のチェインから受け取る引数がありません

また、上記の例では表現できていないかもしれませんが、チェイニングを中断させる方法は二つかあります。

  • rejectedされたPromiseをリターンする: return Promise.reject('reject reason') またはreturn new Promise(_,reject=> reject('reject reason'))
  • エラーをスローする: throw new Error('error msg')

このthenメソッドのチェイニングが、コールバックのピラミッドよりだいぶ見やすくなりました。人間の読み書きのときに、上から下までの直感に沿っているからです。ただ、今のコード例のようになると、ある意味でthen地獄かもしれません。といった時は、処理ロジックの分割を考えた方が良いかもしれませんね。

全体的に、Promiseのメソッドチェインには一つ不思議に思われるところがあります。それは、resolve/rejectの引数が、then/catchの引数として渡され、さらにthenのリターン値が、次のthenの引数としても渡されるところです。本来「非同期処理を順番がわかるような同期処理に」変える目的に、これで達成できています。ここの原理について、詳しくは実装の節で見ていきたいと思います。

Promiseには、then, catch, finally以外にも、いくつかよく使われるAPIがあります。

  • Promise.resolve() / Promise.reject() そのまんまです
  • Promise.all([p1, p2, ...]) これはPromiseオブジェクトの配列を引数としています。要はすべてのプロミスがresolveするまで待ち、その結果を配列の形で次のthenに渡します。ただし、途中でどれかのPromiseでエラーが出るとcatchに飛び、実行が中止となります。
  • Promise.allSettled([p1, p2, ...]) 上記のallとの違いというのは、allは任意のrejectで中止となりますが、allSettledはとにかく全てがrejectまたはresolveまで待ちます。
  • Promise.race([p1, p2, ...]) レースするので、どれかのPromiseが先に状態変化すると、その結果をリターンします。
  • Promise.any([p1, p2, ...]) raceとの違いは、resolveされたPromiseだけを待ち、先にrejectされたものがあっても無視されます。

ジェネレーター(Generator)

さて、話を少し逸らしますが、ES6には、新しい特殊な関数、Generatorを導入しています。Pythonとかに経験があるとよく耳にするかもしれません。ジェネレーターは何かというと、関数の実行を一時的に中断させ、また指定のタイミングで続行することが可能な特殊な関数です。通常の関数は、最初から最後まで、一気に実行されますが、ジェネレーターは、yieldというキーワードを使い、事実上の「セーブポイント」を作ることが可能です。早速例を見てみましょう。

function * generatorFn() {
  let a = yield 1
  console.log('a is ', a)
  let b = yield 2
  console.log('b is ', b)
  let C = a + b
  console.log('c is ', C)
}

let gen = generatorFn()
console.log(gen)

let step1 = gen.next()
console.log('step1 is ', step1)

let step2 = gen.next()
console.log('step2 is ', step2)

let step3 = gen.next()
console.log('step3 is ', step3)

これで実行してみると、ちょっと変な出力になります。

  • *マークを関数名の前につけると、関数はジェネレーターオブジェクトをリターンします
  • yieldキーワードでは、valuedoneとの属性を含むオブジェクトを生成します
  • nextメソッドを呼び出すことで、一時中止となったyieldのところから、実行再開が可能です
  • doneがtrueとなれば、ジェネレーターの実行が完了となります

ただ、一つ問題というのは、yieldで生成された{value:..., done:...}は、let a = yield 1の形で変数aに付与できませんでした。これは、.next()メソッドに、引数が必要とのことです。


let step1 = gen.next()
console.log('step1 is ', step1)

let step2 = gen.next(step1.value)
console.log('step2 is ', step2)

let step3 = gen.next(step2.value)
console.log('step3 is ', step3)

ここで、step1のyieldで生成された1を、変数aに渡しました。直感に反するかもしれませんが、値の付与というのは、=サインの右側から判定されます。つまり、先に右側(right hand side: RHSとも)で、yieldによりプログラムが中止となり、このタイミングでは値の付与はされていません。次にgen.next(step1.value)を呼び出すときに、値の付与が続き、渡されたstep1.valueaに付与されます。

はいはい、ここまでなんとかわかりましたが、結局これ、何に使うの?

今回のトピックはPromiseなので、ジェネレーターがPromiseのために何らかの役立つことができます。それは、非同期処理を順番がわかるように、同期処理に近い書き方ができるのです。

function * generatorFn() {
  let res1 = yield setTimeout(() => 'timeout', 1000)
  console.log('res1 is ', res1)
  let res2 = yield new Promise(function(resolve, reject) {
    setTimeout(() => resolve(123), 1000)
  });
  console.log('res2 is ', res2)
}

let gen = generatorFn()

let step1 = gen.next()
console.log('step1 is ', step1)

let step2 = gen.next(step1.value)
console.log('step2 is ', step2)

let step3 = gen.next(step2.value)
console.log('step3 is ', step3)

上記のコードを実行すると、次のようになります。

  • 実行自体は一瞬で終わり、コンソールに結果が出ます
  • setTimeoutのリターン値はyieldによってコントロールできず、謎の数値が戻ってきます(これは環境によってリターン値が変わります)
  • ただ、Promiseオブジェクトの結果は、問題なく取得でき、変数にも付与できます
  • つまり、thenのコールバックを介さずに、同期コードのように、Promiseの処理結果を変数に付与することが可能になります

今までの知見をまとめると、Generatorを使うなら、doneの属性がtrueとなるまで、再帰の形でnextを呼び続けると、Promiseをthenのチェインを使わずに、同期コードのようにかけます:

// ジェネレーターを実行する関数を作る
function generatorFnRunner(fn) {
  let gen = fn() // ここはジェネレーター関数
  let step = gen.next() // 1回目のyield

  // ループの関数を定義
  function loop(stepArg, generator) {
    let val = stepArg.value

    // Promiseオブジェクトの場合は、thenのコールバックの中でresolve値をnextに渡す
    if (val instanceof Promise) {
      val.then((promiseVal)=>{
        if (!stepArg.done) {
          loop(generator.next(promiseVal), generator)
        }
      })
    } else {
      // Promise以外の場合はそのままvalueを渡す
      if (!stepArg.done) {
        loop(generator.next(stepArg.value), generator)
      }
    }
  }

  loop(step, gen)
}

function * generatorFn() {
  let res1 = yield new Promise(function(resolve) {
    setTimeout(() => resolve('p1 run'), 1000)
  })
  console.log(res1)
  let res2 = yield new Promise(function(resolve) {
    setTimeout(() => resolve('p2 run'), 1000)
  })
  console.log(res2)
  let res3 = yield new Promise(function(resolve) {
    setTimeout(() => resolve('p3 run'), 1000)
  })
  console.log(res3)
}

generatorFnRunner(generatorFn)

async/await

ジェネレーターの最後の例を見ると、なんか見覚えのある形ではないでしょうか。

その通り、async/awaitの原理と言えば、ジェネレーターです(もちろん本家の実装はより複雑ですが)。ES7より提案され、ES8で導入されて以来、Promiseに関しては、thenのチェインニングではなく、async/awaitを使おう、との呼び声が高まってきました。

理由はただ一つ、非同期のコードを同期コードのように書けるからです(何度も繰り返していますが重要なポイントなのでご容赦を)。次のようなコードは、もはやスタンダードになっていますね。

async function fetchData() {
  try {
    const res = await fetch('...')
    const data = await res.json()
    // ...
  } catch (e) {
    // ...
  }
}

async/awaitの導入により、開発者が独自でジェネレーターを使うことが不要になりましたが、async/awaitをサポートしていないブラウザーに関しては、ポリフィルとしてジェネレーターのコードが代案となります。

asyncで始まる関数について、一般の関数と比べて若干違います。こちらのコードで、出力の順番がわかるのでしょうか。

async function fn() {
  console.log(3)
  let a = await 4
  console.log(a)
  return 1
}

console.log(1)
let res = fn()
console.log(res)
// Promise {<fulfilled>: 1}
// [[Prototype]]: Promise
// [[PromiseState]]: "fulfilled"
// [[PromiseResult]]: 1
console.log(2)
  • asyncの関数は、そのものがPromiseオブジェクトとなります
  • async関数の中身には、awaitまでのコードが同期コード、awaitからが非同期コードとなります

一見おかしいかもしれませんが、Promiseで書き直すと:

console.log(1)
new Promise(function(resolve){
  console.log(3)
  resolve(4)
}).then(function(a){
  console.log(a)
})
console.log(2)

つまり、Promiseのコールバック関数と同じ原理で、resolveまでのコードが全部同期ですが、awaitが現れてから、yieldと同じように、awaitの左側と以下のコードが全部非同期になります。

やれやれ、全部繋がっていますね。

では最後に、ジェネレーターのコードをasync/awaitに訳すと、*yieldasync/awaitで入れ替える程度ですね

async function fn() {
  let res1 = await new Promise(function(resolve) {
    setTimeout(() => resolve('p1 run'), 1000)
  })
  console.log(res1)
  let res2 = await new Promise(function(resolve) {
    setTimeout(() => resolve('p2 run'), 1000)
  })
  console.log(res2)
  let res3 = await new Promise(function(resolve) {
    setTimeout(() => resolve('p3 run'), 1000)
  })
  console.log(res3)
}

終わりに

今回はPromiseの紹介と、jsの非同期処理のいくつかの方法とその関連を書いてみました。時代の流れ的にasync/awaitが一番おすすめになってきましたが、それまでの歩み、採用された理由について理解しておくのが良いでしょう。後編では自作でPromiseを実装してみたいと思います。

ちょっとした補足:Promisifyテクニック

任意の同期処理関数をプロミスをリターンする非同期関数に変換する手法のことです。

まずは実例を見ています。最近業務でReact-Queryを使っています。ユーザーが場所の名前を入力している場合はGoogle Map APIから場所の経度緯度を取得、入力していない場合は、ブラウザーのnavigator APIから、現在の経度緯度ロケーションを取得するケースです。

いずれのAPIでも、コールバック形式で、処理が非同期になっていて、リターン値がありません。この場合、プロミスオブジェクトで囲んで、resolveとreject関数を中に入れると、リターン値のあるawaitableに変換できます。

interface LocationIface {
  lat: number
  lng: number
}
const useUserLocation = (area?: string) => {
  const [location, setLocation] = useState<LocationIface>({} as LocationIface)

  const { refetch, isFetching } = useQuery(
    ['location', area],
    () => fetchLocationByName(area),
    {
      enabled: false,
      onSuccess: setLocation
    }
  )
  const fetchLocationByName = (area?: string): Promise<LocationIface> => {
    // 場所名がない場合はブラウザーから現在地位置情報取得
    if (!area) return getDefaultLocation()
    // 他の場合はgoogle map APIから取得
    const map = new google.maps.Map(document.createElement('div'))
    const service = new google.maps.places.PlacesService(map)
    // プロミスで囲む
    return new Promise((resolve, reject) => {
      const request = {
        query: area,
        fields: ['geometry', 'name', 'formatted_address']
      }
      service.findPlaceFromQuery(request, (results, status) => {
        if (status === google.maps.places.PlacesServiceStatus.OK && results) {
          const data = results[0]
          if (data?.geometry?.location) {
            const pos = {
              lat: data.geometry.location.lat(),
              lng: data.geometry.location.lng()
            }
            // awaitしたいデータをresolveに渡す
            resolve(pos)
          } else {
            reject('Not geometry data found!')
          }
        } else {
          reject(`status: ${status}, result: ${JSON.stringify(results)} `)
        }
      })
    })
  }

  const getDefaultLocation = (): Promise<LocationIface> => {
    return new Promise((resolve, reject) => {
      navigator.geolocation.getCurrentPosition(
        (position) => {
          const { latitude, longitude } = position.coords
          resolve({ lat: latitude, lng: longitude })
        },
        (err) => {
          reject('Error on getting navigator: ' + err.message)
        }
      )
    })
  }

  // ...
}

この形を抽象化していくと、より汎用的な方法としては、任意の関数を引数に入れると、プロミス化することができます。詳細はこちらにも参照。ちなみにNode.jsにはutilsとして導入されています(こちら

// JS
/**
 * @param {(...args) => void} func
 * @returns {(...args) => Promise<any>}
 */
function promisify(func) {
  return function(...args) {
    return new Promise((resolve, reject) => {
      const callback = (err, result) => {
        if (err) {
          reject(err)
        } else {
          resolve(result)
        }
      }
      func.call(this, ...args, callback)
    })
  }
}
// TS
const promisify =
  (func: Function) =>
  (...args: any[]) =>
    new Promise((resolve, reject) =>
      func(...args, (err: Error, result: any) =>
        err ? reject(err) : resolve(result)
      )
    )

実際の業務で、promisifyがかけられるケースもあるし、今回のように直接Promiseで囲んだ方がやりやすいのもあります。いずれにしても、扱う対象をプロミスに統一したい場合に用いることで良いでしょう。

Discussion

ログインするとコメントできます