V8 的 Error 物件與棧追蹤的妙用

gplane發表於2019-02-16

本文的講述都是以 Node.js 環境為例子,而 Node.js 使用的 JavaScript 引擎是 V8,因此理論上 Chrome 也能適用,其它瀏覽器我就不清楚了。

現狀

最近在寫 Rize(歡迎 star) 的時候,一直為錯誤的棧追蹤而愁。為什麼呢?這要從 Rize 的架構說起。

由於 puppeteer 的絕大多數操作和 API 是非同步的,而寫非同步程式碼的良好寫法是用 ES2017 的 async/await 語法。

但我們都知道,async/await 實際上返回的是一個 Promise(即使你沒有顯式地 return 什麼,它將是 Promise<void>)。很明顯這樣不能達到我想要的 API 鏈式呼叫的效果。我總不能對著 Promise 例項操作 prototype,然後把我自己的 API 挪上去吧?

所以我使用了一個佇列來儲存使用者想要進行的操作。也就是說,使用者在呼叫 Rize 的 API 之後,並不會(也不可能)立即執行這些操作,而是放在佇列中,等待時機適合(例如瀏覽器已經啟動或者上一個操作已經完成)才執行。由於送入佇列的是函式,因此在 push 的引數可以放心地使用 async/await

但是,一旦這些操作中出現錯誤,錯誤的定位變得十分麻煩。

下面這張圖是直接用 Node.js 執行一個指令碼的結果:

V8 的 Error 物件與棧追蹤的妙用

下面這張圖是在 Jest 中執行一段程式碼的結果:

V8 的 Error 物件與棧追蹤的妙用

原因是,

首先,佇列中的函式是 async function,這本來就給 debug 帶來麻煩。

其次,這些函式並不是立即在 API 中呼叫的,而是由專門的佇列處理程式碼來呼叫。在錯誤發生時,V8 只能跟蹤到那段佇列處理程式碼那裡。

這就為使用者帶來麻煩。錯誤發生了,卻只能看著錯誤訊息一點一點地去試著定位有問題的地方。

探索

為此我去閱讀了 Node.js 的官方文件,看了 Errors 這一部分,不過似乎沒什麼收穫。

後來又找到了 TJ Holowaychuk 大神寫的庫 callsite,看看能不能有用。從文件上看,這個庫並不適合我的需求。

但我閱讀了 callsite 的原始碼,原始碼很短,十行不到。我在原始碼發現了一些資訊。

callsite 是利用 V8 的 Stack Trace API 來獲取函式呼叫處的一些資訊,如檔名,行號等等。callsite 是如何獲取這些資料的呢?

非常簡單,就一句:

var err = new Error()
複製程式碼

對,僅僅是 new 一個 Error 例項,而且並不是要丟擲這個錯誤。

對比我們平時的程式碼,通常當我們 throw 一個錯誤之後,我們能得到一些錯誤棧資訊。但實際上,不需要 throw,僅僅是新建一個 Error 例項,也能讓 V8 記錄下當前的呼叫棧資訊。

解決

既然發現這個事實,那我們可以在需要記錄呼叫棧的地方 new 一個 Error 例項。(千萬不要把它丟擲,不然你後面的程式碼就沒法執行了)

此時當前的棧資訊已經被記錄下來,那麼我們怎樣去使用這些資訊呢?

如果使用者的程式碼執行正常,那就沒什麼關係了。關鍵是在發生錯誤的時候。這裡要提一提的是,我的那段佇列處理程式碼是帶有 try…catch 塊的,大概長這樣:

try {
  await fn()
} catch (error) {
  throw error
} finally {
  // do some stuff ...
}
複製程式碼

你可能好奇什麼要把捕捉的異常還要丟擲,因為我想要的是後面的 finally 塊啊,但同時我又希望異常能繼續被丟擲。

在這裡,我們就要對 catch 塊做點功夫。當然這個 try…catch 塊是能夠獲取到之前新建的 Error 例項的,在這裡我省略了那部分程式碼。

為了方便敘述,我把之前 new 的那個 Error 例項命名為 trace,即假設 const trace = new Error()

顯然把 trace 的所有棧資訊都拿過來是不適合的,因為它有一些我們並不需要的棧資訊(這部分資訊是位於 API 呼叫處以上的)。

每一個 Error 例項都有個 stack 屬性,它是一個多行字串,我們先把它的每行分開,儲存在陣列中:

const stack = trace.stack.split('\n')
複製程式碼

要注意 stack 的第一行不是棧資訊,而是錯誤訊息,這個不能去掉。所以:

stack.splice(1, 2)
複製程式碼

我這裡有兩行的資訊是沒用的,所以刪去兩行,實際上要根據你的需要修改第二個引數。

現在可以把 trace 的棧資訊替換掉實際 error 的棧資訊:

error.stack = stack.join('\n')
複製程式碼

結果

現在就可以得到友好的錯誤棧資訊了:

V8 的 Error 物件與棧追蹤的妙用

配合 Jest 就能更好地定位問題所在之處:

V8 的 Error 物件與棧追蹤的妙用

最後是宣傳一下我正在寫的庫 Rize(可以讓你簡單優雅地使用 puppeteer),也就是本文提到的,歡迎前往 GitHub 並 star。

部落格原文在這裡

相關文章