はじめに
JavaScriptを使い始めて以来ずっとPromiseの理解が曖昧でした。
たとえばNext.js v15以降でパラメータは必ず非同期で渡す仕様になっているため、テストコード上でもそれにきちんとその仕様に適合するようにrenderメソッドを書いて上げる必要があります。
このとき、型が合わないからなんとなく Promise.resolve() を書いていました。
render(Page({ params: Promise.resolve({ id: 1 }) }))
ここでふと、「Promiseを "解決する" なんだ?」という疑問が浮かんできたので、調べてみました。
コンストラクタのresolveとPromise.resolve()は別物
まず整理が必要だったのが、この2つのresolveの関係です。
const p = new Promise((resolve, reject) => {
resolve(42); // ここで呼ぶ
});
p.then(value => console.log(value)); // 42
こっちはPromiseコンストラクタが executor に渡してくれる関数。呼んでも何も返さず(undefined)、副作用として外側のPromiseの結末を決めるだけのものです。
一方Promise.resolve()は新しいPromiseを生成する静的メソッドで、これは別物です。
const p = Promise.resolve(42);
// 実質的にこれと同じ
const p2 = new Promise(resolve => resolve(42));
内部的にはPromise.resolve(x)はだいたい次と等価で、要は「新しいPromiseを作って、その中でコンストラクタのresolveを呼んでいるラッパー」なんだなと理解しました。
function resolvePromise(x) {
return new Promise((resolve) => {
resolve(x); // コンストラクタのresolveをそのまま使っている
});
}
同じ「resolve」という名前がついているのは、どちらも「ある値をPromiseの履行状態に結びつける(resolveする)」という同じ操作を表しているからです。
「解決する」ことはPromiseの成否とは無関係
「解決する」という語感から、履行状態にすることが解決なんだと思っていましたが、これは勘違いでした。
MDNのドキュメントからリンクされている State and Fates には次のように定義されています。
- States(状態)…Promiseには、fullfilled(履行)、rejected(拒否)、pending(待機)という、互いに排他的な3つの状態があります。
- Fates(結末)…Promiseには、resolved(解決済み)とunresolved(未解決)という、互いに排他的な2つの結末があります。
Promiseは、それのresolveまたはrejectを試みても何の影響も生じない場合、resolvedされたものとみなされます。つまり、そのPromiseは、別のPromiseに従うように「locked in」されているか、あるいはfulfilledまたはrejectedされている状態にあるということです。
つまり、それぞれは
- fulfilled = 状態。値が確定して完了したこと。
- resolve = 操作。「このPromiseの結末を、この値(またはPromiseの結末)に紐づける」こと。
であって、違うレイヤーの話です。
コールセンターに喩えてみましょう。
- pending:まだ誰につなぐか決まっていない
- resolve:「この件はあちらの担当者に委ねます」と転送する
- fulfilled:担当者が「はい、対応できます」と成立させる
- rejected:担当者が「申し訳ございません、対応できません」と断る
つまりオペレーター(外側のPromise)は「転送する」というアクション(resolve)を起こした時点では、まだ話がまとまったわけではない。転送先の担当者(内側のPromise)が実際にどう対応するかによって、初めて結果が決まるわけです。
(余計にわかりづらい喩えだったらごめんなさい…)
resolveに渡すものが普通の値なら、ほぼ即座にfulfilledになります。
しかし、Promise(thenable)を渡した場合は話が変わります。
const inner = new Promise(resolve => {
setTimeout(() => resolve("内側の値"), 1000);
});
const outer = new Promise(resolve => {
resolve(inner); // 値ではなくPromiseをresolveしている
console.log("resolve呼んだ直後");
});
outer.then(value => console.log(value));
実行順序はこうなります。
-
resolve(inner)が呼ばれる → outerはまだfulfilledにならない -
"resolve呼んだ直後"が出力される(outerはpendingのまま) - 1秒後、innerがfulfilledになって初めて、outerも
"内側の値"でfulfilledになる
つまりresolve(inner)を呼んだ時点では、「innerの結果を待つ」という運命(Fate)が決まっただけで、状態(State)はまだpendingのままです。
Thenableとは
thenメソッドを持っているオブジェクトのことです。つまり名前のままで、thenできるオブジェクトです。
PromiseはThenableの一つですが、Promise以外でも下記のように履行されたときと拒否されたときに呼び出せる2つのコールバックを取っているメソッドを実装していれば、それもThenableなオブジェクトといえます。
const thenable = {
then(resolve, reject) {
resolve("成功!");
}
};
resolveしたのにrejectedで終わることもある
さらに踏み込むと、resolveに渡したPromiseがrejectされた場合、外側もrejectedになります。
const inner = new Promise((resolve, reject) => {
setTimeout(() => reject(new Error("失敗した")), 1000);
});
const outer = new Promise((resolve) => {
resolve(inner); // innerに運命を委ねる
});
outer.then(
value => console.log("fulfilled:", value),
err => console.log("rejected:", err.message)
);
// 1秒後: "rejected: 失敗した"
resolve(inner)を呼んだ時点では「innerの結果に従う」ことが決まっただけで、1秒後にinnerがrejectされると、outerもrejectedになる。resolve関数を呼んだのに、最終的にfulfilledではなくrejectedで終わるというのは、名前だけ見ると直感に反する動きだなと感じました。
整理すると、こんな表になります。
| 渡したもの | resolve呼び出し直後 | 最終的な状態 |
|---|---|---|
| 通常の値 | 即fulfilled | fulfilled |
| fulfilledするPromise | pending → 後でfulfilled | fulfilled |
| rejectedするPromise | pending → 後でrejected | rejected(resolve関数を呼んだのに!) |
Promise.resolve()も内部的には同じ仕組みなので、同様のことが起きます。
const failingPromise = new Promise((resolve, reject) => {
setTimeout(() => reject(new Error("失敗確定")), 1000);
});
const outer = Promise.resolve(failingPromise);
outer.then(
value => console.log("fulfilled:", value),
err => console.log("rejected:", err.message)
);
// 1秒後: "rejected: 失敗確定"
Promise.resolve(failingPromise)って名前だけ見ると「成功した」ように錯覚しそうになりますが、実際にはfailingPromiseの結末にそのまま従うので、最終的にrejectedになります。
まとめ・学んだこと
- コンストラクタの
resolve関数とPromise.resolve()静的メソッドは別物だが、内部的には前者を使って後者が実装されている関係 -
resolveは「操作(運命)」、fulfilled/rejectedは「状態」であり、別の軸の概念 -
resolveに値を渡すと即fulfilledになるが、Promise(thenable)を渡すとそのPromiseの結末に従うことになり、即座にはfulfilledにならない -
resolve(内側のPromise)を呼んでも、内側がrejectされれば外側もrejectedになる。「resolve=必ず成功」ではない - Next.jsのテストで
params: Promise.resolve({ id: 1 })と書けるのは、「awaitすればすぐに値が返るPromiseを用意している」だけで、resolve自体に特別な「成功」の意味はない そもそも「解決」っていう日本語訳があまりよくない気が