譯者注:本文做者是著名 JavaScript BDD 測試框架 Chai.js 源碼貢獻者之一,Chai.js 中會遇到不少異常處理的狀況。跟隨做者思路,從 JavaScript 基本的 Errors 原理,到如何實際使用 Stack Traces,深刻學習和理解 JavaScript Errors 和 Stack Traces。文章貼出的源碼連接也很是值得學習。html
做者:lucasfcosta <br/>
編譯:鬍子大哈 node
翻譯原文:[http://huziketang.com/blog/po...
](http://huziketang.com/blog/po... <br/>
英文原文:JavaScript Errors and Stack Traces in Depth react
轉載請註明出處,保留原文連接以及做者信息git
好久沒給你們更新關於 JavaScript 的內容了,這篇文章咱們來聊聊 JavaScript 。github
此次咱們聊聊 Errors 和 Stack traces 以及如何熟練地使用它們。編程
不少同窗並不重視這些細節,可是這些知識在你寫 Testing 和 Error 相關的 lib 的時候是很是有用的。使用 Stack traces 能夠清理無用的數據,讓你關注真正重要的問題。同時,你真正理解 Errors 和它們的屬性究竟是什麼的時候,你將會更有信心的使用它們。api
這篇文章在開始的時候看起來比較簡單,但當你熟練運用 Stack trace 之後則會感到很是複雜。因此在看難的章節以前,請確保你理解了前面的內容。promise
在咱們談到 Errors 以前,咱們必須理解 Stack 是如何工做的。它其實很是簡單,可是在開始以前瞭解它也是很是必要的。若是你已經知道了這些,能夠略過這一章節。數據結構
每當有一個函數調用,就會將其壓入棧頂。在調用結束的時候再將其從棧頂移出。框架
這種有趣的數據結構叫作「最後一個進入的,將會第一個出去」。這就是廣爲所知的 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 中被調用的時候,它會被繼續壓入棧頂,當 c 在 b 中被調用的時候,也同樣。
在運行 c 的時候,棧中包含了 a,b,c,而且其順序也是 a,b,c。
當 c 調用完畢時,它會被從棧頂移出,隨後控制流回到 b。當 b 執行完畢後也會從棧頂移出,控制流交還到 a。最後,當 a 執行完畢後也會從棧中移出。
爲了更好的展現這樣一種行爲,咱們用console.trace()
來將 Stack trace 打印到控制檯上來。一般咱們讀 Stack traces 信息的時候是從上往下讀的。
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 // <-- For now feel free to ignore anything below this point, these are Node's internals 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 中把 Stack trace 打印出來,咱們能夠看到 c 已經從棧中移出了,棧中只有 a 和 b。
function c() { console.log('c'); } function b() { console.log('b'); c(); console.trace(); } function a() { console.log('a'); b(); } a();
下面能夠看到,c 已經不在棧中了,在其執行完之後,從棧中 pop 出去了。
Trace at b (repl:4:9) at a (repl:3:1) at repl:1:1 // <-- For now feel free to ignore anything below this point, these are Node's internals 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
原型,用戶能夠擴展其含義,以建立本身的 Error 對象。
Error.prototype
對象一般包含下面屬性:
constructor
- 一個錯誤實例原型的構造函數
message
- 錯誤信息
name
- 錯誤名稱
這幾個都是標準屬性,有時不一樣編譯的環境會有其獨特的屬性。在一些環境中,例如 Node 和 Firefox,甚至還有stack
屬性,這裏麪包含了錯誤的 Stack trace。一個Error的堆棧追蹤包含了從其構造函數開始的全部堆棧幀。
若是你想要學習一個Error
對象的特殊屬性,我強烈建議你看一下在MDN上的這篇文章。
要拋出一個Error
,你必須使用throw
關鍵字。爲了catch
一個拋出的Error
,你必須把可能拋出Error
的代碼用try
塊包起來。而後緊跟着一個catch
塊,catch
塊中一般會接受一個包含了錯誤信息的參數。
和在 Java 中相似,不論在try
中是否拋出Error
, JavaScript 中都容許你在try/catch
塊後面緊跟着一個finally
塊。不論你在try
中的操做是否生效,在你操做完之後,都用finally
來清理對象,這是個編程的好習慣。
介紹到如今的知識,可能對於大部分人來講,都是已經掌握了的,那麼如今咱們就進行更深刻一些的吧。
使用try
塊時,後面能夠不跟着catch
塊,可是必須跟着finally
塊。因此咱們就有三種不一樣形式的try
語句:
try...catch
try...finally
try...catch...finally
Try語句也能夠內嵌在一個try
語句中,如:
try { try { // 這裏拋出的Error,將被下面的catch獲取到 throw new Error('Nested error.'); } 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
信息了,由於有可能它並非Error
信息,而是一個字符串或者一個數字。另外這也致使了若是你須要處理 Stack trace 或者其餘有意義的元數據,也將變的很困難。
例如給你下面這段代碼:
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);
這段代碼,若是其餘人傳遞一個帶有拋出Error
對象的函數給runWithoutThrowing
函數的話,將完美運行。然而,若是他拋出一個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
會告訴你這個 Error 信息是undefined
。這如今看起來不是很重要,可是若是你須要肯定是否這個Error
中確實包含某個屬性,或者用另外一種方式處理Error
的特殊屬性,那你就須要多花不少的功夫了。
另外,當拋出一個非Error
對象的值時,你沒有訪問Error
對象的一些重要的數據,好比它的堆棧,而這在一些編譯環境中是一個很是重要的Error
對象屬性。
Error 還能夠當作其餘普通對象同樣使用,你並不須要拋出它。這就是爲何它一般做爲回調函數的第一個參數,就像fs.readdir
函數這樣:
const fs = require('fs'); fs.readdir('/example/i-do-not-exist', function callback(err, dirs) { if (err instanceof Error) { // 'readdir'將會拋出一個異常,由於目錄不存在 // 咱們能夠在咱們的回調函數中使用 Error 對象 console.log('Error Message: ' + err.message); console.log('See? We can use Errors without using try statements.'); } else { console.log(dirs); } });
最後,你也能夠在 promise 被 reject 的時候使用Error
對象,這使得處理 promise reject 變得很簡單。
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); } });
ok,那麼如今,大家所期待的部分來了:如何使用堆棧追蹤。
這一章專門討論支持 Error.captureStackTrace 的環境,如:NodeJS。
Error.captureStackTrace
函數的第一個參數是一個object
對象,第二個參數是一個可選的function
。捕獲堆棧跟蹤所作的是要捕獲當前堆棧的路徑(這是顯而易見的),而且在 object 對象上建立一個stack
屬性來存儲它。若是提供了第二個 function 參數,那麼這個被傳遞的函數將會被當作是本次堆棧調用的終點,本次堆棧跟蹤只會展現到這個函數被調用以前。
咱們來用幾個例子來更清晰的解釋下。咱們將捕獲當前堆棧路徑而且將其存儲到一個普通 object 對象中。
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) <-- 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
(a被壓入棧),而後從a
的內部調用了b
(b被壓入棧,而且在a
的上面)。在b
中,咱們捕獲到了當前堆棧路徑而且將其存儲在了myObj
中。這就是爲何打印在控制檯上的只有a
和b
,並且是下面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) <-- 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)
當咱們傳遞b
到Error.captureStackTraceFunction
裏時,它隱藏了b
和在它以上的全部堆棧幀。這就是爲何堆棧路徑裏只有a
的緣由。
看到這,你可能會問這樣一個問題:「爲何這是有用的呢?」。它之因此有用,是由於你能夠隱藏全部的內部實現細節,而這些細節其餘開發者調用的時候並不須要知道。例如,在 Chai 中,咱們用這種方法對咱們代碼的調用者屏蔽了不相關的實現細節。
正如我在上一節中提到的,Chai 用棧處理技術使得堆棧路徑和調用者更加相關,這裏是咱們如何實現它的。
首先,讓咱們來看一下當一個 Assertion 失敗的時候,AssertionError
的構造函數作了什麼。
// 'ssfi'表明"起始堆棧函數",它是移除其餘不相關堆棧幀的起始標記 function AssertionError (message, _props, ssf) { var extend = exclude('name', 'message', 'stack', 'constructor', 'toJSON') , props = extend(_props || {}); // 默認值 this.message = message || 'Unspecified AssertionError'; this.showDiff = false; // 從屬性中copy for (var key in props) { this[key] = props[key]; } // 這裏是和咱們相關的 // 若是提供了起始堆棧函數,那麼咱們從當前堆棧路徑中獲取到, // 而且將其傳遞給'captureStackTrace',以保證移除其後的全部幀 ssf = ssf || arguments.callee; if (ssf && Error.captureStackTrace) { Error.captureStackTrace(this, ssf); } else { // 若是沒有提供起始堆棧函數,那麼使用原始堆棧 try { throw new Error(); } catch(e) { this.stack = e.stack; } } }
正如你在上面能夠看到的,咱們使用了Error.captureStackTrace
來捕獲堆棧路徑,而且把它存儲在咱們所建立的一個AssertionError
實例中。而後傳遞了一個起始堆棧函數進去(用if判斷若是存在則傳遞),這樣就從堆棧路徑中移除掉了不相關的堆棧幀,不顯示一些內部實現細節,保證了堆棧信息的「清潔」。
感興趣的讀者能夠繼續看一下最近 @meeber 在 這裏 的代碼。
在咱們繼續看下面的代碼以前,我要先告訴你addChainableMethod
都作了什麼。它添加所傳遞的能夠被鏈式調用的方法到 Assertion,而且用包含了 Assertion 的方法標記 Assertion 自己。用ssfi(表示起始堆棧函數指示器)這個名字記錄。這意味着當前 Assertion 就是堆棧的最後一幀,就是說不會再多顯示任何 Chai 項目中的內部實現細節了。我在這裏就很少列出來其整個代碼了,裏面用了不少 trick 的方法,可是若是你想了解更多,能夠從 這個連接 裏獲取到。
在下面的代碼中,展現了lengthOf
的 Assertion 的邏輯,它是用來檢查一個對象的肯定長度的。咱們但願調用咱們函數的開發者這樣來使用:expect(['foo', 'bar']).to.have.lengthOf(2)
。
function assertLength (n, msg) { if (msg) flag(this, 'message', msg); var obj = flag(this, 'object') , ssfi = flag(this, 'ssfi'); // 密切關注這一行 new Assertion(obj, msg, ssfi, true).to.have.property('length'); var len = obj.length; // 這一行也是相關的 this.assert( len == n , 'expected #{this} to have a length of #{exp} but got #{act}' , 'expected #{this} to not have a length of #{act}' , n , len ); } Assertion.addChainableMethod('lengthOf', assertLength, assertLengthChain);
在代碼中,我着重對跟咱們相關的代碼進行了註釋,咱們從this.assert
的調用開始。
下面是this.assert
方法的代碼:
Assertion.prototype.assert = function (expr, msg, negateMsg, expected, _actual, showDiff) { var ok = util.test(this, arguments); if (false !== showDiff) showDiff = true; if (undefined === expected && undefined === _actual) showDiff = false; if (true !== config.showDiff) showDiff = false; if (!ok) { msg = util.getMessage(this, arguments); var actual = util.getActual(this, arguments); // 這是和咱們相關的行 throw new AssertionError(msg, { actual: actual , expected: expected , showDiff: showDiff }, (config.includeStack) ? this.assert : flag(this, 'ssfi')); } };
assert
方法主要用來檢查 Assertion 的布爾表達式是真仍是假。若是是假,則咱們必須實例化一個AssertionError
。這裏注意,當咱們實例化一個AssertionError
對象的時候,咱們也傳遞了一個起始堆棧函數指示器(ssfi)。若是配置標記includeStack
是打開的,咱們經過傳遞一個this.assert
給調用者,以向他展現整個堆棧路徑。但是,若是includeStack
配置是關閉的,咱們則必須從堆棧路徑中隱藏內部實現細節,這就須要用到存儲在ssfi
中的標記了。
ok,那麼咱們再來討論一下其餘和咱們相關的代碼:
new Assertion(obj, msg, ssfi, true).to.have.property('length');
能夠看到,當建立這個內嵌 Assertion 的時候,咱們傳遞了ssfi
中已獲取到的內容。這意味着,當建立一個新的 Assertion 時,將使用這個函數來做爲從堆棧路徑中移除無用堆棧幀的起始點。順便說一下,下面這段代碼是Assertion
的構造函數。
function Assertion (obj, msg, ssfi, lockSsfi) { // 這是和咱們相關的行 flag(this, 'ssfi', ssfi || Assertion); flag(this, 'lockSsfi', lockSsfi); flag(this, 'object', obj); flag(this, 'message', msg); return util.proxify(this); }
還記得我在講述addChainableMethod
時說的,它用包含他本身的方法設置的ssfi
標記,這就意味着這是堆棧路徑中最底層的內部幀,咱們能夠移除在它之上的全部幀。
回想上面的代碼,內嵌 Assertion 用來判斷對象是否是有合適的長度(Length)。傳遞ssfi
到這個 Assertion 中,要避免重置咱們要將其做爲起始指示器的堆棧幀,而且使先前的addChainableMethod
在堆棧中保持可見狀態。
這看起來可能有點複雜,如今咱們從新回顧一下,咱們想要移除沒有用的堆棧幀都作了什麼工做:
當咱們運行一個 Assertion 時,咱們設置它自己來做爲咱們移除其後面堆棧幀的標記。
這個 Assertion 開始執行,若是判斷失敗,那麼從剛纔咱們所存儲的那個標記開始,移除其後面全部的內部幀。
若是有內嵌 Assertion,那麼咱們必需要使用包含當前 Assertion 的方法做爲移除後面堆棧幀的標記,即放到ssfi
中。所以咱們要傳遞當前ssfi
(起始堆棧函數指示器)到咱們即將要新建立的內嵌 Assertion 中來存儲起來。
最後我仍是強烈建議來閱讀一下 @meeber的評論 來加深對它的理解。
我最近正在寫一本《React.js 小書》,對 React.js 感興趣的童鞋,歡迎指點。