原文:JavaScript Errors and Stack Traces in Depth

原作者:Lucas Fernandes da Costa

本文將探討錯誤(Error)和

呼叫棧蹤跡

(stack trace)以及如何對它們進行操作。

很多時候大家並不關注這些細節,但是如果你在開發任何跟測試或錯誤處理相關的框架,瞭解這些知識肯定會有所幫助。 透過操縱呼叫棧你可以清理無關緊要的資訊然後聚焦於重要的事情。 全面理解Error以及它的屬性之後,你就可以更加自信地利用它們。

這篇文章開篇可能會顯得太淺顯,但是到操作呼叫棧的時候就會變得複雜,所以在閱讀那部分內容前請確保已經充分理解開頭的內容

呼叫棧的工作原理

在探討錯誤之前,我們先得理解呼叫棧是怎麼運作的。雖然非常簡單,但是對於後續內容的理解非常重要。 如果你已經知道這部分內容,可以跳過。

每當一個函式呼叫發生,這次呼叫就會被推到

棧頂

,執行結束後就會從棧頂移除。

“棧”這種

資料結構

的特點就是最後進入的專案會先出來,也就是所謂的後進先出(LIFO)。

假設我們在函式x裡呼叫函式y,那麼我們的棧裡就會按順序有x、y。

再換個例子,比如我們有下面這段程式碼:

function c() {

console。log(‘c’);

}

function b() {

console。log

(‘b’);

c();

}

function a() {

console。log(‘a’);

b();

}

a();

上面的例子,執行a它就會被加到棧頂。然後當b在a裡被呼叫,b也被推到棧頂。c在b裡呼叫也同理。

在執行c的時候,我們的棧裡按順序包含:a、b、c。

在c結束執行的那一刻,它就從棧頂移除,然後

流程控制權

就交還給b。 當b結束時,它也會出棧,然後

控制權

又回到a。 最終當a結束時,它也會離開棧。

為了更好地演示這種現象,我們可以利用一下

console。trace

()來在控制檯裡輸出當前的呼叫棧蹤跡。 你應按照從下往上的順序閱讀呼叫棧蹤跡,想象每一行都是在它下一行裡執行的。

function c() {

console。log(‘c’);

console。trace();

}

function b() {

console。log(‘b’);

c();

}

function a() {

console。log(‘a’);

b();

}

a();

在Node REPL(互動式即時直譯器)裡執行這段程式碼我們可以得到:

Trace

at c (repl:3:9)

at b (repl:3:1)

at a (repl:3:1)

at repl:1:1 // <—— 可以先忽略從這裡往下的內容,這些都是Node

內部邏輯

at realRunInThisContextScript (vm。js:22:35)

at sigintHandlersWrap (vm。js:98:12)

at ContextifyScript。Script。runInThisContext (vm。js:24:12)

at REPLServer。defaultEval (repl。js:313:29)

at bound

(domain。js:280:14)

at REPLServer。runBound [

as eval

] (domain。js:293:12)

如上,在c裡列印當前呼叫棧,我們可以看到a、b、c。

現在如果我們在c結束執行之後的b裡輸出呼叫棧蹤跡,我們就能看到c已從棧頂移除,所以我們只有a和b。

function c() {

console。log(‘c’);

}

function b() {

console。log(‘b’);

c();

console。trace();

}

function a() {

console。log(‘a’);

b();

}

a();

如下可見c不再存在於我們的棧裡,因它已經執行完畢並已出棧。

Trace

at b (repl:4:9)

at a (repl:3:1)

at repl:1:1 // <—— 往後是Node內部實現邏輯

at realRunInThisContextScript (vm。js:22:35)

……

一言概之:你呼叫某個函式,它就會被加到棧頂,執行結束就從棧頂移除。就這麼簡單。

Error物件以及錯誤處理

錯誤發生時,通常一個

Error物件

會被丟擲。Error也可以用作原型讓使用者擴充套件並建立自定義的錯誤型別。

Error。prototype物件通常有一下屬性:

constructor - 該例項原型的建構函式

