在 Chrome 開發人員工具中取得預測結果:原因與改進方式

Eric Leese
Eric Leese

在網路應用程式中偵錯異常似乎很簡單:發生錯誤時暫停執行並進行調查。但 JavaScript 的非同步特性讓這項作業變得相當複雜。當例外狀況透過承諾和非同步函式傳送時,Chrome 開發人員工具如何得知何時及何處暫停?

本文將深入探討擷取預測的挑戰,也就是 DevTools 能夠預測程式碼稍後是否會擷取例外狀況。我們將探討為何這麼困難,以及 V8 (Chrome 的 JavaScript 引擎) 近期的改善方式如何讓它更精確,進而提供更順暢的偵錯體驗。

擷取預測的重要性

在 Chrome 開發人員工具中,您可以選擇只針對未偵測到的例外狀況暫停程式碼執行作業,而略過已偵測到的例外狀況。

Chrome 開發人員工具提供不同的選項,可在已偵測或未偵測到的例外狀況中暫停

在幕後,偵錯工具會在例外狀況發生時立即停止,以便保留背景。這是「預測」,因為目前無法確定程式碼稍後是否會捕捉到例外狀況,尤其是在非同步情況下。這種不確定性源自預測程式行為的固有難度,類似於停止問題

請考慮以下範例:偵錯工具應在何處暫停?(請參閱下一節的解答)。

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?
  }
}

在偵錯工具中暫停例外狀況可能會造成中斷,導致經常中斷並跳到不熟悉的程式碼。為避免這種情況,您可以選擇只偵錯未偵測到的例外狀況,因為這類例外狀況更有可能是實際錯誤的訊號。不過,這取決於捕捉預測的準確度。

錯誤的預測結果會讓使用者感到挫折:

  • 偽陰性 (預測為「未偵測到」實際上已偵測到)。偵錯工具中不必要的暫停。
  • 偽陽性 (預測「已偵測」時,實際上並未偵測到)。錯失擷取重大錯誤的機會,可能會迫使您對所有例外狀況進行偵錯,包括預期的例外狀況。

另一種減少偵錯中斷的方法是使用忽略清單,這可避免在指定第三方程式碼中發生例外狀況時中斷。不過,準確的捕捉預測仍是關鍵。如果第三方程式碼產生的例外狀況會逃逸並影響您自己的程式碼,您就需要能夠對其進行偵錯。

非同步程式碼的運作方式

Promise、asyncawait 以及其他非同步模式可能會導致以下情況:在例外狀況或拒絕發生前,可能會採用執行路徑,而這在例外狀況發生時難以判斷。這是因為除非例外狀況已發生,否則無法等待承諾或新增 catch 處理常式。讓我們來看看先前的範例:

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

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

在本範例中,outer() 會先呼叫 inner(),後者會立即擲回例外狀況。從這點來看,偵錯工具可以得出結論,inner() 會傳回已遭拒絕的承諾,但目前沒有任何項目正在等待或處理該承諾。偵錯工具可以推測 outer() 可能會等待,並推測會在目前的 try 區塊中等待,因此會處理,但偵錯工具無法確定這點,除非已傳回已拒絕的承諾,並最終到達 await 陳述式。

偵錯工具無法保證擷取的預測結果一定正確,但會使用各種啟發法的常見程式碼模式來進行正確預測。如要瞭解這些模式,建議您先瞭解承諾的運作方式。

在 V8 中,JavaScript Promise 會以物件形式表示,可處於三種狀態之一:已完成、已拒絕或待處理。如果應許是已完成狀態,且您呼叫 .then() 方法,系統會建立新的待處理應許,並排定新的應許反應工作,以便執行處理常式,然後將應許設為已完成 (使用處理常式的結果),或在處理常式擲回例外狀況時,將其設為已拒絕。如果您對已遭拒絕的承諾呼叫 .catch() 方法,也會發生同樣的情況。相反地,如果在已拒絕的 promise 上呼叫 .then(),或是在已完成的 promise 上呼叫 .catch(),則會傳回相同狀態的 promise,且不會執行處理常式。

待處理的 Promise 包含反應清單,每個反應物件都包含執行處理常式或拒絕處理常式 (或兩者皆包含) 和反應 Promise。因此,在待處理的應許中呼叫 .then() 會新增含有已完成處理常式的回應,以及回應應許的全新待處理應許,而 .then() 會傳回該應許。呼叫 .catch() 會新增類似的回應,但會附上拒絕處理程序。使用兩個引數呼叫 .then() 會建立兩個處理常式,並且呼叫 .finally() 或等待承諾時,會新增兩個處理常式,這些處理常式是實作這些功能的內建函式。

當待處理的承諾最終完成或遭拒時,系統會為所有已完成的處理常式或所有已拒絕的處理常式,排定回應工作。系統會更新對應的回應承諾,並可能觸發回應工作。

範例

請考慮使用以下程式碼:

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

