深入理解JavaScript錯誤和堆棧追蹤
有時候人們并不關(guān)注這些細(xì)節(jié),但這方面的知識肯定有用,尤其是當(dāng)你正在編寫與測試或errors相關(guān)的庫。例如這個星期我們的chai中出現(xiàn)了一個令人驚嘆的Pull Request,它大大改進了我們處理堆棧跟蹤的方式,并在用戶斷言失敗時提供了更多的信息。
操作堆棧記錄可以讓你清理無用數(shù)據(jù),并集中精力處理重要事項。此外,當(dāng)你真正弄清楚Error及其屬性,你將會更有信心地利用它。
本文開頭部分或許太過于簡單,但當(dāng)你開始處理堆棧記錄時,它將變得稍微有些復(fù)雜,所以請確保你在開始這個那部分章節(jié)之前已經(jīng)充分理解前面的內(nèi)容。
堆棧調(diào)用如何工作
在談?wù)揺rrors之前我們必須明白堆棧調(diào)用如何工作。它非常簡單,但對于我們將要深入的內(nèi)容而言卻是至關(guān)重要的。如果你已經(jīng)知道這部分內(nèi)容,請隨時跳過本節(jié)。
每當(dāng)函數(shù)被調(diào)用,它都會被推到堆棧的頂部。函數(shù)執(zhí)行完畢,便會從堆棧頂部移除。
這種數(shù)據(jù)結(jié)構(gòu)的有趣之處在于***一個入棧的將會***個從堆棧中移除,這也就是我們所熟悉的LIFO(后進,先出)特性。
這也就是說我們在函數(shù)x中調(diào)用函數(shù)y,那么對應(yīng)的堆棧中的順序為x y。
假設(shè)你有下面這樣的代碼:
- function c() {
- console.log('c');
- }
- function b() {
- console.log('b');
- c();
- }
- function a() {
- console.log('a');
- b();
- }
- a();
在上面這里例子中,當(dāng)執(zhí)行a函數(shù)時,a便會添加到堆棧的頂部,然后當(dāng)b函數(shù)在a函數(shù)中被調(diào)用,b也會被添加到堆棧的頂部,依次類推,在b中調(diào)用c也會發(fā)生同樣的事情。
當(dāng)c執(zhí)行時,堆棧中的函數(shù)的順序為a b c
c執(zhí)行完畢后便會從棧頂移除,這時控制流重新回到了b中,b執(zhí)行完畢同樣也會從棧頂移除,***控制流又回到了a中,***a執(zhí)行完畢,a也從堆棧中移除。
我們可以利用console.trace()來更好的演示這種行為,它會在控制臺打印出當(dāng)前堆棧中的記錄。此外,通常而言你應(yīng)該從上到下讀取堆棧記錄。想想下面的每一行代碼都是在哪調(diào)用的。
- function c() {
- console.log('c');
- console.trace();
- }
- function b() {
- console.log('b');
- c();
- }
- function a() {
- console.log('a');
- b();
- }
- a();
在Node REPL服務(wù)器上運行上述代碼會得到如下結(jié)果:
- 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)
如你所見,當(dāng)我們在c中打印堆棧,堆棧中的記錄為a,b,c。
如果我們現(xiàn)在在b中并且在c執(zhí)行完之后打印堆棧,我們將會發(fā)現(xiàn)c已經(jīng)從堆棧的頂部移除,只剩下了a和b。
- function c() {
- console.log('c');
- }
- function b() {
- console.log('b');
- c();
- console.trace();
- }
- function a() {
- console.log('a');
- b();
- }
- a();
正如你看到的那樣,堆棧中已經(jīng)沒有c,因為它已經(jīng)完成運行,已經(jīng)被彈出去了。
- 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)
總結(jié):調(diào)用方法,方法便會添加到堆棧頂部,執(zhí)行完畢之后,它就會從堆棧中彈出。
Error對象 和 Error處理
當(dāng)程序發(fā)生錯誤時,通常都會拋出一個Error對象。Error對象也可以作為一個原型,用戶可以擴展它并創(chuàng)建自定義錯誤。
Error.prototype對象通常有以下屬性:
- constructor- 實例原型的構(gòu)造函數(shù)。
- message - 錯誤信息
- name - 錯誤名稱
以上都是標(biāo)準(zhǔn)屬性,(但)有時候每個環(huán)境都有其特定的屬性,在例如Node,F(xiàn)irefox,Chorme,Edge,IE 10+,Opera 和 Safari 6+ 中,還有一個包含錯誤堆棧記錄的stack屬性。錯誤堆棧記錄包含從(堆棧底部)它自己的構(gòu)造函數(shù)到(堆棧頂部)所有的堆棧幀。
如果想了解更多關(guān)于Error對象的具體屬性,我強烈推薦MDN上的這篇文章。
拋出錯誤必須使用throw關(guān)鍵字,你必須將可能拋出錯誤的代碼包裹在try代碼塊內(nèi)并緊跟著一個catch代碼塊來捕獲拋出的錯誤。
正如Java中的錯誤處理,try/catch代碼塊后緊跟著一個finally代碼塊在JavaScript中也是同樣允許的,無論try代碼塊內(nèi)是否拋出異常,finally代碼塊內(nèi)的代碼都會執(zhí)行。在完成處理之后,***實踐是在finally代碼塊中做一些清理的事情,(因為)無論你的操作是否生效,都不會影響到它的執(zhí)行。
(鑒于)上面所談到的所有事情對大多數(shù)人來講都是小菜一碟,那么就讓我們來談一些不為人所知的細(xì)節(jié)。
try代碼塊后面不必緊跟著catch,但(此種情況下)其后必須緊跟著finally。這意味著我們可以使用三種不同形式的try語句:
- try...catch
- try...finally
- try...catch...finally
Try語句可以像下面這樣互相嵌套:
- try {
- try {
- throw new Error('Nested error.'); // The error thrown here will be caught by its own `catch` clause
- } catch (nestedErr) {
- console.log('Nested catch'); // This runs
- }
- } catch (err) {
- console.log('This will not run.');
- }
你甚至還可以在catch和finally代碼塊中嵌套try語句:
- 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對象。盡管這看起來非常cool且非常自由,但實際并非如此,尤其是對開發(fā)第三方庫的開發(fā)者來說,因為他們必須處理用戶(使用庫的開發(fā)者)的代碼。由于缺乏標(biāo)準(zhǔn),他們并不能把控用戶的行為。你不能相信用戶并簡單的拋出一個Error對象,因為他們不一定會那么做而是僅僅拋出一個字符串或者數(shù)字(鬼知道用戶會拋出什么)。這也使得處理必要的堆棧跟蹤和其他有意義的元數(shù)據(jù)變得更加困難。
假設(shè)有以下代碼:
- 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對象的函數(shù)給runWithoutThrowing函數(shù)(那就謝天謝地了),然而總有些人偷想懶直接拋出一個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);
現(xiàn)在第二個console.log會打印出 the error’s message is undefined.這么看來也沒多大的事(后果)呀,但是如果您需要確保某些屬性存在于Error對象上,或以另一種方式(例如Chai的throws斷言 does))處理Error對象的特定屬性,那么你做需要更多的工作,以確保它會正常工資。
此外,當(dāng)拋出的值不是Error對象時,你無法訪問其他重要數(shù)據(jù),例如stack,在某些環(huán)境中它是Error對象的一個屬性。
Errors也可以像其他任何對象一樣使用,并不一定非得要拋出他們,這也是它們?yōu)槭裁炊啻伪挥米骰卣{(diào)函數(shù)的***個參數(shù)(俗稱 err first)。 在下面的fs.readdir()例子中就是這么用的。
- const fs = require('fs');
- fs.readdir('/example/i-do-not-exist', function callback(err, dirs) {
- if (err instanceof Error) {
- // `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);
- }
- });
***,在rejecting promises時也可以使用Error對象。這使得它更容易處理promise rejections:
- 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);
- }
- });
操縱堆棧跟蹤
上面啰嗦了那么多,***的重頭戲來了,那就是如何操縱堆棧跟蹤。
本章專門針對那些像NodeJS支Error.captureStackTrace的環(huán)境。
Error.captureStackTrace函數(shù)接受一個object作為***個參數(shù),第二個參數(shù)是可選的,接受一個函數(shù)。capture stack trace 捕獲當(dāng)前堆棧跟蹤,并在目標(biāo)對象中創(chuàng)建一個stack屬性來存儲它。如果提供了第二個參數(shù),則傳遞的函數(shù)將被視為調(diào)用堆棧的終點,因此堆棧跟蹤將僅顯示調(diào)用該函數(shù)之前發(fā)生的調(diào)用。
讓我們用例子來說明這一點。首先,我們將捕獲當(dāng)前堆棧跟蹤并將其存儲在公共對象中。
- const myObj = {};
- function c() {
- }
- function b() {
- // Here we will store the current stack trace into myObj
- Error.captureStackTrace(myObj);
- c();
- }
- function a() {
- b();
- }
- // First we will call these functions
- a();
- // Now let's see what is the stack trace stored into myObj.stack
- console.log(myObj.stack);
- // This will print the following stack to the console:
- // 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)
不知道你注意到?jīng)],我們首先調(diào)用了a(a入棧),然后我們a中又調(diào)用了b(b入棧且在a之上)。然后在b中我們捕獲了當(dāng)前堆棧記錄并將其存儲在myObj中。因此在控制臺中才會按照b a的順序打印堆棧。
現(xiàn)在讓我們給Error.captureStackTrace傳遞一個函數(shù)作為第二個參數(shù),看看會發(fā)生什么:
- const myObj = {};
- function d() {
- // Here we will store the current stack trace into myObj
- // This time we will hide all the frames after `b` and `b` itself
- Error.captureStackTrace(myObj, b);
- }
- function c() {
- d();
- }
- function b() {
- c();
- }
- function a() {
- b();
- }
- // First we will call these functions
- a();
- // Now let's see what is the stack trace stored into myObj.stack
- console.log(myObj.stack);
- // This will print the following stack to the console:
- // 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)
當(dāng)把b傳給Error.captureStackTraceFunction時,它隱藏了b本身以及它之后所有的調(diào)用幀。因此控制臺僅僅打印出一個a。
至此你應(yīng)該會問自己:“這到底有什么用?”。這非常有用,因為你可以用它來隱藏與用戶無關(guān)的內(nèi)部實現(xiàn)細(xì)節(jié)。在Chai中,我們使用它來避免向用戶顯示我們是如何實施檢查和斷言本身的不相關(guān)的細(xì)節(jié)。
操作堆棧追蹤實戰(zhàn)
正如我在上一節(jié)中提到的,Chai使用堆棧操作技術(shù)使堆棧跟蹤更加與我們的用戶相關(guān)。下面將揭曉我們是如何做到的。
首先,讓我們來看看當(dāng)斷言失敗時拋出的AssertionError的構(gòu)造函數(shù):
- // `ssfi` stands for "start stack function". It is the reference to the
- // starting point for removing irrelevant frames from the stack trace
- function AssertionError (message, _props, ssf) {
- var extend = exclude('name', 'message', 'stack', 'constructor', 'toJSON')
- , props = extend(_props || {});
- // Default values
- this.message = message || 'Unspecified AssertionError';
- this.showDiff = false;
- // Copy from properties
- for (var key in props) {
- this[key] = props[key];
- }
- // Here is what is relevant for us:
- // If a start stack function was provided we capture the current stack trace and pass
- // it to the `captureStackTrace` function so we can remove frames that come after it
- ssf = ssf || arguments.callee;
- if (ssf && Error.captureStackTrace) {
- Error.captureStackTrace(this, ssf);
- } else {
- // If no start stack function was provided we just use the original stack property
- try {
- throw new Error();
- } catch(e) {
- this.stack = e.stack;
- }
- }
- }
如你所見,我們使用Error.captureStackTrace捕獲堆棧追蹤并將它存儲在我們正在創(chuàng)建的AssertError實例中(如果存在的話),然后我們將一個起始堆棧函數(shù)傳遞給它,以便從堆棧跟蹤中刪除不相關(guān)的調(diào)用幀,它只顯示Chai的內(nèi)部實現(xiàn)細(xì)節(jié),最終使堆棧變得清晰明了。
現(xiàn)在讓我們來看看@meeber在這個令人驚嘆的PR中提交的代碼。
在你開始看下面的代碼之前,我必須告訴你addChainableMethod方法是干啥的。它將傳遞給它的鏈?zhǔn)椒椒ㄌ砑拥綌嘌陨?,它也用包含斷言的方法?biāo)記斷言本身,并將其保存在變量ssfi(啟動堆棧函數(shù)指示符)中。這也就意味著當(dāng)前斷言將會是堆棧中的***一個調(diào)用幀,因此我們不會在堆棧中顯示Chai中的任何進一步的內(nèi)部方法。我沒有添加整個代碼,因為它做了很多事情,有點棘手,但如果你想讀它,點我閱讀。
下面的這個代碼片段中,我們有一個lengOf斷言的邏輯,它檢查一個對象是否有一定的length。我們希望用戶可以像這樣來使用它: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');
- // Pay close attention to this line
- new Assertion(obj, msg, ssfi, true).to.have.property('length');
- var len = obj.length;
- // This line is also relevant
- 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);
Assertion.addChainableMethod('lengthOf', assertLength, assertLengthChain);
在上面的代碼片段中,我突出強調(diào)了與我們現(xiàn)在相關(guān)的代碼。讓我們從調(diào)用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);
- // This is the relevant line for us
- throw new AssertionError(msg, {
- actual: actual
- , expected: expected
- , showDiff: showDiff
- }, (config.includeStack) ? this.assert : flag(this, 'ssfi'));
- }
- };
assert方法負(fù)責(zé)檢查斷言布爾表達式是否通過。如果不通過,我們則實例化一個AssertionError。不知道你注意到?jīng)],在實例化AssertionError時,我們也給它傳遞了一個堆棧追蹤函數(shù)指示器(ssfi),如果配置的includeStack處于開啟狀態(tài),我們通過將this.assert本身傳遞給它來為用戶顯示整個堆棧跟蹤。反之,我們則只顯示ssfi標(biāo)記中存儲的內(nèi)容,隱藏掉堆棧跟蹤中更多的內(nèi)部實現(xiàn)細(xì)節(jié)。
現(xiàn)在讓我們來討論下一行和我們相關(guān)的代碼吧:
- `new Assertion(obj, msg, ssfi, true).to.have.property('length');`
As you can see here we are passing the content we’ve got from the ssfi flag when creating our nested assertion. This means that when the new assertion gets created it will use this function as the starting point for removing unuseful frames from the stack trace. By the way, this is the Assertion constructor: 如你所見,我們在創(chuàng)建嵌套斷言時將從ssfi標(biāo)記中的內(nèi)容傳遞給了它。這意味著新創(chuàng)建的斷言會使用那個方法作為起始調(diào)用幀,從而可以從堆棧追蹤中清除沒有的調(diào)用棧。順便也看下Assertion的構(gòu)造器吧:
- function Assertion (obj, msg, ssfi, lockSsfi) {
- // This is the line that matters to us
- flag(this, 'ssfi', ssfi || Assertion);
- flag(this, 'lockSsfi', lockSsfi);
- flag(this, 'object', obj);
- flag(this, 'message', msg);
- return util.proxify(this);
- }
不知道你是否還記的我先前說過的addChainableMethod方法,它使用自己的父級方法設(shè)置ssfi標(biāo)志,這意味著它始終處于堆棧的底部,我們可以刪除它之上的所有調(diào)用幀。
通過將ssfi傳遞給嵌套斷言,它只檢查我們的對象是否具有長度屬性,我們就可以避免重置我們將要用作起始指標(biāo)器的調(diào)用幀,然后在堆棧中可以看到以前的addChainableMethod。
這可能看起來有點復(fù)雜,所以讓我們回顧一下我們想從棧中刪除無用的調(diào)用幀時Chai中所發(fā)生的事情:
- 當(dāng)我們運行斷言時,我們將它自己的方法作為移除堆棧中的下一個調(diào)用幀的參考
- 斷言失敗時,我們會移除所有我們在參考幀之后保存的內(nèi)部調(diào)用幀。
- 如果存在嵌套的斷言。我們必須依舊使用當(dāng)前斷言的父方法作為刪除下一個調(diào)用幀的參考點,因此我們把當(dāng)前的ssfi(起始函數(shù)指示器)傳遞給我們所創(chuàng)建的斷言,以便它可以保存。