message - 錯誤資訊

name - 錯誤名稱

這些都是標準屬性,各類執行環境還有自己的屬性。 在有些環境裡,比如Node、Firefox、Chrome、Edge、IE 10+、Opera、Safari 6+,我們還能得到stack屬性。 它包含著錯誤的呼叫棧蹤跡。 一個錯誤的呼叫棧蹤跡包含直到自己的建構函式為止的所有呼叫幀

如果你想了解更多有關Error物件的屬性,強烈推薦閱讀這篇在MDN上的文章。

丟擲一個錯誤你需要用到throw關鍵字。 而為了接到一個被丟擲的異常,你就需要將可能丟擲異常的程式碼用try包圍並在後面新增catch程式碼塊。 catch還提供一個引數用來獲得接收到的錯誤。

就如在Java裡一樣,JavaScript還允許你定義一個finally程式碼塊,無論是否發生錯誤都會無條件執行。 不管操作是否成功,都用finally做一下清理工作是很好的一種實踐。

到目前為止內容,大部分人都是已經知道的,下面開始講一些不那麼淺顯的細節。

你的try程式碼塊可以沒有catch,但是那樣就必須有finally。所以你可以寫三種不同形態的try:

try。。。catch

try。。。finally

try。。。catch。。。finally

還有值得注意的是,你可以丟擲Error物件以外的東西。 雖然聽起來很酷,但這麼做並不好,尤其是對於需要與別人寫的程式碼打交道的框架開發者。 因為那樣的話,就沒有任何標準,你也就無法預測你的使用者會給你什麼。 你無法確信他們會丟擲一個Error物件因為他們可以選擇不這麼做,而是丟擲一個

字串

或數字。 這也會阻礙你去處理呼叫棧以及其他有用的元資料。

比如你寫了下面的程式碼:

function runWithoutThrowing(func) {

try {

func();

} catch (e) {

console。log(‘There was an error, but I will not throw it。’);

console。log(‘The error\’s message was: ‘ + e。message)

}

}

function funcThatThrowsError() {

throw new TypeError(’I am a TypeError。‘);

}

runWithoutThrowing(funcThatThrowsError);

如果你的使用者傳入runWithoutThrowing的函式丟擲的是Error物件,一切都執行良好。 然而,如果他最後丟擲的是個String,你可能就會遇到問題:

function runWithoutThrowing(func) {

try {

func();

} catch (e) {

console。log(’There was an error, but I will not throw it。‘);

console。log(’The error\‘s message was: ’ + e。message)

}

}

function funcThatThrowsString() {

throw ‘I am a String。’;

}

runWithoutThrowing(funcThatThrowsString);

現在第二個console。log會顯示該錯誤的資訊是undefined。 現在看起來看似並不重要,單如果你需要確保錯誤裡包含某種屬性,或者以其他的方式處理錯誤 (如Chai框架裡的throws斷言) 你就需要做很多事情以確保一切都工作正常。

丟擲非Error物件,你就無法訪問許多環境裡提供的如stack這類關鍵資訊。

Error物件可以像任何其他物件一樣使用,你並不一定需要將其丟擲。 所以它們經常出現在一些回撥函式的第一個引數,不如Node裡的fs。readdir。

const fs = require(‘fs’);

fs。readdir(‘/example/i-do-not-exist’, function callback(err, dirs) {

if (err instanceof Error) {

// `readdir` 會因目錄不存在而丟擲錯誤

// 我們可以在回撥函數里得到並使用這個錯誤物件

console。log(‘Error Message: ’ + err。message);

console。log(‘See? We can use Errors without using try statements。’);

} else {

console。log(dirs);

}

});

最後,Error類的物件也可以在Promise裡用於駁回:

new Promise(function(resolve, reject) {

reject(new Error(‘The promise was rejected。’));

})。then(function() {

console。log(‘I am an error。’);

})。catch(function(err) {

if (err instanceof Error) {

console。log(‘The promise was rejected with an error。’);

console。log(‘Error Message: ’ + err。message);

}

});

操作呼叫棧蹤跡

