Promiseをすり替えたらJavaScriptにバレた話

プロフィール画像

岸川拓磨

2025年01月31日

リンクをコピーXでシェアするfacebookでシェアする

はじめに

メンバーズの岸川です。

業務外でJavaScriptのコードを書いていたときに遭遇した、作っていないはずのPromiseOpen in new tabオブジェクトがどこかにできているという事象の理解が中々難しかったので、その時の振り返りと知見の共有としてこの記事を書いていきます。

やっていたこと

複数の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/awaitOpen in new tab

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の非同期処理についての理解が深まったと感じています。
まだまだ学ぶことは多いですが、原因を追い求める過程自体はとても楽しめました。この経験を通じて、今後もマイペースに学び続けていきたいと思います。

この記事を書いた人

岸川拓磨
岸川拓磨
2021年メンバーズ入社。フロントエンドをメインにサービスの運用保守に携わっています。最近はバックエンドにも多少触れるようになってきました。趣味はマリオカート8 デラックスです。
ページトップへ戻る