你不知道的 JS 錯誤和調用棧常識

本文首發知乎專欄:《前端週刊》。全文共 6988 字,讀完需 10 分鐘,速讀需 3 分鐘。經過剖析 JS 中調用棧的工做機制,講解錯誤拋出、處理的正確姿式,以及錯誤堆棧的獲取、清理處理方法,但願你們對這個少有人關注但極其有用的知識點可以有所理解和掌握。適合的學習對象是初中級 JS 工程師。html

大多數工程師可能並沒留意過 JS 中錯誤對象、錯誤堆棧的細節,即便他們天天的平常工做會面臨很多的報錯,部分同窗甚至在 console 的錯誤面前一臉懵逼,不知道從何開始排查,若是你對本文講解的內容有系統的瞭解,就會從容不少。而錯誤堆棧清理能讓你有效去掉噪音信息,聚焦在真正重要的地方,此外,若是理解了 Error 的各類屬性究竟是什麼,你就能更好的利用他。前端

接下來,咱們就直奔主題。node

調用棧的工做機制

在探討 JS 中的錯誤以前,咱們必須理解調用棧(Call Stack)的工做機制,其實這個機制很是簡單,若是你對這個已經一清二楚了,能夠直接跳過這部份內容。api

簡單的說:函數被調用時,就會被加入到調用棧頂部,執行結束以後,就會從調用棧頂部移除該函數,這種數據結構的關鍵在於後進先出,即你們所熟知的 LIFO。好比,當咱們在函數 y 內部調用函數 x 的時候,調用棧從下往上的順序就是 y -> xpromise

咱們再舉個代碼實例:數據結構

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 執行完,也會被從調用棧移除。app

爲了更好的說明調用棧的工做機制,咱們對上面的代碼稍做改動,使用 console.trace 來把當前的調用棧輸出到 console 中,你能夠認爲console.trace 打印出來的調用棧的每一行出現的緣由是它下面的那行調用而引發的。dom

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

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

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

a();

當咱們在 Node.js 的 REPL 中運行這段代碼,會獲得以下的結果:ide

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。若是把代碼再稍做改動,在 bc 執行完以後調用,以下:函數

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 十、Opera 和 Safari 6+ 中會有 stack 屬性,它包含了錯誤代碼的調用棧,接下來咱們簡稱錯誤堆棧錯誤堆棧包含了產生該錯誤時完整的調用棧信息。若是您想了解更多關於 Error 對象的非標準屬性,我強烈建議你閱讀 MDN這篇文章

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

此外,你可使用沒有 catchtry 語句,可是後面必須跟上 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 語句也能夠嵌套在 catchfinally 語句中,好比下面的兩個例子:

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 對象、錯誤堆棧有了清晰的認識,在遇到錯誤的時候不在慌亂。若是對文中的內容有任何疑問,歡迎在下面評論。想知道這我的接下來會寫些什麼?歡迎訂閱個人知乎專欄:《前端週刊》

Happy Hacking

相關文章
相關標籤/搜索