Chrome DevTools での予測を把握する: 難しい理由とその改善方法

Eric Leese
Eric Leese

ウェブ アプリケーションで例外をデバッグするのは簡単なようです。何か問題が発生したら実行を一時停止して調査します。しかし、JavaScript の非同期性により、これは驚くほど複雑になります。Chrome DevTools は、例外がプロミスと非同期関数を通過するときに、いつ、どこで停止すればよいかをどのように把握しているのですか?

この記事では、キャッチ予測の課題について詳しく説明します。キャッチ予測は、コードの後半で例外がキャッチされるかどうかを DevTools が予測する機能です。なぜ難しいのか、そして V8(Chrome を動かす JavaScript エンジン)の最近の改善によって、より正確になり、デバッグがスムーズになったのかについて説明します。

キャッチ予測が重要である理由

Chrome DevTools では、キャッチされていない例外でのみコード実行を一時停止し、キャッチされた例外をスキップできます。

Chrome DevTools には、キャッチされた例外またはキャッチされていない例外で停止するための個別のオプションが用意されています

デバッガは、例外が発生するとすぐに停止し、コンテキストを保持します。これは予測です。特に非同期のシナリオでは、コードの後半で例外がキャッチされるかどうかを、現時点では確実に知ることはできません。この不確実性は、停止問題と同様に、プログラムの動作を予測することの根本的な難しさに起因しています。

次の例について考えてみましょう。デバッガはどこで停止する必要がありますか。(次のセクションで回答をご確認ください)。

async function inner() {
  throw new Error(); // Should the debugger pause here?
}

async function outer() {
  try {
    const promise = inner();
    // ...
    await promise;
  } catch (e) {
    // ... or should the debugger pause here?
  }
}

デバッガで例外で停止すると、中断が頻繁に発生し、慣れていないコードにジャンプする可能性があります。これを軽減するには、キャッチされない例外のみデバッグするように選択します。これは、実際のバグを示す可能性が高いためです。ただし、これはキャッチ予測の精度に依存します。

予測が正しくないと、ユーザーの不満につながります。

  • 偽陰性(検出されるときに「検出されない」と予測する)。デバッガでの不要な停止。
  • 偽陽性(捕獲されないのに「捕獲された」と予測する)。重大なエラーをキャッチする機会を逃し、想定される例外を含むすべての例外をデバッグしなければならない可能性がある。

デバッグの中断を減らすもう 1 つの方法は、無視リストを使用することです。これにより、指定したサードパーティ コード内の例外で中断を防ぐことができます。ただし、正確なキャッチ予測は依然として重要です。サードパーティ コードで発生した例外がエスケープして独自のコードに影響する場合は、デバッグできるようにする必要があります。

非同期コードの仕組み

Promise、async、await などの非同期パターンを使用すると、例外または拒否が処理される前に、例外がスローされた時点で判断するのが難しい実行パスをたどる可能性があります。これは、例外が発生するまで、Promise を待機したり、キャッチハンドラを追加したりできない可能性があるためです。前述の例を見てみましょう。

async function inner() {
  throw new Error();
}

async function outer() {
  try {
    const promise = inner();
    // ...
    await promise;
  } catch (e) {
    // ...
  }
}

この例では、outer() が最初に inner() を呼び出し、すぐに例外をスローします。これにより、デバッガは inner() が拒否された Promise を返すことがわかりますが、現在、その Promise を待機または処理しているものは何もないことがわかります。デバッガは、outer() がおそらくそれを待機し、現在の try ブロックで待機し、そのために処理すると推測できますが、拒否されたプロミスが返され、最終的に await ステートメントに到達するまで、デバッガはこれを確信できません。

デバッガは、キャッチの予測が正確であることを保証することはできませんが、一般的なコーディング パターンに対してさまざまなヒューリスティクスを使用して正確に予測します。これらのパターンを理解するには、Promise の仕組みを学ぶことが役立ちます。

V8 では、JavaScript の Promise はオブジェクトとして表され、fulfilled、rejected、pending の 3 つの状態のいずれかになります。Promise が fulfilled 状態にあり、.then() メソッドを呼び出すと、新しい保留中の Promise が作成され、新しい Promise 反応タスクがスケジュールされます。このタスクはハンドラを実行し、ハンドラの結果で Promise を fulfilled に設定するか、ハンドラが例外をスローした場合は rejected に設定します。拒否された Promise で .catch() メソッドを呼び出した場合も同様です。一方、拒否された Promise で .then() を呼び出したり、解決された Promise で .catch() を呼び出したりすると、同じ状態の Promise が返され、ハンドラは実行されません。

