🤝

Promiseは何を『約束』しているのか

2022/12/10に公開約9,700字

Jitoinを開発しているitteと申します。Webエンジニア向けのサービスや記事を公開しています。

この記事は、JavaScriptのPromiseがよく分からないという人向けに、次のことを説明するものです。

  • Promiseがどういったものなのか
  • よく使われる使い方

なお、この記事で説明しているPromiseはPromiseオブジェクトのことです。

Promiseは『何を』約束しているのか

まず、Promiseという名前はとても分かりにくいです。promiseは日本語で『約束』『契約』『保証』といった意味がありますが、何を約束しているのかが省略されているため、名前だけではどういったものなのか分からないのです。

この何をについて、人によって解釈が異なると思いますが、私は次の2つを約束していると考えています。

  • すぐにできないことをやっておくという約束
  • 終わったら教えるという約束

この2点について詳しく説明していきます。

Promiseを作ってくれる関数

説明の前に、Promiseは自分で作ることよりも、誰かに作ってもらうことのほうが圧倒的に多いです。まずは、誰かが作ったPromiseを扱うことから始めましょう。

誰かと言いましたが、Promiseを作るのは関数が多いです。Promiseを作ってくれる関数は沢山あります。例えばこんなものです。

所属API名 説明 関数例
Fetch API HTTP通信 fetch(), Response.text()など
Web Crypto API 暗号化や署名 crypto.subtle.encrypt()など
File System Access API ファイル操作 showOpenFilePicker()など
WebAuthn 生体認証 navigator.credentials.create()など

このほかにも、Node.jsでもファイル操作やデータベースへのアクセスにPromiseを作る関数があります。

関数以外がPromiseを作ることもありますが、関数がPromiseを作るパターンが利用頻度が多く、最初は理解しやすいです。

fetch()関数

これらのAPIの中で、最も使用頻度が高いのはfetch()関数だと思います。Promiseは理解していないけれどfetch()は使ったことがあるという人は多いと思います。

fetch()関数を使うとHTTPリクエストを発行することができます。例えば次のコードはipify APIという、ユーザーが使っているIPアドレスを返してくれるサービスに対して、IPアドレスを要求しています。

let fetching = fetch('https://api.ipify.org')

この例では、変数fetchingfetch()関数によって作られたPromiseが入ります。fetch()関数を実行した時点でfetch()関数との間で『約束』を交わしたことになります。

2つの約束

『すぐにできないことをやっておく』という約束

fetch()関数は、インターネットを使ってipifyにアクセスしますので、時間がかかります。私たちの体感では一瞬に思えますが、コンピュータからすると、とても時間がかかるのです。

とはいえ、呼び出し元をいつまでも待たせるわけにはいきません。

そのため、fetch()関数はとりあえず

「すぐにはできないけれど、ちゃんとやっておくから!」

と返事をします。それがPromiseです。

『終わったら教える』という約束

ちゃんとやってくれればそれでいい、ということもありますが、時には終わったら教えて欲しいときもあります。

fetch()関数は

「すぐにはできないけれど、終わったら教えるね!」

という返事も同時にしてくれます。それもPromiseです。

すなわちPromiseの意味するところ

2つの約束を合わせると

すぐにできないことをやっておくから、終わったら教える

ということになります。

もう一度、fetch()関数の例を見てみます。

let fetching = fetch('https://api.ipify.org')

この例で、変数fetchingは『フェッチしている』という意味ですが、『すぐにできないことをやっておくから、終わったら教える』という意味を含んでの『フェッチしている』になっています。

ここでは、変数名を現在分詞形にしていますがPromiseの命名に規則はありません。その時々で分かりやすい名前を付けてください。

then()

「終わったら教えるね」という約束を交わした限りは、終わったことを教えてもらわねばなりません。
これにはthen()を使います。

thenを直訳すると『それから』ですが、Promiseのthenを意訳すると、『終わったら』あるいは『終わっていたら』になります。

次のプログラムは交わした約束であるfetchingが終わったら、then()に与えた関数を実行します。

fetching
  .then(() => {
    console.log('終わったようだね')
  })

なお、コードを実行するときにはすでにfetchingが終わっていることもあるので『終わっていたら』という意味でもあります。

then()もPromiseを返す

さて、実はthen()もPromiseを返します。この辺りから、Promiseはややこしくなってきます。

let logging =
  fetching
    .then(() => {
      console.log('終わったようだね')
    })

この例で、変数loggingがどんな約束なのか考えてみます。

  • then()fetchingが終わったときに関数を実行する
  • Promiseは『すぐにできないことをやっておくから、終わったら教える』

ということだったので、変数loggingはこんな約束になります。

fetchingが終わったときにやっておくから、終わったら教える

then()で結果を受け取る

fetch()関数はHTTPリクエストを発行する関数でした。実行すると、HTTPレスポンスが返ってくるはずです。
普通の関数であれば、実行するとすぐに結果が返ってきますが、fetch()関数は時間がかかるので結果がすぐに返ってきません。
then()を使うと、終わったことを教えてもらうと同時に、結果を受け取ることもできます。

