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

全文共 6988 字,讀完需 10 分鐘,速讀需 3 分鐘。本文通過剖析 JS 中調用棧的工作機制,講解錯誤拋出、處理的正確姿勢,以及錯誤堆棧的獲取、清理處理方法,希望大家對這個少有人關注但極其有用的知識點能夠有所理解和掌握。適合的學習對象是初中級 JS 工程師。

大多數工程師可能並沒留意過 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();}// 調用函數 aa();// 列印 myObj.stackconsole.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.stackconsole.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

想讀到更多類似內容?請訂閱我的專欄《前端周刊,讓你在前端領域跟上時代的腳步》,或者掃描本文封面中的二維碼訂閱微信號。

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


推薦閱讀:

跨瀏覽器問題的五種解決方案
極樂技術周報(第二十一期)
大家匯率轉換都用什麼開放介面?

TAG:JavaScript | 原生JavaScript | 前端开发 |