保留中の Promise には、各リアクション オブジェクトにフルフィル ハンドラまたは拒否ハンドラ(またはその両方)とリアクション Promise を含むリアクション リストが含まれています。そのため、保留中の Promise で .then() を呼び出すと、処理済みのハンドラを含むリアクションと、リアクション Promise の新しい保留中の Promise が追加され、.then() が返されます。.catch() を呼び出すと、拒否ハンドラ付きの同様のリアクションが追加されます。2 つの引数を指定して .then() を呼び出すと、両方のハンドラを含むリアクションが作成されます。.finally() を呼び出すか、Promise を待機すると、これらの機能の実装に固有の組み込み関数である 2 つのハンドラを含むリアクションが追加されます。

保留中の Promise が最終的に完了または拒否されると、完了したハンドラまたは拒否されたハンドラのすべてに対して、リアクション ジョブがスケジュールされます。対応するリアクション プロミスが更新され、独自のリアクション ジョブがトリガーされる可能性があります。

例

たとえば、次のコードについて考えてみます。

return new Promise(() => {throw new Error();})
    .then(() => console.log('Never happened'))
    .catch(() => console.log('Caught'));

このコードには 3 つの異なる Promise オブジェクトが含まれていることがわかりにくい場合があります。上記のコードは、次のコードと同等です。

const promise1 = new Promise(() => {throw new Error();});
const promise2 = promise1.then(() => console.log('Never happened'));
const promise3 = promise2.catch(() => console.log('Caught'));
return promise3;

この例では、次の手順が実行されます。

  1. Promise コンストラクタが呼び出されます。
  2. 新しい保留中の Promise が作成されます。
  3. 匿名関数が実行されます。
  4. 例外がスローされます。この時点で、デバッガは停止するかどうかを決定する必要があります。
  5. Promise コンストラクタは、この例外をキャッチし、Promise の状態を rejected に変更し、値をスローされたエラーに設定します。この Promise が返され、promise1 に格納されます。
  6. promise1 が rejected 状態であるため、.then() はリアクション ジョブをスケジュールしません。代わりに、新しい Promise(promise2)が返されます。この Promise も同じエラーで拒否された状態です。
  7. .catch() は、指定されたハンドラと新しい保留中のリアクション プロミスを使用してリアクション ジョブをスケジュールします。このプロミスは promise3 として返されます。この時点で、デバッガはエラーが処理されることを認識します。
  8. リアクション タスクが実行されると、ハンドラは正常に返され、promise3 の状態が fulfilled に変更されます。

次の例は構造は似ていますが、実行は大きく異なります。

return Promise.resolve()
    .then(() => {throw new Error();})
    .then(() => console.log('Never happened'))
    .catch(() => console.log('Caught'));

これは次と同等です。

const promise1 = Promise.resolve();
const promise2 = promise1.then(() => {throw new Error();});
const promise3 = promise2.then(() => console.log('Never happened'));
const promise4 = promise3.catch(() => console.log('Caught'));
return promise4;

この例では、次の手順が実行されます。

  1. Promise は fulfilled 状態で作成され、promise1 に保存されます。
  2. 最初の匿名関数でプロミス リアクション タスクがスケジュールされ、その (pending) リアクション プロミスが promise2 として返されます。
  3. リアクションは、promise2 にフルフィルされたハンドラとそのリアクション Promise とともに追加され、promise3 として返されます。
  4. リアクションが promise3 に追加され、拒否されたハンドラと別のリアクション Promise が追加されます。この Promise は promise4 として返されます。
  5. 手順 2 でスケジュールされたレアクション タスクが実行されます。
  6. ハンドラが例外をスローします。この時点で、デバッガは停止するかどうかを決定する必要があります。現在、実行されている JavaScript コードはハンドラのみです。
  7. タスクが例外で終了するため、関連するリアクション プロミス(promise2)は拒否状態に設定され、値はスローされたエラーに設定されます。
  8. promise2 には 1 つのリアクションがあり、そのリアクションに拒否されたハンドラがないため、そのリアクション プロミス(promise3)も同じエラーで rejected に設定されます。
  9. promise3 には 1 つのリアクションがあり、そのリアクションには拒否されたハンドラがあるため、そのハンドラとそのリアクション Promise(promise4)を使用して Promise リアクション タスクがスケジュールされます。
  10. そのリアクション タスクが実行されると、ハンドラは正常に返され、promise4 の状態は fulfilled に変更されます。

キャッチ予測の方法