これはthen()に与える関数の引数で受け取ります。次の例ではresponseに結果のHTTPレスポンスが入ります。

fetching
  .then((response) => {
    console.log(response.ok)
  })

コードを補足すると、HTTPレスポンスのokプロパティでHTTPリクエストが成功した(ステータスが200299)かどうかを表す真偽値を得ています。

Promiseを作ったのがfetch()関数だったので、結果はHTTPレスポンスてしたが、他の関数が作っていた場合はそれに応じた結果を引数で受け取ることができます。

await

さて、then()から離れてPromiseに戻ると、『終わったら教える』という約束を交わしたものの、終わってもらわないと処理を進めたくないということがあります。

そんなときawaitを使います。

awaitasyncと一緒に使うものですが、まずは別々に理解したほうが分かりやすいので、asyncの説明は後回しにします。

awaitを意訳すると、『終わるまで待つ』あるいはこれも『終わっていたら』という意味になります。次のコードはfetchingが終わるのを待ってからconsole.logを実行します。

await fetching
console.log('終わったようだね')

awaitで結果を受け取る

awaitでも次のように結果を受け取ることができます。

let response = await fetching
console.log('結果:', response.ok)

awaitを使えば、Promiseの存在をほとんど感じずに時間がかかる処理の結果を得ることができます。こんな感じです。

let response = await fetch('https://api.ipify.org')
console.log('結果:', response.ok)

このコードにおいて、fetch()関数の使い方が普通の関数と違うのはawaitキーワードを書いているかどうかだけです。
fetch()関数はすぐにできないことをPromiseとして返していましたが、終わるまで待つのであれば、普通の関数と同じように直接結果を受け渡してもいいはずです。awaitはその仲介をしてくれています。

Promiseを介していますが、Promiseの存在を意識しなくてもよくなっていますので、awaitを書き忘れないように注意が必要です。

Promiseの処理を並行して走らせる

ときには、複数のPromiseを同時に走らせたいときがあります。
例えば、天気予報 APIで大阪と東京の天気を取得したいとします。fetch()関数は時間がかかるので、できればfetch()関数を2つ並行して走らせたいです。

そんなときはPromiseを2つ作れば可能です。

let osakaFetching = fetch('https://weather.tsukumijima.net/api/forecast/city/270000')
let tokyoFetching = fetch('https://weather.tsukumijima.net/api/forecast/city/130010')

一見、一つずつ動いているように見えますが、この2行はあくまで約束を交わしているだけですので、天気予報APIへの通信は並行して動きます。

Promise.all()

複数のPromiseが約束した処理は並行して動いています。すると終了時間もバラバラになります。

それらすべて終わったときに何かを実行したいときは、Promise.all()を利用します。

Promise.all()は、Promiseの配列を受け取り、すべてのPromiseが終わったら教えてくれるPromiseを返します。言い換えると複数のPromiseを1つにまとめます。

次のように使います。

let tenkiFetching = Promise.all([osakaFetching, tokyoFetching])

このPromiseの結果は、元々のPromiseの結果が配列になっています。引数の受け取りの際には次のように分割代入を使うと便利です。

tenkiFetching
  .then(([osakaResponse, tokyoResponse]) => {
     console.log('結果:', osakaResponse.ok, tokyoResponse.ok)
  })

複数のPromiseのawait

awaitは便利ですが、複数のPromiseを使うときにこのように書いてしまうことがあります。

let osakaResponse = await fetch('https://weather.tsukumijima.net/api/forecast/city/270000')
let tokyoResponse = await fetch('https://weather.tsukumijima.net/api/forecast/city/130010')
console.log('結果:', osakaResponse.ok, tokyoResponse.ok)

こう書いてもプログラムは正しく動くのですが、大阪の天気を取得するのを待ってから、東京の転機の取得を始めるので、同時に動かすのに比べて2倍の時間がかかってしまいます。

サーバーへの負荷を抑えるためにあえてそうすることも、ごく稀にあるのですが、通常は次のように同時に走らせます。

let osakaFetching = fetch('https://weather.tsukumijima.net/api/forecast/city/270000')
let tokyoFetching = fetch('https://weather.tsukumijima.net/api/forecast/city/130010')
let [osakaResponse, tokyoResponse] = await Promise.all([osakaFetching, tokyoFetching])
console.log('結果:', osakaResponse.ok, tokyoResponse.ok)

Promiseの解決と失敗

Promiseでは、解決(resolve)と失敗(reject)という言葉をよく使います。これは、resolve()関数とreject()関数に由来しますが、これらの関数については本記事で説明しません。

Promiseに隠されたもう一つの意味から、解決と失敗が何なのかを理解できれば問題ありません。

時間がかかること=すぐに解決できない問題

fetch()関数はインターネットを使って外部にアクセスする、すなわち『時間がかかること』に取り組んでいました。Promiseはこういった『時間がかかること』『すぐにできないこと』に対して取り組むことを約束しています。

この『時間がかかること』を『すぐに解決できない問題』と言い換えてみます。すると、おのずと解決と失敗の意味が分かります。

もう一度改めて、Promiseが約束していることを書き直します。