現在終於到了大家在等待的不封:如何操作呼叫棧蹤跡。

本章內容適用於支援Error。captureStackTrace的環境,比如NodeJS。

Error。captureStackTrace函式接受一個物件作為第一個引數以及一個函式作為第二個可選引數。 這個函式是用來捕捉當前的呼叫棧然後存在傳入的物件當stack屬性中。 如果傳入了第二個引數,傳入的函式會被視作呼叫棧的終點,於是呼叫棧蹤跡就只會顯示該函式調執行前的部分。

舉個例子,首先我們只是捕捉當前的呼叫棧並存在一個普通物件裡。

const myObj = {};

function c() {

}

function b() {

// 此處把呼叫棧存到myObj裡

Error。captureStackTrace(myObj);

c();

}

function a() {

b();

}

// 首先呼叫這個函式

a();

// 現在看看

myObj。stack

都存了什麼

console。log(myObj。stack);

// 這會在控制檯裡列印如下內容

// at b (repl:3:7) <—— 既然是在B裡呼叫的captureStackTrace,所以最後一項是B

// at a (repl:2:1)

// at repl:1:1 <—— 往後是Node內部邏輯

// at realRunInThisContextScript (vm。js:22:35)

// ……

能注意到,我們先呼叫的a(被加到了棧裡)然後在a裡呼叫了b(被新增到a上面)。 然後,在b裡,我們捕捉到了當前的呼叫棧並存進了myObj。 所以在終端裡列印的的棧裡,我們會得到a和b。

現在我們看看給Error。captureStackTrace的第二個引數傳入一個函式會發生什麼:

const myObj = {};

function d() {

// 在myObj裡儲存當前的呼叫棧蹤跡

// 這次我們隱藏`b`以及`b`之前的呼叫幀

Error。captureStackTrace(myObj, b);

}

function c() {

d();

}

function b() {

c();

}

function a() {

b();

}

// 首先呼叫這個函式

a();

// 現在看看myObj。stack裡存了什麼

console。log(myObj。stack);

// 會在控制檯中列印如下內容

// at a (repl:2:1) <—— 如所見,我們只得到了b呼叫前的內容

// at repl:1:1 <—— 往後是Node內部邏輯

// at realRunInThisContextScript (vm。js:22:35)

// 。。。。

Error。captureStackTraceFunction

傳入b以後,b及其之上的呼叫幀都被隱藏了。 所以我們的呼叫棧蹤跡裡只有a。

現在你可能會問:“這有什麼用?” 利用這個方法可以對你的使用者隱藏與他無關的框架實現細節。 以Chai框架為例,我們利用這特性避免給使用者顯示它所不關心的斷言功能實現細節。

操作呼叫棧蹤跡的真實用例

如之前所屬,Chai框架利用呼叫棧操作技術來給使用者顯示更有意義的錯誤棧資訊。 我們是這麼做的:

首先看看斷言失敗時丟擲的AssertionError

建構函式

// `ssf` 代表 “start stack function”。 從這裡開始從呼叫棧裡移除不相關資訊

function AssertionError (message, _props, ssf) {

var extend = exclude(‘name’, ‘message’, ‘stack’, ‘constructor’, ‘toJSON’)

, props = extend(_props || {});

// 預設值

this。message = message || ‘Unspecified AssertionError’;

this。showDiff = false;

// 複製props

for (var key in props) {

this[key] = props[key];

}

// 一下是我們關注的內容:

// 如果傳入了ssf,就傳遞給captureStackTrace來移除它之後的內容

ssf = ssf || arguments。callee;

if (ssf && Error。captureStackTrace) {

Error。captureStackTrace(this, ssf);

} else {

// 如果沒有,就使用原始的stack屬性

try {

throw new Error();

} catch(e) {

this。stack = e。stack;

}

}

}

如上,如果提供了呼叫棧開始函式,我們使用Error。captureStackTrace來捕捉呼叫棧然後儲存到AssertionError例項裡,並從呼叫棧移除不相關內容(都是Chai的內部實現細節,只會汙染棧資訊)。