您好,登錄后才能下訂單哦!
如何深入理解JavaScript錯誤和堆棧追蹤,相信很多沒有經驗的人對此束手無策,為此本文總結了問題出現的原因和解決方法,通過這篇文章希望你能解決這個問題。
有時候人們并不關注這些細節,但這方面的知識肯定有用,尤其是當你正在編寫與測試或errors相關的庫。例如這個星期我們的chai中出現了一個令人驚嘆的Pull Request,它大大改進了我們處理堆棧跟蹤的方式,并在用戶斷言失敗時提供了更多的信息。操作堆棧記錄可以讓你清理無用數據,并集中精力處理重要事項。此外,當你真正弄清楚Error及其屬性,你將會更有信心地利用它。
堆棧調用如何工作
在談論errors之前我們必須明白堆棧調用如何工作。它非常簡單,但對于我們將要深入的內容而言卻是至關重要的。如果你已經知道這部分內容,請隨時跳過本節。
每當函數被調用,它都會被推到堆棧的頂部。函數執行完畢,便會從堆棧頂部移除。
這種數據結構的有趣之處在于***一個入棧的將會***個從堆棧中移除,這也就是我們所熟悉的LIFO(后進,先出)特性。
這也就是說我們在函數x中調用函數y,那么對應的堆棧中的順序為x y。
假設你有下面這樣的代碼:
function c() { console.log('c'); } function b() { console.log('b'); c(); } function a() { console.log('a'); b(); } a();
在上面這里例子中,當執行a函數時,a便會添加到堆棧的頂部,然后當b函數在a函數中被調用,b也會被添加到堆棧的頂部,依次類推,在b中調用c也會發生同樣的事情。
當c執行時,堆棧中的函數的順序為a b c
c執行完畢后便會從棧頂移除,這時控制流重新回到了b中,b執行完畢同樣也會從棧頂移除,***控制流又回到了a中,***a執行完畢,a也從堆棧中移除。
我們可以利用console.trace()來更好的演示這種行為,它會在控制臺打印出當前堆棧中的記錄。此外,通常而言你應該從上到下讀取堆棧記錄。想想下面的每一行代碼都是在哪調用的。
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。
如果我們現在在b中并且在c執行完之后打印堆棧,我們將會發現c已經從堆棧的頂部移除,只剩下了a和b。
function c() { console.log('c'); } function b() { console.log('b'); c(); console.trace(); } function a() { console.log('a'); b(); } a();
正如你看到的那樣,堆棧中已經沒有c,因為它已經完成運行,已經被彈出去了。
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.prototype對象通常有以下屬性:
constructor- 實例原型的構造函數。
message - 錯誤信息
name - 錯誤名稱
以上都是標準屬性,(但)有時候每個環境都有其特定的屬性,在例如Node,Firefox,Chorme,Edge,IE 10+,Opera 和 Safari 6+ 中,還有一個包含錯誤堆棧記錄的stack屬性。錯誤堆棧記錄包含從(堆棧底部)它自己的構造函數到(堆棧頂部)所有的堆棧幀。
如果想了解更多關于Error對象的具體屬性,我強烈推薦MDN上的這篇文章。
拋出錯誤必須使用throw關鍵字,你必須將可能拋出錯誤的代碼包裹在try代碼塊內并緊跟著一個catch代碼塊來捕獲拋出的錯誤。
正如Java中的錯誤處理,try/catch代碼塊后緊跟著一個finally代碼塊在JavaScript中也是同樣允許的,無論try代碼塊內是否拋出異常,finally代碼塊內的代碼都會執行。在完成處理之后,***實踐是在finally代碼塊中做一些清理的事情,(因為)無論你的操作是否生效,都不會影響到它的執行。
(鑒于)上面所談到的所有事情對大多數人來講都是小菜一碟,那么就讓我們來談一些不為人所知的細節。
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且非常自由,但實際并非如此,尤其是對開發第三方庫的開發者來說,因為他們必須處理用戶(使用庫的開發者)的代碼。由于缺乏標準,他們并不能把控用戶的行為。你不能相信用戶并簡單的拋出一個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);
如果你的用戶像上面這樣傳遞一個拋出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會打印出 the error’s message is undefined.這么看來也沒多大的事(后果)呀,但是如果您需要確保某些屬性存在于Error對象上,或以另一種方式(例如Chai的throws斷言 does))處理Error對象的特定屬性,那么你做需要更多的工作,以確保它會正常工資。
此外,當拋出的值不是Error對象時,你無法訪問其他重要數據,例如stack,在某些環境中它是Error對象的一個屬性。
Errors也可以像其他任何對象一樣使用,并不一定非得要拋出他們,這也是它們為什么多次被用作回調函數的***個參數(俗稱 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的環境。
Error.captureStackTrace函數接受一個object作為***個參數,第二個參數是可選的,接受一個函數。capture stack trace 捕獲當前堆棧跟蹤,并在目標對象中創建一個stack屬性來存儲它。如果提供了第二個參數,則傳遞的函數將被視為調用堆棧的終點,因此堆棧跟蹤將僅顯示調用該函數之前發生的調用。
讓我們用例子來說明這一點。首先,我們將捕獲當前堆棧跟蹤并將其存儲在公共對象中。
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)
不知道你注意到沒,我們首先調用了a(a入棧),然后我們a中又調用了b(b入棧且在a之上)。然后在b中我們捕獲了當前堆棧記錄并將其存儲在myObj中。因此在控制臺中才會按照b a的順序打印堆棧。
現在讓我們給Error.captureStackTrace傳遞一個函數作為第二個參數,看看會發生什么:
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)
當把b傳給Error.captureStackTraceFunction時,它隱藏了b本身以及它之后所有的調用幀。因此控制臺僅僅打印出一個a。
至此你應該會問自己:“這到底有什么用?”。這非常有用,因為你可以用它來隱藏與用戶無關的內部實現細節。在Chai中,我們使用它來避免向用戶顯示我們是如何實施檢查和斷言本身的不相關的細節。
操作堆棧追蹤實戰
正如我在上一節中提到的,Chai使用堆棧操作技術使堆棧跟蹤更加與我們的用戶相關。下面將揭曉我們是如何做到的。
首先,讓我們來看看當斷言失敗時拋出的AssertionError的構造函數:
// `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捕獲堆棧追蹤并將它存儲在我們正在創建的AssertError實例中(如果存在的話),然后我們將一個起始堆棧函數傳遞給它,以便從堆棧跟蹤中刪除不相關的調用幀,它只顯示Chai的內部實現細節,最終使堆棧變得清晰明了。
現在讓我們來看看@meeber在這個令人驚嘆的PR中提交的代碼。
在你開始看下面的代碼之前,我必須告訴你addChainableMethod方法是干啥的。它將傳遞給它的鏈式方法添加到斷言上,它也用包含斷言的方法標記斷言本身,并將其保存在變量ssfi(啟動堆棧函數指示符)中。這也就意味著當前斷言將會是堆棧中的***一個調用幀,因此我們不會在堆棧中顯示Chai中的任何進一步的內部方法。我沒有添加整個代碼,因為它做了很多事情,有點棘手,但如果你想讀它,點我閱讀。
下面的這個代碼片段中,我們有一個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);
在上面的代碼片段中,我突出強調了與我們現在相關的代碼。讓我們從調用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方法負責檢查斷言布爾表達式是否通過。如果不通過,我們則實例化一個AssertionError。不知道你注意到沒,在實例化AssertionError時,我們也給它傳遞了一個堆棧追蹤函數指示器(ssfi),如果配置的includeStack處于開啟狀態,我們通過將this.assert本身傳遞給它來為用戶顯示整個堆棧跟蹤。反之,我們則只顯示ssfi標記中存儲的內容,隱藏掉堆棧跟蹤中更多的內部實現細節。
現在讓我們來討論下一行和我們相關的代碼吧:
`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: 如你所見,我們在創建嵌套斷言時將從ssfi標記中的內容傳遞給了它。這意味著新創建的斷言會使用那個方法作為起始調用幀,從而可以從堆棧追蹤中清除沒有的調用棧。順便也看下Assertion的構造器吧:
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方法,它使用自己的父級方法設置ssfi標志,這意味著它始終處于堆棧的底部,我們可以刪除它之上的所有調用幀。
通過將ssfi傳遞給嵌套斷言,它只檢查我們的對象是否具有長度屬性,我們就可以避免重置我們將要用作起始指標器的調用幀,然后在堆棧中可以看到以前的addChainableMethod。
這可能看起來有點復雜,所以讓我們回顧一下我們想從棧中刪除無用的調用幀時Chai中所發生的事情:
當我們運行斷言時,我們將它自己的方法作為移除堆棧中的下一個調用幀的參考
斷言失敗時,我們會移除所有我們在參考幀之后保存的內部調用幀。
如果存在嵌套的斷言。我們必須依舊使用當前斷言的父方法作為刪除下一個調用幀的參考點,因此我們把當前的ssfi(起始函數指示器)傳遞給我們所創建的斷言,以便它可以保存。
看完上述內容,你們掌握如何深入理解JavaScript錯誤和堆棧追蹤的方法了嗎?如果還想學到更多技能或想了解更多相關內容,歡迎關注億速云行業資訊頻道,感謝各位的閱讀!
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。