収穫予測の情報源は 2 つあります。1 つはコールスタックです。これは同期例外に対しては適切です。デバッガは、例外巻き戻しコードと同じ方法で呼び出しスタックを走査し、try...catch ブロック内のフレームを見つけると停止します。拒否された Promise や、Promise コンストラクタまたは中断されたことのない非同期関数で発生した例外の場合、デバッガは呼び出しスタックにも依存しますが、この場合、予測はすべてのケースで信頼できるとは限りません。これは、非同期コードが最も近いハンドラに例外をスローするのではなく、拒否された例外を返すためです。デバッガは、呼び出し元が例外をどのように処理するかについて、いくつかの仮定を行う必要があります。

まず、デバッガは、返された Promise を受信する関数は、その Promise または派生 Promise を返す可能性が高いと想定します。これにより、スタック上の上位にある非同期関数は、その Promise を待機できます。2 つ目の理由は、デバッガは、Promise が非同期関数に返された場合、まず try...catch ブロックに移動または移動せずにすぐに待機すると想定していることです。これらの前提のいずれも正しいことが保証されるわけではありませんが、非同期関数を使用した最も一般的なコーディング パターンについて正しい予測を行うには十分です。Chrome バージョン 125 では、別のヒューリスティクスが追加されました。デバッガは、呼び出し元が返される値(または 2 つの引数を持つ .then()、.then() または .finally() の呼び出しの連鎖の後ろに .catch() または 2 つの引数を持つ .then())で .catch() を呼び出すかどうかを確認します。この場合、デバッガは、これらがトレース中の Promise のメソッドまたはそれに関連するメソッドであると想定するため、拒否がキャッチされます。

2 つ目の情報源は、Promise の反応のツリーです。デバッガはルート プロミスから開始されます。これは、reject() メソッドが呼び出されたばかりの Promise であることもあります。より一般的なケースでは、Promise のリアクション ジョブ中に例外または拒否が発生し、呼び出しスタックにキャッチするものがないように見える場合、デバッガはリアクションに関連付けられた Promise からトレースします。デバッガは、保留中の Promise のすべてのレアクションを確認し、拒否ハンドラがあるかどうかを確認します。いずれかのリアクションが満たされていない場合は、リアクションの Promise を調べ、そこから再帰的にトレースします。すべてのレアクションが最終的に拒否ハンドラにつながる場合、デバッガはプロミスの拒否がキャッチされたと見なします。.finally() 呼び出しの組み込み拒否ハンドラをカウントしないなど、考慮すべき特殊なケースがいくつかあります。

プロミス リアクション ツリーは、情報がある場合は通常信頼できる情報源となります。Promise.reject() の呼び出し、Promise コンストラクタ、まだ何も待機していない非同期関数などでは、トレースに対する反応がないため、デバッガは呼び出しスタックにのみ依存する必要があります。他のケースでは、通常、Promise のリアクション ツリーにはキャッチ予測を推測するために必要なハンドラが含まれていますが、後でハンドラが追加され、例外がキャッチから未キャッチに、またはその逆に変更される可能性は常に存在します。Promise.all/any/race によって作成されるプロミスもあります。この場合、グループ内の他のプロミスが拒否の処理方法に影響する可能性があります。これらのメソッドの場合、デバッガは、Promise がまだ保留中であれば、Promise の拒否が転送されると想定します。

次の 2 つの例を見てみましょう。

漁獲予測の 2 つの例

キャッチされた例外に関するこれらの 2 つの例は類似していますが、キャッチ予測ヒューリスティックは大きく異なります。最初の例では、解決済みのプロミスが作成され、例外をスローする .then() のリアクション ジョブがスケジュールされます。次に、.catch() が呼び出され、拒否ハンドラがリアクション プロミスに接続されます。リアクション タスクが実行されると、例外がスローされ、Promise リアクション ツリーにキャッチハンドラが含まれるため、キャッチされたと検出されます。2 つ目の例では、キャッチハンドラを追加するコードが実行される前に、Promise がすぐに拒否されるため、Promise のリアクション ツリーに拒否ハンドラはありません。デバッガは呼び出しスタックを確認する必要がありますが、try...catch ブロックもありません。これを正しく予測するために、デバッガはコードの現在の位置より先をスキャンして .catch() の呼び出しを見つけ、その結果に基づいて最終的に拒否が処理されると想定します。

概要

Chrome DevTools での捕捉予測の仕組み、そのメリット、制限事項について、ご理解いただければ幸いです。予測が正しくないためにデバッグの問題が発生した場合は、次のオプションを検討してください。

  • コードパターンを、非同期関数を使用するなど、予測しやすいものに変更します。
  • DevTools が停止すべきときに停止しない場合は、すべての例外でブレークするように選択します。
  • デバッガが停止したくない場所で停止する場合は、「ここで一時停止しない」ブレークポイントまたは条件付きブレークポイントを使用します。

謝辞

この投稿の編集にご協力いただいた Sofia Emelianova 様と Jecelyn Yeen 様に心より感謝いたします。