Promiseとは
MDN曰く「プロミス (Promise) は、非同期処理の最終的な完了もしくは失敗を表すオブジェクトです。」
正直、何言ってるのか理解できなかったです。
でも、ChatGPTさんと問答してテストコードを弄りまくった結果、なんとか理解したと思います。
Promiseの解説記事は巷に溢れていますが、使い方が主で、 Promise とは、いったいどんな物であるかという解説はほとんど見かけません。そこで、ChatGPTさんに「Promiseオブジェクトの実態はどんなものですか?」という質問をしたところPromiseをシミュレートしたコードを表示してくれました。
問答とテストを繰り返し最終的にできたコードは以下のようなものです。
class MyPromise {
constructor(executor) {
this.state = "pending";
this.value = undefined;
this.handlers = [];
this.catchHandlers = [];
const resolve = (value) => {
if (this.state !== "pending") return;
this.state = "fulfilled";
this.value = value;
this.handlers.forEach(handler => queueMicrotask(() => handler(value)));
};
const reject = (error) => {
if (this.state !== "pending") return;
this.state = "rejected";
this.value = error;
if (this.catchHandlers.length > 0) {
this.catchHandlers.forEach(handler => queueMicrotask(() => handler(error)));
} else {
// もし catch がない場合でも、次の then で処理できるようにする
this.handlers.forEach(handler => queueMicrotask(() => handler(undefined, error)));
}
};
try {
executor(resolve, reject);
} catch (error) {
reject(error);
}
}
then(onFulfilled, onRejected) {
return new MyPromise((resolve, reject) => {
const handler = (value, error) => {
if (error) {
if (onRejected) {
try {
const result = onRejected(error);
resolve(result);
} catch (e) {
reject(e);
}
} else {
reject(error); // エラーハンドリングされていない場合は次の catch に渡す
}
return;
}
try {
const result = onFulfilled(value);
resolve(result);
} catch (e) {
reject(e);
}
};
this.handlers.push(handler);
if (this.state === "fulfilled") {
queueMicrotask(() => handler(this.value));
} else if (this.state === "rejected") {
queueMicrotask(() => handler(undefined, this.value));
}
});
}
catch(onRejected) {
return new MyPromise((resolve, reject) => {
const handler = (error) => {
try {
const result = onRejected(error);
resolve(result);
} catch (e) {
reject(e);
}
};
this.catchHandlers.push(handler);
if (this.state === "rejected") {
queueMicrotask(() => handler(this.value));
}
});
}
}
とてもよくできたコードでPromiseの基本的な動作を問題なくシミュレートしてくれます。でも、このままだと理解が難しいので、簡略化したコードを次に示します。
class MyPromise {
constructor(executor) {
this.state = "pending";
this.value = undefined;
this.handlers = [];
const resolve = (value) => {
if (this.state !== "pending") return;
this.state = "fulfilled";
this.value = value;
this.handlers.forEach((handler) => {
queueMicrotask(()=>{handler(value)})
});
};
const reject = (error) => {
if (this.state !== "pending") return;
this.state = "rejected";
this.value = error;
this.handlers.forEach((handler) => {
queueMicrotask(()=>{handler(value)})
});
};
try {
executor(resolve, reject);
} catch (error) {
reject(error);
}
}
then(onFulfilled) {
return new MyPromise((resolve_p) => {
this.handlers.push((value_p) => {
const result = onFulfilled(value_p);
resolve_p(result);
});
});
}
}
reject
の部分のコードは正しくないので、reject
という関数が定義されていると思ってください。
promiseはPromiseクラスのインスタンス
Promiseに基本的な使い方は
new MyPromise((resolve)=>{
setTimeout(()=>{
resolve('タイマー終了');
},1000);
}).then((value)=>{
console.log(value);
});
と使いますので、PromiseクラスにnewしてPromiseのインスタンスを作ることで利用します。
すなわち、promiseはインスタンスオブジェクトなわけです。
オブジェクトなのに、なんらかの処理を実行しているというのはなんとなく釈然としないものがありますが、それは次の仕組みによります。
promiseはnewで即座に実行される
promiseを初めてみた時、new Promise
とインスタンスを作るだけで処理が実行されるのが不思議でした。でも、MyPromise
のコードを見れば明白です。
MyPromise
のコンストラクタはexecutor
という関数を引数にとり、コンストラクタの最後で、その関数を実行します。(try
のところ)
executor
は、Promiseをnewする時に渡した関数です。上記の例であれば
(resolve)=>{
setTimeout(()=>{
resolve('タイマー終了');
},1000);
です。
ですので、new Promise
をするとコンストラクタが実行され、渡された関数が即座に実行されるので、new Promise
だけで実行が開始されるのです。
渡された関数はこの例ではsetTimeoutを使った非同期の関数ですが、通常の同期的な関数でも、即座にresolve
するような関数でも構いません。
promiseの非同期的な動作は、ここではなく、後述するようにresolve
の処理の中で行われます。
executorの引数は関数
executor
を実行する時の引数はresolve
とreject
です。
この2つの関数はPromiseクラスのコンストラクタの中で宣言されています。
上記の例であれば、executor
は
(resolve)=>{
setTimeout(()=>{
resolve('タイマー終了');
},1000);
ですので、タイマーが1秒経過に実行されるコールバック関数
()=>{
resolve('タイマー終了');
}
の中のresolve()
はpromiseインスタンスの中のresolve
メソッドです。
理由は、resolve()
は、外側のexcuter
の引数なので、レキシカルスコープ により内側のコールバック関数内のresolve()
は、この引数のresolve
になります。
const resolve = (value) => {
if (this.state !== "pending") return;
this.state = "fulfilled";
this.value = value;
this.handlers.forEach((handler) => {
queueMicrotask(()=>{handler(value)})
});
};
resolve()
は基本的には、excuter
の最後に実行されるメソッドです。
resolveはthenで登録された関数を実行する
resolve()
の中身をみていきましょう。
resolve()
の中のthis
は、new Promise
でできたインスタンスオブジェクトですね。
最初の処理は、promiseの状態(state)をチェックして保留中(pending)であれば成功(fulfilled)にします。
保留中(pending)以外の状態では状態は変化しないようにreturnして、続く処理はパスされます。
次は、resolve
の引数のvalue
をプロパティに保存します。excuter
からの返り値は、このようにreturn
ではなくresolve
を実行することで返します。
次に、一番大事なことをします。
this.handlers.forEach((handler) => {
queueMicrotask(()=>{handler(value)})
});
hanlers
配列に登録されたハンドラー(関数)を先ほど記録したvalueを引数に実行するようにしたアロー関数を作って、マイクロタスクキューに登録します。
マイクロタスクキューに登録されたこの関数は、このresolve
の直後に実行が開始されます。(マイクロタスクキューに他のタスクが登録されていなければ)
マイクロタスクキューに関数が登録されるので、resolve
実行のあとは一旦実行が途切れて、強制的に非同期処理となります。
このhanlers
配列に登録されたハンドラー(関数)は、実はthenによって登録されたコールバック関数を実行するための関数です。(コールバック関数そのものではありません。)
promise
が実行され、解決された際にthen
の後のコードが実行されるのは、この仕組みによります。
thenはPromiseクラスのメソッド
プロミスの記述は
new Promise(...)
.then(...)
.then(...)
.catch(...)
という感じに、一見するとif 〜 else 〜
のような制御構文のように見えますが、実態はPromiseクラスのメソッドチェーンです。
new Promiseやthenメソッドが新しいpromiseを返すので、このようなメソッドチェーンが成立します。次のように書き換えると構造がわかりやすくなります。
const p1 = new Promise(...);
const p2 = p1.then(...);
const p3 = p2.then(...);
const P4 = p3.catch(...);
thenメソッドは
then(onFulfilled) {
return new MyPromise((resolve_p) => {
this.handlers.push((value_p) => {
const result = onFulfilled(value_p);
resolve_p(result);
});
});
}
onFulFilled
という関数を引数にとるメソッドです。
thenは次に実行する関数を登録する
onFulFilled
関数は、
new MyPromise((resolve)=>{
setTimeout(()=>{
resolve('タイマー終了');
},1000);
}).then((value)=>{
console.log(value);
});
このようなコードであれば、
(value)=>{console.log(value);}
です。
promiseが解決後に実行される関数です。
then
メソッドは、このonFulFilled
関数を
(value_p) => {
const result = onFulfilled(value_p);
resolve_p(result);
}
onFulFilled
関数実行後にresolve_p
関数を実行する関数に包んで、this.handlers
配列に登録します。
this.handlers.push(
+ (value_p) => {
+ const result = onFulfilled(value_p);
+ resolve_p(result);
+ }
);
this.handlers
配列は、先に説明したresult
メソッドの中で、マイクロタスクキューに登録する関数を収めたものですので、キューに登録するのは、このonFulFilled
関数を包んだ関数ということになります。
この配列のハンドラーを登録する処理は
(resolve_p) => {
+ this.handlers.push(
+ (value_p) => {
+ const result = onFulfilled(value_p);
+ resolve_p(result);
+ }
+ );
}
もう一つの関数に包みます。
マイクロタスクキューに登録した関数の中のresolve_p
関数は、この包んだ関数の引数です。
return new MyPromise(
+ (resolve_p) => {
+ this.handlers.push(
+ (value_p) => {
+ const result = onFulfilled(value_p);
+ resolve_p(result);
+ }
+ );
+ }
);
thenメソッドは、この関数をexecutor
とした新しいpromiseインスタンスを生成してthenメソッドの返り値として戻します。
executor
はpromiseインスタンスの生成と同時に実行されますので、結果として、thenの後に実行される処理がpromiseに中の次に実行すべきリスト(handlers
配列)に登録されることになります。
thenメソッドチェーン(promiseチェーン)
then(onFulfilled) {
return new MyPromise(
(resolve_p) => {
this.handlers.push(
(value_p) => {
const result = onFulfilled(value_p);
resolve_p(result);
}
);
}
);
}
thenメソッドはこのようなコードになってます。
handlers
配列はthis.handlers
となってます。
この時のthis
は、レキシカルスコープですので、thisで新しく作られたpromiseインスタンスではなく、thenが実行されている最初の(ベースとなる)promiseインスタンスです。
一方、promiseが解決された時に実行されるonFulfilled
関数を実行した後で実行されるresolve_p(result)
関数は、thenで新しく生成されたpromiseインスタンスのresove
関数です。
今のpromiseのthenの関数の実行 → 新しいpromiseのresoveの実行 と進むため、
new Promise(fx1)
.then(fx2)
.then(fx3)
.then(fx4)
というメソッドチェーンを説明のために分解して
const p1 = new Promise(fx1);
const p2 = p1.then(fx2);
const p3 = p2.then(fx3);
const p4 = p3.then(fx4);
というコードで説明すると
fx1実行 → p1のresolve実行 → p1のhandlers配列の中のハンドラ(fx2)の実行 → p2のresolve実行 → p2のhandlers配列の中のハンドラ(fx3)の実行 → p3のresolve実行 → p3のhandlers配列の中のハンドラ(fx4)の実行
というように処理が次々に実行されます。これがthenメソッドチェーンが恙なく実行される理由です。
thenの処理が先行する
上記のコードの処理の順番をより詳細にみていくと、
fx1実行(非同期) → p1.then(fx2)の実行(fx2をhandlers配列に登録) → p2.then(fx3)の実行(fx3をhandlers配列に登録) → p3.then(fx4)の実行(fx4をhandlers配列に登録)
→ (fx1のコールバック関数の実行) → p1のresolve実行 → p1のhandlers配列の中のハンドラ(fx2)をマイクロタスクキューに登録(ここで非同期)
→ マイクロタスクキューのfx2の実行 → p2のresolve実行 → p2のhandlers配列の中のハンドラ(fx3)をマイクロタスクキューに登録(ここで非同期)
→ マイクロタスクキューのfx3の実行 → p3のresolve実行 → p3のhandlers配列の中のハンドラ(fx4)をマイクロタスクキューに登録(ここで非同期)
→ マイクロタスクキューのfx4の実行
という感じにコードの処理自体は同期的に一気に処理され、その後に登録された処理が順次実行されるようになります。
この簡略化されたMyPromiseクラスではfx1が同期的な処理であった場合は正しく処理されませんが、冒頭のMyPromiseクラスであればfx1が同期的な処理であってもthenの処理が先に同期的に処理されます。この場合は次のようになります。
fx1実行(同期) → p1のresolve実行 → p1.then(fx2)の実行(fx2をhandlers配列に登録) → p1のhandlers配列の中のハンドラ(fx2)をマイクロタスクキューに登録 → p2.then(fx3)の実行(fx3をhandlers配列に登録) → p3.then(fx4)の実行(fx4をhandlers配列に登録)
→ (ここで非同期)
→ マイクロタスクキューのfx2の実行 → p2のresolve実行 → p2のhandlers配列の中のハンドラ(fx3)をマイクロタスクキューに登録(ここで非同期)
→ マイクロタスクキューのfx3の実行 → p3のresolve実行 → p3のhandlers配列の中のハンドラ(fx4)をマイクロタスクキューに登録(ここで非同期)
→ マイクロタスクキューのfx4の実行
このように、最初に一連のpromiseインスタンスを生成したのちに、promiseに登録された処理を非同期にかつ順番通りに処理していくことになります。
Promiseとは
newした単体のプロミス (promise) は、確かにMDNがいう「プロミス (Promise) は、非同期処理の最終的な完了もしくは失敗を表すオブジェクト」ではありますが、then
メソッドを追加した場合には、
「プロミス(promise)とは、(非)同期処理の最終的な完了もしくは失敗を表し、その結果に応じた処理を非同期に実行し、さらに続いて処理すべきプロミスへの参照を保持したオブジェクト」という感じでしょうか。
同期的な処理のように書かれたソースプログラムを、promiseチェーンという形でメモリ上にコピーして、非同期に順序通りに実行する仕組みがPromiseの本質です。