ウェブ アプリケーションの例外のデバッグは簡単なようです。何か問題が発生したら実行を一時停止して調査します。しかし、JavaScript の非同期性により、これは驚くほど複雑になります。Chrome DevTools は、例外がプロミスと非同期関数を飛び越えるときに、いつ、どこで停止すればよいかをどのようにして把握しますか?
この記事では、キャッチ予測の課題について詳しく説明します。キャッチ予測は、コードの後半で例外がキャッチされるかどうかを DevTools が予測する機能です。デバッグが難しい理由と、V8(Chrome を動かす JavaScript エンジン)の最近の改善によってデバッグがより正確になり、スムーズにデバッグできるようになった方法について説明します。
キャッチ予測が重要である理由
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;
この例では、次の手順が実行されます。
Promise
コンストラクタが呼び出されます。- 新しい保留中の
Promise
が作成されます。 - 匿名関数が実行されます。
- 例外がスローされます。この時点で、デバッガは停止するかどうかを決定する必要があります。
- Promise コンストラクタは、この例外をキャッチし、Promise の状態を
rejected
に変更し、値をスローされたエラーに設定します。この Promise が返され、promise1
に格納されます。 promise1
がrejected
状態であるため、.then()
はリアクション ジョブをスケジュールしません。代わりに、新しい Promise(promise2
)が返されます。この Promise も同じエラーで拒否された状態です。.catch()
は、指定されたハンドラと新しい保留中のリアクション プロミスを使用してリアクション ジョブをスケジュールします。このプロミスはpromise3
として返されます。この時点で、デバッガはエラーが処理されることを認識します。- リアクション タスクが実行されると、ハンドラは正常に返され、
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;
この例では、次の手順が実行されます。
Promise
はfulfilled
状態で作成され、promise1
に保存されます。- 最初の匿名関数でプロミス リアクション タスクがスケジュールされ、その
(pending)
リアクション プロミスがpromise2
として返されます。 - リアクションは、
promise2
にフルフィルされたハンドラとそのリアクション Promise とともに追加され、promise3
として返されます。 - リアクションが
promise3
に追加され、拒否されたハンドラと別のリアクション Promise が追加されます。この Promise はpromise4
として返されます。 - 手順 2 でスケジュールされたレアクション タスクが実行されます。
- ハンドラが例外をスローします。この時点で、デバッガは停止するかどうかを決定する必要があります。現在、実行されている JavaScript コードはハンドラのみです。
- タスクが例外で終了するため、関連するリアクション プロミス(
promise2
)は拒否状態に設定され、値はスローされたエラーに設定されます。 promise2
には 1 つのリアクションがあり、そのリアクションに拒否されたハンドラがないため、そのリアクション プロミス(promise3
)も同じエラーでrejected
に設定されます。promise3
には 1 つのリアクションがあり、そのリアクションには拒否されたハンドラがあるため、そのハンドラとそのリアクション Promise(promise4
)を使用して Promise リアクション タスクがスケジュールされます。- そのリアクション タスクが実行されると、ハンドラは正常に返され、
promise4
の状態は fulfilled に変更されます。
キャッチ予測の方法
収穫予測の情報源は 2 つあります。1 つはコールスタックです。これは同期例外に対しては適切です。デバッガは、例外巻き戻しコードと同じ方法で呼び出しスタックを走査し、try...catch
ブロック内のフレームを見つけると停止します。拒否された Promise や、Promise コンストラクタまたは非同期関数で発生した例外(中断されたことがない関数)の場合、デバッガは呼び出しスタックにも依存しますが、この場合、予測はすべてのケースで信頼できるとは限りません。これは、非同期コードが最も近いハンドラに例外をスローするのではなく、拒否された例外を返すためです。デバッガは、呼び出し元が例外をどのように処理するかについて、いくつかの仮定を行う必要があります。
まず、デバッガは、返された Promise を受け取る関数は、その Promise または派生 Promise を返す可能性が高いと想定します。これにより、スタック上の上位にある非同期関数は、その Promise を待機できます。2 つ目は、デバッガは、Promise が非同期関数に返された場合、まず try...catch
ブロックに移動または移動せずに、すぐに await することを前提としています。これらの前提のいずれも正しいことが保証されるわけではありませんが、非同期関数を使用した最も一般的なコーディング パターンについて正しい予測を行うには十分です。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 つの例は類似していますが、キャッチ予測ヒューリスティックは大きく異なります。最初の例では、解決済みのプロミスが作成され、例外をスローする .then()
のリアクション ジョブがスケジュールされます。次に、.catch()
が呼び出され、拒否ハンドラがリアクション プロミスに接続されます。リアクション タスクが実行されると、例外がスローされ、Promise リアクション ツリーにキャッチ ハンドラが含まれるため、キャッチされたと検出されます。2 つ目の例では、キャッチハンドラを追加するコードが実行される前に、Promise がすぐに拒否されるため、Promise のリアクション ツリーに拒否ハンドラはありません。デバッガは呼び出しスタックを確認する必要がありますが、try...catch
ブロックもありません。これを正しく予測するために、デバッガはコードの現在の位置より先をスキャンして .catch()
の呼び出しを見つけ、そのことを根拠に最終的に拒否が処理されることを前提とします。
概要
この説明で、Chrome DevTools でのキャッチ予測の仕組み、その長所と短所について理解していただけたでしょうか。予測が正しくないためにデバッグの問題が発生した場合は、次のオプションを検討してください。
- 非同期関数を使用するなど、予測が容易なコードパターンに変更します。
- DevTools が停止すべきときに停止しない場合は、すべての例外でブレークするように選択します。
- デバッガが停止したくない場所で停止する場合は、「ここでは停止しない」ブレークポイントまたは条件付きブレークポイントを使用します。
謝辞
この投稿の編集にご協力いただいた Sofia Emelianova 様と Jecelyn Yeen 様に心より感謝いたします。