すぐに解決できない問題をやっておくから、終わったら教える

Promiseが省略していた何をは、こんなに長い言葉だったのです。

Promiseの解決

Promiseの解決とは、そのままですね。すぐに解決できない問題が解決したということです。つまり、やっていたことが終わったということです。
Promiseが解決されたときにthen()awaitが動き出します。

Promiseの失敗

では、Promiseの失敗は何かというと、すぐに解決できない問題が解決しなかったということです。やっていたことを終わらせることができず、約束が果たせなかったのです。これはエラーや例外が発生した状況と言えます。

失敗したときの処理

失敗したときに何か処理をしたい場合はcatch()を使います。fetch()関数が失敗することはほとんど無いのですがfetch()関数で例を書くとこうなります。

fetching
  .catch(() => {
    console.log('失敗したようだね')
  })

ちなみに、catch()もPromiseを返します。catch()が返すPromiseは、fetchingが解決したらfetchingの結果を、失敗したら与えられた関数を実行した結果を返すPromiseです。すごくややこしいので、catch()が返すPromiseを変数に入れて使うようなコードは、なるべく書かないほうが良いと思います。

また、awaitがPromiseの失敗を受け取ったときは、例外を発生させますのでtry...catch文で検知することができます。

try {
  await fetching
} catch(e) {
  console.log('失敗したようだね')
}

Promiseを返す関数を作る

fetch()関数のようにPromiseを返す関数は、自分で作ることもできます。asyncとPromiseコンストラクタという2通りの方法があります。このうち、asyncのほうが利用されるケースが多いのでasyncを説明します。

async

関数にasyncキーワードを付けると、その関数をPromiseを返す関数にすることができます。この関数をasync関数あるいは非同期関数と呼びます。本記事では、同期/非同期について説明しませんので、async関数で統一します。

例えば、ただ足し算をする関数があるとします。

function add(a, b) {
  return a + b
}

let result = add(1, 2)

このadd関数を定義するときにasyncを付けると、async関数になりますので、結果を取得するときはthen()awaitが必要になります。

async function add(a, b) {
  return a + b
}

add(1, 2).then((result) => {
})

let result = await add(1, 2)

さて、ここでa + bはいつ実行されているのか気になる人もいると思います。

答えは、関数を呼び出したときにすぐに実行されるのですが、それはどうでもいいことです。Promiseはすぐにできないことをやっておくから、終わったら教えるという約束でした。例えすぐにできちゃったとしても、約束を交わした双方にとって重要なことではありません。すぐに実行されても、1時間後でも良いのです。

Promiseを学び始めたときは、どのような順序で処理が動くのか気になると思いますが、頭を切り替えて、決まりが無い『いつか』あるいは『あとで』実行されるものだと考えてください。

asyncawait

asyncを使えばPromiseを返す関数を作れました。それは、関数を呼び出す相手に対して、今できないから、あとでやっとくよと結果を先送りにしたということです。

逆に言えば、async関数の中では、焦ることなくゆっくり処理をしても良いということです。具体的には、今まで何度も説明したawaitを使うことができます。

awaitは『終わるまで待つ』ものでした。普通の関数でawaitを使うと、プログラムが停止してしまいますので使えないようになっています。しかし、async関数ではプログラムを止めてしまっても、すぐに処理をする必要が無いので使っていいのです。

awaitが無いasync関数は『待つ』過程が無く処理が終わりますので、async関数である必要がありませんので、結局のところ、asyncawaitを使うためにあります。

async関数の中ではawaitが使える
async function getIpAddress() {
  let response = await fetch('https://api.ipify.org')
  if (response.ok) {
    return await response.text()
  } else {
    throw Error('予期せぬエラーが発生しました')
  }
}

Promiseをより深く理解したいとき

これまでの説明で、Promiseを使う上で99%くらいは困らないと思います。しかし、時にはもっと理解していないとプログラミングが難しい局面があります。

本記事では説明しませんが、何を調べたらいいかを書いておきます。

Top level await

awaitはasync関数の中以外でも使えることがあります。私が今把握している限りではTop level awaitと呼ばれるものがあります。とても便利てすので調べてみてください。

同期/非同期

同期/非同期はPromiseに限った言葉ではありません。同期/非同期について理解できるとPromiseがより分かるようになります。
ただ、理解していなくても、Promiseを使っているとだんだん理解できますのであまり心配は要りません。

Promiseコンストラクタ

asyncともう一つの、Promiseを作る方法です。
例えば、コールバックやイベントといったPromise以外の『〇〇したら』を扱うものをPromiseにすることができます。必要になったときにPromiseコンストラクタで調べると良いと思います。

Promise.all()以外の関数

Promise.all()以外にも、Promise.race()Promise.allSettled()Promise.any()といった関数があります。これらはすべて複数のPromiseから1つのPromiseを得るためのものです。滅多に利用されることはありませんが、知っておいて損は無いと思います。

Promiseの処理が具体的にいつ実行されるのか

Promiseをさらに深く理解するには、JavaScriptがコンピュータ上でどのように動くのかを理解する必要があります。シングルスレッドタスクキューイベントループがキーワードになります。

Discussion

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