Promiseをすり替えたらJavaScriptにバレた話
はじめに
メンバーズの岸川です。
業務外でJavaScriptのコードを書いていたときに遭遇した、作っていないはずのPromiseオブジェクトがどこかにできているという事象の理解が中々難しかったので、その時の振り返りと知見の共有としてこの記事を書いていきます。
やっていたこと
複数のPromiseオブジェクトをqueueに詰めつつ、同時にqueueからPromiseを取り出しawaitして処理していくようなコードを書いていました。
概要としては以下のようなコードになります。
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
const queue = [];
(async () => {
let { promise, resolve } = Promise.withResolvers();
queue.push(promise);
for (let i = 0; i < 10; i++) {
await sleep(Math.random() * 2000);
resolve('data');
({ promise, resolve } = Promise.withResolvers());
queue.push(promise);
}
resolve('data');
})();
// queueから取り出す処理はランダムに間隔を空けて呼び出される
while (queue.length !== 0) {
await sleep(Math.random() * 3000);
console.log('result', await queue.shift());
}
実際に書いていたコードはPromiseをqueueに入れる処理が複数の場所に記述されているなど、この例よりも更に複雑だったため
実装の確認の意味で簡易的にでもqueueの中身を確認して、正しくPromiseがqueueに入っていることとqueueの中を流れていることを確認しようとしていました。
とりあえずログを出してみる
Promiseをqueueに入れる場所とqueueから取り出す場所にconsole.logを入れてみました。
しかし、以下のようなログになってしまったためうまく動いているのか良く分かりませんでした。
enqueue [ Promise { <pending> } ]
enqueue [ Promise { 'data' }, Promise { <pending> } ]
dequeue [ Promise { <pending> } ]
enqueue [ Promise { 'data' }, Promise { <pending> } ]
enqueue [ Promise { 'data' }, Promise { 'data' }, Promise { <pending> } ]
dequeue [ Promise { 'data' }, Promise { <pending> } ]
enqueue [ Promise { 'data' }, Promise { 'data' }, Promise { <pending> } ]
Promiseの挙動をデバッグする手法
そこで、以下のような一意な名前がつくPromiseを継承したクラスを作り、このPromiseをqueueに流すことで動作を確認することにしました。
class MyPromise extends Promise {
static usedId = 0;
#id = MyPromise.usedId++;
get [Symbol.toStringTag]() { return `id: ${this.#id}` };
}
このMyPromiseクラスは[Symbol.toStringTag]を上書きすることによってconsole.logしたときにidが表示できます。
更にidはコンストラクタが呼ばれた順で連番になるのでPromise同士を区別しながらqueueの状態を見たり、idが飛んでいないかで取りこぼしが起きてないかを判断できます。
enqueue [
MyPromise [id: 00] { 'data' },
MyPromise [id: 01] { 'data' },
MyPromise [id: 02] { 'data' },
MyPromise [id: 03] { <pending> }
]
ログの出力結果がおかしい
さて、これでいい感じに動作を確認できると思ったところで問題が起こりました。
ログを出力させると、queueがちゃんと流れていることは確認できたのですが、MyPromiseのidが稀に歯抜けになって入っていたのです。
enqueue [
MyPromise [id: 00] { 'data' },
MyPromise [id: 01] { 'data' },
MyPromise [id: 02] { 'data' },
MyPromise [id: 03] { <pending> }
]
dequeue [
MyPromise [id: 01] { 'data' },
MyPromise [id: 02] { 'data' },
MyPromise [id: 03] { <pending> }
]
enqueue [
MyPromise [id: 01] { 'data' },
MyPromise [id: 02] { 'data' },
MyPromise [id: 03] { 'data' },
MyPromise [id: 05] { <pending> } // ← 04が無い...
]
idが歯抜けになるということは、コンストラクタが実行されているがqueueに入っていないMyPromiseがいるということになります。
この実装ではMyPromiseはすべてqueueに入れているはずなので何か意図しない動作があるようです。
実装ミスの可能性もあるので、原因を調べていくことにしました。
idが歯抜けになる原因の調査
色々と試していくと、queueからPromiseを取り出す最後のwhile文を消すとidが歯抜けにならない事が分かってきました。
更に不要な要素を削っていくと、以下のコードのどこかに原因があるようです。
while (queue.length !== 0) {
await queue.shift();
}
このコードで怪しいのは await queue.shift();なので、awaitの挙動について調べていきます。MDNでawaitについて見ていくと、以下の記述を見つけました。
- 1.ネイティブの Promise(これは expression が Promise またはそのサブクラスに属し、かつ expression.constructor === Promise であることを意味します)の場合: プロミスは then() を呼び出すことなく、直接使用され、ネイティブに待ち受けられます。
- 2.Thenable オブジェクトの場合(ネイティブでないプロミス、ポリフィル、プロキシー、子クラス、など): 新しいプロミスは、ネイティブの Promise() コンストラクターで、オブジェクトの then() メソッドを呼び出して、resolve コールバックを渡すことで生成します。
- 3.Thenable ではない値: 履行済みの Promise を構築して使用します。
出典: https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/await
Thenableオブジェクトの場合のthen()メソッドを呼び出しての部分が怪しいですね。
thenメソッドが呼び出されたらその返り値はPromiseです。
返り値のPromiseを作るためにMyPromiseのthen、つまり継承元の本物のPromiseのthenメソッドの内部でコンストラクタが実行されていると仮定すれば、
awaitするたびにMyPromiseのコンストラクタが実行されることになるので今回の現象が説明できそうです。
処理系がPromiseと判定している条件は以下の2つです。
- expressionがPromiseまたはそのサブクラスに属し
- expression.constructor === Promiseであること
1つ目はMyPromiseの定義より明らかにサブクラスなのでセーフですが、2つ目が怪しいので実験してみます。
class MyPromise extends Promise {
static usedId = 0;
#id = MyPromise.usedId++;
get [Symbol.toStringTag]() { return `id: ${this.#id}` };
}
console.log((new MyPromise(() => {})).constructor ===
Promise); // false
false が出力されることから expression.constructor === Promiseであることが満たされていないと分かりました。
あとは、実際にawaitしたときにMyPromiseのconstructorが呼ばれていることを以下のように確認して調査完了です。
class MyPromise extends Promise {
static usedId = 0;
#id = MyPromise.usedId++;
get [Symbol.toStringTag]() { return `id: ${this.#id}` };
constructor(...args) {
super(...args);
console.log('constructor is called', this.#id);
}
then(...args) {
console.log('then is called');
const p = super.then(...args);
console.log(p, 'is then result');
return p;
}
}
const p = new MyPromise((res) => res());
await p;
constructor is called 0 // 普通にコンストラクタが呼ばれる
then is called // awaitでthenが呼び出されている
constructor is called 1 // thenの実行中に新しいMyPromiseオブジェクトが生成されている
MyPromise [id: 1] { <pending> } is then result // thenからさっき作られたMyPromiseオブジェクトが返されている
ということで、今回の謎の事象はPromiseをMyPromiseにすり替えていたことがJavaScriptにバレていたことで、awaitしたときにthenメソッドが実行されていたからだと判明しました。
さいごに
今回の問題はPromiseを独自クラスMyPromiseに置き換えた際、それがJavaScriptの内部処理で検出され、awaitの際にthenメソッドが実行された結果、新たなPromiseが生成されていたことが原因でした。今回の調査を進める中でJavaScriptの非同期処理についての理解が深まったと感じています。
まだまだ学ぶことは多いですが、原因を追い求める過程自体はとても楽しめました。この経験を通じて、今後もマイペースに学び続けていきたいと思います。
この記事を書いた人

関連記事
- 【Flutter状態管理入門】StatefulWidget・...
Nicolas Christopher
Advent Calendar!
Advent Calendar 2024開催中!