這段程式碼涉及三個不同的 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. 承諾建構函式會擷取這個例外狀況,然後將承諾狀態變更為 rejected,並將其值設為擲回的錯誤。它會傳回這個承諾,並儲存在 promise1 中。
  6. .then() 不會排定任何反應工作,因為 promise1 處於 rejected 狀態。相反地,系統會傳回新的承諾 (promise2),而這項承諾也處於已拒絕狀態,且有相同的錯誤。
  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. 系統會在 fulfilled 狀態下建立 Promise,並儲存在 promise1 中。
  2. 承諾反應工作會使用第一個匿名函式排程,其 (pending) 反應承諾會傳回為 promise2
  3. 使用已完成的處理常式和回應應許,將回應新增至 promise2,並以 promise3 的形式傳回。
  4. 使用已拒絕的處理常式和其他回應 Promise 新增回應至 promise3,並以 promise4 的形式傳回。
  5. 執行在步驟 2 中排定的回應工作。
  6. 處理程序會擲回例外狀況。此時,偵錯工具需要決定是否要停止。目前,處理程序是唯一執行的 JavaScript 程式碼。
  7. 由於工作以例外狀況結束,因此相關聯的回應應許 (promise2) 會設為已拒絕狀態,並將其值設為擲回的錯誤。
  8. 由於 promise2 只有一個回應,且該回應沒有已拒絕的處理常式,因此其回應應許 (promise3) 也設為 rejected,並顯示相同的錯誤。
  9. 由於 promise3 有一個回應,且該回應確實有遭拒的處理常式,因此系統會使用該處理常式和回應 Promise (promise4) 排程 Promise 回應工作。
  10. 當反應工作執行時,處理常式會正常傳回,且 promise4 的狀態會變更為已完成。

擷取預測值的方法

有兩個潛在的資訊來源可用於預測捕獲量。其中一個是呼叫堆疊。這對於同步例外狀況來說是合理的做法:偵錯工具可以以與例外狀況解開程式碼相同的方式檢查呼叫堆疊,如果偵測到 try...catch 區塊中的影格,就會停止。對於已拒絕的承諾或承諾建構函式或非同步函式中未曾暫停的例外狀況,偵錯工具也會依賴呼叫堆疊,但在這種情況下,其預測結果並非在所有情況下都可靠。這是因為非同步程式碼會傳回已拒絕的例外狀況,而不是向最近的處理常式擲回例外狀況,因此偵錯工具必須對呼叫端會如何處理該例外狀況做出一些假設。

首先,偵錯工具會假設接收傳回的承諾函式很可能會傳回該承諾或衍生承諾,因此在堆疊中更上層的非同步函式將有機會等待該承諾。其次,偵錯工具會假設如果承諾傳回至非同步函式,就會立即等待,而不會先進入或離開 try...catch 區塊。這兩種假設都無法保證正確無誤,但對於最常見的非同步函式程式碼模式,這兩種假設已足以做出正確預測。在 Chrome 125 版中,我們新增了另一種啟發法:偵錯工具會檢查呼叫端是否即將針對傳回的值 (或具有兩個引數的 .then(),或是 .then().finally() 的呼叫鏈結,後面接著 .catch() 或兩個引數的 .then()) 呼叫 .catch()。在這種情況下,偵錯工具會假設這些是我們正在追蹤的承諾方法或與之相關的方法,因此會擷取拒絕。

第二個資訊來源是承諾反應樹狀結構。偵錯工具會從根應許承諾開始。有時這是剛呼叫其 reject() 方法的承諾。更常見的情況是,當例外狀況或拒絕發生在應許反應工作期間,且呼叫堆疊上沒有任何項目似乎能擷取該狀況時,偵錯工具會從與反應相關聯的應許進行追蹤。偵錯工具會查看待處理應許中的所有回應,並檢查是否有拒絕處理程序。如果有任何回應不符合要求,則會查看回應應許,並從中遞迴追蹤。如果所有回應最終都會導致拒絕處理程序,偵錯工具會認為已偵測到承諾拒絕。我們需要處理一些特殊情況,例如不計算 .finally() 呼叫的內建拒絕處理程序。

如果有相關資訊,則應承諾回應樹狀圖通常會提供可靠的資訊來源。在某些情況下,例如呼叫 Promise.reject() 或在 Promise 建構函式或在尚未等待任何內容的非同步函式中,就不會產生追蹤反應,因此偵錯工具必須單獨依賴呼叫堆疊。在其他情況下,承諾反應樹狀圖通常會包含用於推斷捕捉預測所需的處理常式,但後續可能會新增更多處理常式,將例外狀況從已捕捉變為未捕捉,反之亦然。還有 Promise.all/any/race 建立的承諾,群組中的其他承諾可能會影響拒絕處理方式。針對這些方法,偵錯工具會假設如果承諾仍處於待處理狀態,就會轉發承諾拒絕。

請參考以下兩個範例:

兩個捕捉預測範例

雖然這兩個捕捉例外狀況的範例看起來很相似,但需要截然不同的捕捉預測經驗法則。在第一個範例中,系統會建立已解析的承諾,然後排定 .then() 的回應工作,並擲回例外狀況,接著呼叫 .catch(),將拒絕處理常式附加至回應承諾。執行回應工作時,系統會擲回例外狀況,而應許回應樹狀圖會包含擷取處理常式,因此會偵測到擷取的狀況。在第二個範例中,系統會在執行新增 catch 處理常式程式碼之前立即拒絕應許,因此應許的回應樹狀結構中沒有拒絕處理常式。偵錯工具必須查看呼叫堆疊,但也沒有 try...catch 區塊。為了正確預測這項情況,偵錯工具會在程式碼的目前位置前掃描,找出對 .catch() 的呼叫,並據此假設最終會處理拒絕。

摘要

希望這篇說明能讓您瞭解 Chrome 開發人員工具中的 catch 預測功能如何運作,以及其優點和限制。如果您因為預測結果不正確而遇到偵錯問題,請考慮下列選項:

  • 將程式碼模式改為更容易預測的模式,例如使用非同步函式。
  • 如果開發人員工具未在應停止時停止,請選取這個選項,以便在所有例外狀況中中斷。
  • 如果偵錯工具停在您不想停留的位置,請使用「永不在此處暫停」中斷點或條件中斷點。

特別銘謝

在此深深感謝 Sofia Emelianova 和 Jecelyn Yeen 協助編輯這篇文章!