你不知道的 JavaScript 錯誤和呼叫棧常識

王仕軍發表於2017-03-10

大多數工程師可能並沒留意過 JS 中錯誤物件、錯誤堆疊的細節,即使他們每天的日常工作會面臨不少的報錯,部分同學甚至在 console 的錯誤面前一臉懵逼,不知道從何開始排查,如果你對本文講解的內容有系統的瞭解,就會從容很多。而錯誤堆疊清理能讓你有效去掉噪音資訊,聚焦在真正重要的地方,此外,如果理解了 Error 的各種屬性到底是什麼,你就能更好的利用他。

接下來,我們就直奔主題。

呼叫棧的工作機制

在探討 JS 中的錯誤之前,我們必須理解呼叫棧(Call Stack)的工作機制,其實這個機制非常簡單,如果你對這個已經一清二楚了,可以直接跳過這部分內容。

簡單的說:函式被呼叫時,就會被加入到呼叫棧頂部,執行結束之後,就會從呼叫棧頂部移除該函式,這種資料結構的關鍵在於後進先出,即大家所熟知的 LIFO。比如,當我們在函式 y 內部呼叫函式 x 的時候,呼叫棧從下往上的順序就是 y -> x 。

我們再舉個程式碼例項:

function c() {
    console.log('c');
}

function b() {
    console.log('b');
    c();
}

function a() {
    console.log('a');
    b();
}

a();

這段程式碼執行時,首先 a 會被加入到呼叫棧的頂部,然後,因為 a 內部呼叫了 b,緊接著 b 被加入到呼叫棧的頂部,當 b 內部呼叫 c 的時候也是類似的。在呼叫 c的時候,我們的呼叫棧從下往上會是這樣的順序:a -> b -> c。在 c 執行完畢之後,c 被從呼叫棧中移除,控制流回到 b 上,呼叫棧會變成:a -> b,然後 b 執行完之後,呼叫棧會變成:a,當 a 執行完,也會被從呼叫棧移除。

為了更好的說明呼叫棧的工作機制,我們對上面的程式碼稍作改動,使用 console.trace 來把當前的呼叫棧輸出到 console 中,你可以認為console.trace 列印出來的呼叫棧的每一行出現的原因是它下面的那行呼叫而引起的。

function c() {
    console.log('c');
    console.trace();
}

function b() {
    console.log('b');
    c();
}

function a() {
    console.log('a');
    b();
}

a();

當我們在 Node.js 的 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 內部呼叫 console.trace 的時候,呼叫棧從下往上的結構是:a -> b -> c。如果把程式碼再稍作改動,在 b 中 c 執行完之後呼叫,如下:

function c() {
    console.log('c');
}

function b() {
    console.log('b');
    c();
    console.trace();
}

function a() {
    console.log('a');
    b();
}

a();

通過輸出結果可以看到,此時列印的呼叫棧從下往上是:a -> b,已經沒有 c 了,因為 c 執行完之後就從呼叫棧移除了。

Trace
    at b (repl:4:9)
    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)
    at REPLServer.onLine (repl.js:513:10)

再總結下呼叫棧的工作機制:呼叫函式的時候,會被推到呼叫棧的頂部,而執行完畢之後,就會從呼叫棧移除。

Error 物件及錯誤處理

當程式碼中發生錯誤時,我們通常會丟擲一個 Error 物件。Error 物件可以作為擴充套件和建立自定義錯誤型別的原型。Error 物件的 prototype 具有以下屬性:

  • constructor – 負責該例項的原型建構函式;
  • message – 錯誤資訊;
  • name – 錯誤的名字;

上面都是標準屬性,有些 JS 執行環境還提供了標準屬性之外的屬性,如 Node.js、Firefox、Chrome、Edge、IE 10、Opera 和 Safari 6+ 中會有 stack 屬性,它包含了錯誤程式碼的呼叫棧,接下來我們簡稱錯誤堆疊。錯誤堆疊包含了產生該錯誤時完整的呼叫棧資訊。如果您想了解更多關於 Error 物件的非標準屬性,我強烈建議你閱讀 MDN 的這篇文章

丟擲錯誤時,你必須使用 throw 關鍵字。為了捕獲丟擲的錯誤,則必須使用 try catch 語句把可能出錯的程式碼塊包起來,catch 的時候可以接收一個引數,該引數就是被丟擲的錯誤。與 Java 中類似,JS 中也可以在 try catch 語句之後有 finally,不論前面程式碼是否丟擲錯誤 finally 裡面的程式碼都會執行,這種語言的常見用途有:在 finally 中做些清理的工作。

此外,你可以使用沒有 catch 的 try 語句,但是後面必須跟上 finally,這意味著我們可以使用三種不同形式的 try 語句:

  • try … catch
  • try … finally
  • try … catch … finally

try 語句還可以巢狀在 try 語句中,比如:

try {
    try {
        throw new Error('Nested error.'); // 這裡的錯誤會被自己緊接著的 catch 捕獲
    } catch (nestedErr) {
        console.log('Nested catch'); // 這裡會執行
    }
} catch (err) {
    console.log('This will not run.');  // 這裡不會執行
}

try 語句也可以巢狀在 catch 和 finally 語句中,比如下面的兩個例子:

try {
    throw new Error('First error');
} catch (err) {
    console.log('First catch running');
    try {
        throw new Error('Second error');
    } catch (nestedErr) {
        console.log('Second catch running.');
    }
}
try {
    console.log('The try block is running...');
} finally {
    try {
        throw new Error('Error inside finally.');
    } catch (err) {
        console.log('Caught an error inside the finally block.');
    }
}

同樣需要注意的是,你可以丟擲不是 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 物件,這段程式碼不會有任何問題,如果他們丟擲了字串那就有問題了,比如:

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);

這段程式碼執行時,runWithoutThrowing 中的第 2 次 console.log 會丟擲錯誤,因為 e.message 是未定義的。這些看起來似乎沒什麼大不了的,但如果你的程式碼需要使用 Error 物件的某些特定屬性,那麼你就需要做很多額外的工作來確保一切正常。如果你丟擲的值不是 Error 物件,你就不會拿到錯誤相關的重要資訊,比如 stack,雖然這個屬性在部分 JS 執行環境中才會有。

Error 物件也可以向其他物件那樣使用,你可以不用丟擲錯誤,而只是把錯誤傳遞出去,Node.js 中的錯誤優先回撥就是這種做法的典型範例,比如 Node.js 中的 fs.readdir 函式:

const fs = require('fs');

fs.readdir('/example/i-do-not-exist', function callback(err, dirs) {
    if (err) {
        // `readdir` will throw an error because that directory does not exist
        // We will now be able to use the error object passed by it in our callback function
        console.log('Error Message: ' + err.message);
        console.log('See? We can use Errors without using try statements.');
    } else {
        console.log(dirs);
    }
});

此外,Error 物件還可以用於 Promise.reject 的時候,這樣可以更容易的處理 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);
    }
});

錯誤堆疊的裁剪

Node.js 才支援這個特性,通過 Error.captureStackTrace 來實現,Error.captureStackTrace 接收一個 object 作為第 1 個引數,以及可選的 function 作為第 2 個引數。其作用是捕獲當前的呼叫棧並對其進行裁剪,捕獲到的呼叫棧會記錄在第 1 個引數的 stack 屬性上,裁剪的參照點是第 2 個引數,也就是說,此函式之前的呼叫會被記錄到呼叫棧上面,而之後的不會。

讓我們用程式碼來說明,首先,把當前的呼叫棧捕獲並放到 myObj 上:

const myObj = {};

function c() {
}

function b() {
    // 把當前呼叫棧寫到 myObj 上
    Error.captureStackTrace(myObj);
    c();
}

function a() {
    b();
}

// 呼叫函式 a
a();

// 列印 myObj.stack
console.log(myObj.stack);

// 輸出會是這樣
//    at b (repl:3:7) <-- Since it was called inside B, the B call is the last entry in the stack
//    at a (repl:2:1)
//    at repl:1:1 <-- Node internals below this line
//    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)
//    at REPLServer.onLine (repl.js:513:10)

上面的呼叫棧中只有 a -> b,因為我們在 b 呼叫 c 之前就捕獲了呼叫棧。現在對上面的程式碼稍作修改,然後看看會發生什麼:

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) <-- As you can see here we only get frames before `b` was called
//    at repl:1:1 <-- Node internals below this line
//    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)
//    at REPLServer.onLine (repl.js:513:10)
//    at emitOne (events.js:101:20)

在這段程式碼裡面,因為我們在呼叫 Error.captureStackTrace 的時候傳入了 b,這樣 b 之後的呼叫棧都會被隱藏。

現在你可能會問,知道這些到底有啥用?如果你想對使用者隱藏跟他業務無關的錯誤堆疊(比如某個庫的內部實現)就可以試用這個技巧。

總結

通過本文的描述,相信你對 JS 中的呼叫棧、Error 物件、錯誤堆疊有了清晰的認識,在遇到錯誤的時候不在慌亂。如果對文中的內容有任何疑問,歡迎在下面評論。

One More Thing

想知道這個人以後還會寫什麼?請關注本專欄,或者關注作者本人,也可以掃描文章封面中的二維碼訂閱前端週刊微訊號。

腳註:本文是在 http://lucasfcosta.com/2017/02/17/JavaScript-Errors-and-Stack-Traces.html 的基礎上做了大量修改而成,英文好的同學可以直接讀原文,因為考慮到最後那部分離多數工程師實際工作較遠,就沒有翻譯。

相關文章