深入理解JavaScript Errors和Stack Traces
這次我們聊聊 Errors 和 Stack traces 以及如何熟練地使用它們。
很多同學(xué)并不重視這些細(xì)節(jié),但是這些知識(shí)在你寫(xiě) Testing 和 Error 相關(guān)的 lib 的時(shí)候是非常有用的。使用 Stack traces 可以清理無(wú)用的數(shù)據(jù),讓你關(guān)注真正重要的問(wèn)題。同時(shí),你真正理解 Errors 和它們的屬性到底是什么的時(shí)候,你將會(huì)更有信心的使用它們。
這篇文章在開(kāi)始的時(shí)候看起來(lái)比較簡(jiǎn)單,但當(dāng)你熟練運(yùn)用 Stack trace 以后則會(huì)感到非常復(fù)雜。所以在看難的章節(jié)之前,請(qǐng)確保你理解了前面的內(nèi)容。
一、Stack是如何工作的
在我們談到 Errors 之前,我們必須理解 Stack 是如何工作的。它其實(shí)非常簡(jiǎn)單,但是在開(kāi)始之前了解它也是非常必要的。如果你已經(jīng)知道了這些,可以略過(guò)這一章節(jié)。
每當(dāng)有一個(gè)函數(shù)調(diào)用,就會(huì)將其壓入棧頂。在調(diào)用結(jié)束的時(shí)候再將其從棧頂移出。
這種有趣的數(shù)據(jù)結(jié)構(gòu)叫做“***一個(gè)進(jìn)入的,將會(huì)***個(gè)出去”。這就是廣為所知的 LIFO(后進(jìn)先出)。
舉個(gè)例子,在函數(shù) x 的內(nèi)部調(diào)用了函數(shù) y,這時(shí)棧中就有個(gè)順序先 x 后 y。我再舉另外一個(gè)例子,看下面代碼:
- function c() {
- console.log('c');
- }
- function b() {
- console.log('b');
- c();
- }
- function a() {
- console.log('a');
- b();
- }
- a();
上面的這段代碼,當(dāng)運(yùn)行 a 的時(shí)候,它會(huì)被壓到棧頂。然后,當(dāng) b 在 a 中被調(diào)用的時(shí)候,它會(huì)被繼續(xù)壓入棧頂,當(dāng) c 在 b 中被調(diào)用的時(shí)候,也一樣。
在運(yùn)行 c 的時(shí)候,棧中包含了 a,b,c,并且其順序也是 a,b,c。
當(dāng) c 調(diào)用完畢時(shí),它會(huì)被從棧頂移出,隨后控制流回到 b。當(dāng) b 執(zhí)行完畢后也會(huì)從棧頂移出,控制流交還到 a。***,當(dāng) a 執(zhí)行完畢后也會(huì)從棧中移出。
為了更好的展示這樣一種行為,我們用 console.trace() 來(lái)將 Stack trace 打印到控制臺(tái)上來(lái)。通常我們讀 Stack traces 信息的時(shí)候是從上往下讀的。
- function c() {
- console.log('c');
- console.trace();
- }
- function b() {
- console.log('b');
- c();
- }
- function a() {
- console.log('a');
- b();
- }
- a();
當(dāng)我們?cè)?Node REPL 服務(wù)端執(zhí)行的時(shí)候,會(huì)返回如下:
- 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 中打印出來(lái)的時(shí)候,我看到了 a,b 和 c?,F(xiàn)在,如果在 c 執(zhí)行完畢以后,在 b 中把 Stack trace 打印出來(lái),我們可以看到 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();
下面可以看到,c 已經(jīng)不在棧中了,在其執(zhí)行完以后,從棧中 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)
概括一下:當(dāng)調(diào)用時(shí),壓入棧頂。當(dāng)它執(zhí)行完畢時(shí),被彈出棧,就是這么簡(jiǎn)單。
二、Error 對(duì)象和 Error 處理
當(dāng) Error 發(fā)生的時(shí)候,通常會(huì)拋出一個(gè) Error 對(duì)象。Error 對(duì)象也可以被看做一個(gè) Error 原型,用戶可以擴(kuò)展其含義,以創(chuàng)建自己的 Error 對(duì)象。
Error.prototype 對(duì)象通常包含下面屬性:
- constructor - 一個(gè)錯(cuò)誤實(shí)例原型的構(gòu)造函數(shù)
- message - 錯(cuò)誤信息
- name - 錯(cuò)誤名稱
這幾個(gè)都是標(biāo)準(zhǔn)屬性,有時(shí)不同編譯的環(huán)境會(huì)有其獨(dú)特的屬性。在一些環(huán)境中,例如 Node 和 Firefox,甚至還有 stack 屬性,這里面包含了錯(cuò)誤的 Stack trace。一個(gè) Error 的堆棧追蹤包含了從其構(gòu)造函數(shù)開(kāi)始的所有堆棧幀。
如果你想要學(xué)習(xí)一個(gè) Error 對(duì)象的特殊屬性,我強(qiáng)烈建議你看一下在MDN上的這篇文章。
要拋出一個(gè) Error,你必須使用 throw 關(guān)鍵字。為了 catch 一個(gè)拋出的 Error,你必須把可能拋出 Error 的代碼用 try 塊包起來(lái)。然后緊跟著一個(gè) catch 塊,catch 塊中通常會(huì)接受一個(gè)包含了錯(cuò)誤信息的參數(shù)。
和在 Java 中類似,不論在 try 中是否拋出 Error, JavaScript 中都允許你在 try/catch 塊后面緊跟著一個(gè) finally 塊。不論你在 try 中的操作是否生效,在你操作完以后,都用 finally 來(lái)清理對(duì)象,這是個(gè)編程的好習(xí)慣。
介紹到現(xiàn)在的知識(shí),可能對(duì)于大部分人來(lái)說(shuō),都是已經(jīng)掌握了的,那么現(xiàn)在我們就進(jìn)行更深入一些的吧。
使用 try 塊時(shí),后面可以不跟著 catch 塊,但是必須跟著 finally 塊。所以我們就有三種不同形式的 try 語(yǔ)句:
- try...catch
- try...finally
- try...catch...finally
Try 語(yǔ)句也可以內(nèi)嵌在一個(gè) try 語(yǔ)句中,如:
- try {
- try {
- // 這里拋出的Error,將被下面的catch獲取到
- throw new Error('Nested error.');
- } catch (nestedErr) {
- // 這里會(huì)打印出來(lái)
- console.log('Nested catch');
- }
- } catch (err) {
- console.log('This will not run.');
- }
你也可以把 try 語(yǔ)句內(nèi)嵌在 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.');
- }
- }
這里給出另外一個(gè)重要的提示:你可以拋出非 Error 對(duì)象的值。盡管這看起來(lái)很炫酷,很靈活,但實(shí)際上這個(gè)用法并不好,尤其在一個(gè)開(kāi)發(fā)者改另一個(gè)開(kāi)發(fā)者寫(xiě)的庫(kù)的時(shí)候。因?yàn)檫@樣代碼沒(méi)有一個(gè)標(biāo)準(zhǔn),你不知道其他人會(huì)拋出什么信息。這樣的話,你就不能簡(jiǎn)單的相信拋出的 Error 信息了,因?yàn)橛锌赡芩⒉皇?Error 信息,而是一個(gè)字符串或者一個(gè)數(shù)字。另外這也導(dǎo)致了如果你需要處理 Stack trace 或者其他有意義的元數(shù)據(jù),也將變的很困難。
例如給你下面這段代碼:
- 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);
這段代碼,如果其他人傳遞一個(gè)帶有拋出 Error 對(duì)象的函數(shù)給 runWithoutThrowing 函數(shù)的話,將***運(yùn)行。然而,如果他拋出一個(gè) 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);
可以看到這段代碼中,第二個(gè) console.log 會(huì)告訴你這個(gè) Error 信息是 undefined。這現(xiàn)在看起來(lái)不是很重要,但是如果你需要確定是否這個(gè) Error 中確實(shí)包含某個(gè)屬性,或者用另一種方式處理 Error 的特殊屬性,那你就需要多花很多的功夫了。
另外,當(dāng)拋出一個(gè)非 Error 對(duì)象的值時(shí),你沒(méi)有訪問(wèn) Error 對(duì)象的一些重要的數(shù)據(jù),比如它的堆棧,而這在一些編譯環(huán)境中是一個(gè)非常重要的 Error 對(duì)象屬性。
Error 還可以當(dāng)做其他普通對(duì)象一樣使用,你并不需要拋出它。這就是為什么它通常作為回調(diào)函數(shù)的***個(gè)參數(shù),就像 fs.readdir 函數(shù)這樣:
- const fs = require('fs');
- fs.readdir('/example/i-do-not-exist', function callback(err, dirs) {
- if (err instanceof Error) {
- // 'readdir'將會(huì)拋出一個(gè)異常,因?yàn)槟夸洸淮嬖?nbsp;
- // 我們可以在我們的回調(diào)函數(shù)中使用 Error 對(duì)象
- console.log('Error Message: ' + err.message);
- console.log('See? We can use Errors without using try statements.');
- } else {
- console.log(dirs);
- }
- });
***,你也可以在 promise 被 reject 的時(shí)候使用 Error 對(duì)象,這使得處理 promise reject 變得很簡(jiǎn)單。
- 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);
- }
- });
三、使用 Stack Trace
ok,那么現(xiàn)在,你們所期待的部分來(lái)了:如何使用堆棧追蹤。
這一章專門討論支持 Error.captureStackTrace 的環(huán)境,如:NodeJS。
Error.captureStackTrace 函數(shù)的***個(gè)參數(shù)是一個(gè) object 對(duì)象,第二個(gè)參數(shù)是一個(gè)可選的 function。捕獲堆棧跟蹤所做的是要捕獲當(dāng)前堆棧的路徑(這是顯而易見(jiàn)的),并且在 object 對(duì)象上創(chuàng)建一個(gè) stack 屬性來(lái)存儲(chǔ)它。如果提供了第二個(gè) function 參數(shù),那么這個(gè)被傳遞的函數(shù)將會(huì)被看成是本次堆棧調(diào)用的終點(diǎn),本次堆棧跟蹤只會(huì)展示到這個(gè)函數(shù)被調(diào)用之前。
我們來(lái)用幾個(gè)例子來(lái)更清晰的解釋下。我們將捕獲當(dāng)前堆棧路徑并且將其存儲(chǔ)到一個(gè)普通 object 對(duì)象中。
- const myObj = {};
- function c() {
- }
- function b() {
- // 這里存儲(chǔ)當(dāng)前的堆棧路徑,保存到myObj中
- Error.captureStackTrace(myObj);
- c();
- }
- function a() {
- b();
- }
- // 首先調(diào)用這些函數(shù)
- a();
- // 這里,我們看一下堆棧路徑往 myObj.stack 中存儲(chǔ)了什么
- console.log(myObj.stack);
- // 這里將會(huì)打印如下堆棧信息到控制臺(tái)
- // 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)
我們從上面的例子中可以看到,我們首先調(diào)用了a(a被壓入棧),然后從a的內(nèi)部調(diào)用了b(b被壓入棧,并且在a的上面)。在b中,我們捕獲到了當(dāng)前堆棧路徑并且將其存儲(chǔ)在了 myObj 中。這就是為什么打印在控制臺(tái)上的只有a和b,而且是下面a上面b。
好的,那么現(xiàn)在,我們傳遞第二個(gè)參數(shù)到 Error.captureStackTrace 看看會(huì)發(fā)生什么?
- const myObj = {};
- function d() {
- // 這里存儲(chǔ)當(dāng)前的堆棧路徑,保存到myObj中
- // 這次我們隱藏包含b在內(nèi)的b以后的所有堆棧幀
- Error.captureStackTrace(myObj, b);
- }
- function c() {
- d();
- }
- function b() {
- c();
- }
- function a() {
- b();
- }
- // 首先調(diào)用這些函數(shù)
- a();
- // 這里,我們看一下堆棧路徑往 myObj.stack 中存儲(chǔ)了什么
- console.log(myObj.stack);
- // 這里將會(huì)打印如下堆棧信息到控制臺(tái)
- // 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 里時(shí),它隱藏了 b 和在它以上的所有堆棧幀。這就是為什么堆棧路徑里只有a的原因。
看到這,你可能會(huì)問(wèn)這樣一個(gè)問(wèn)題:“為什么這是有用的呢?”。它之所以有用,是因?yàn)槟憧梢噪[藏所有的內(nèi)部實(shí)現(xiàn)細(xì)節(jié),而這些細(xì)節(jié)其他開(kāi)發(fā)者調(diào)用的時(shí)候并不需要知道。例如,在 Chai 中,我們用這種方法對(duì)我們代碼的調(diào)用者屏蔽了不相關(guān)的實(shí)現(xiàn)細(xì)節(jié)。
四、真實(shí)場(chǎng)景中的 Stack Trace 處理
正如我在上一節(jié)中提到的,Chai 用棧處理技術(shù)使得堆棧路徑和調(diào)用者更加相關(guān),這里是我們?nèi)绾螌?shí)現(xiàn)它的。
首先,讓我們來(lái)看一下當(dāng)一個(gè) Assertion 失敗的時(shí)候,AssertionError 的構(gòu)造函數(shù)做了什么。
- // 'ssfi'代表"起始堆棧函數(shù)",它是移除其他不相關(guān)堆棧幀的起始標(biāo)記
- function AssertionError (message, _props, ssf) {
- var extend = exclude('name', 'message', 'stack', 'constructor', 'toJSON')
- , props = extend(_props || {});
- // 默認(rèn)值
- this.message = message || 'Unspecified AssertionError';
- this.showDiff = false;
- // 從屬性中copy
- for (var key in props) {
- this[key] = props[key];
- }
- // 這里是和我們相關(guān)的
- // 如果提供了起始堆棧函數(shù),那么我們從當(dāng)前堆棧路徑中獲取到,
- // 并且將其傳遞給'captureStackTrace',以保證移除其后的所有幀
- ssfssf = ssf || arguments.callee;
- if (ssf && Error.captureStackTrace) {
- Error.captureStackTrace(this, ssf);
- } else {
- // 如果沒(méi)有提供起始堆棧函數(shù),那么使用原始堆棧
- try {
- throw new Error();
- } catch(e) {
- this.stack = e.stack;
- }
- }
- }
正如你在上面可以看到的,我們使用了 Error.captureStackTrace 來(lái)捕獲堆棧路徑,并且把它存儲(chǔ)在我們所創(chuàng)建的一個(gè) AssertionError 實(shí)例中。然后傳遞了一個(gè)起始堆棧函數(shù)進(jìn)去(用if判斷如果存在則傳遞),這樣就從堆棧路徑中移除掉了不相關(guān)的堆棧幀,不顯示一些內(nèi)部實(shí)現(xiàn)細(xì)節(jié),保證了堆棧信息的“清潔”。
在我們繼續(xù)看下面的代碼之前,我要先告訴你 addChainableMethod 都做了什么。它添加所傳遞的可以被鏈?zhǔn)秸{(diào)用的方法到 Assertion,并且用包含了 Assertion 的方法標(biāo)記 Assertion 本身。用ssfi(表示起始堆棧函數(shù)指示器)這個(gè)名字記錄。這意味著當(dāng)前 Assertion 就是堆棧的***一幀,就是說(shuō)不會(huì)再多顯示任何 Chai 項(xiàng)目中的內(nèi)部實(shí)現(xiàn)細(xì)節(jié)了。我在這里就不多列出來(lái)其整個(gè)代碼了,里面用了很多 trick 的方法,但是如果你想了解更多,可以從 這個(gè)鏈接 里獲取到。
在下面的代碼中,展示了 lengthOf 的 Assertion 的邏輯,它是用來(lái)檢查一個(gè)對(duì)象的確定長(zhǎng)度的。我們希望調(diào)用我們函數(shù)的開(kāi)發(fā)者這樣來(lái)使用: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');
- // 密切關(guān)注這一行
- new Assertion(obj, msg, ssfi, true).to.have.property('length');
- var len = obj.length;
- // 這一行也是相關(guān)的
- 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 方法的代碼:
- function assertLength (n, msg) {
- if (msg) flag(this, 'message', msg);
- var obj = flag(this, 'object')
- , ssfi = flag(this, 'ssfi');
- // 密切關(guān)注這一行
- new Assertion(obj, msg, ssfi, true).to.have.property('length');
- var len = obj.length;
- // 這一行也是相關(guān)的
- 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);
assert 方法主要用來(lái)檢查 Assertion 的布爾表達(dá)式是真還是假。如果是假,則我們必須實(shí)例化一個(gè) AssertionError。這里注意,當(dāng)我們實(shí)例化一個(gè) AssertionError 對(duì)象的時(shí)候,我們也傳遞了一個(gè)起始堆棧函數(shù)指示器(ssfi)。如果配置標(biāo)記 includeStack 是打開(kāi)的,我們通過(guò)傳遞一個(gè) this.assert 給調(diào)用者,以向他展示整個(gè)堆棧路徑??墒?,如果 includeStack 配置是關(guān)閉的,我們則必須從堆棧路徑中隱藏內(nèi)部實(shí)現(xiàn)細(xì)節(jié),這就需要用到存儲(chǔ)在 ssfi 中的標(biāo)記了。
ok,那么我們?cè)賮?lái)討論一下其他和我們相關(guān)的代碼:
- new Assertion(obj, msg, ssfi, true).to.have.property('length');
可以看到,當(dāng)創(chuàng)建這個(gè)內(nèi)嵌 Assertion 的時(shí)候,我們傳遞了 ssfi 中已獲取到的內(nèi)容。這意味著,當(dāng)創(chuàng)建一個(gè)新的 Assertion 時(shí),將使用這個(gè)函數(shù)來(lái)作為從堆棧路徑中移除無(wú)用堆棧幀的起始點(diǎn)。順便說(shuō)一下,下面這段代碼是 Assertion 的構(gòu)造函數(shù)。
- function Assertion (obj, msg, ssfi, lockSsfi) {
- // 這是和我們相關(guān)的行
- flag(this, 'ssfi', ssfi || Assertion);
- flag(this, 'lockSsfi', lockSsfi);
- flag(this, 'object', obj);
- flag(this, 'message', msg);
- return util.proxify(this);
- }
還記得我在講述 addChainableMethod 時(shí)說(shuō)的,它用包含他自己的方法設(shè)置的 ssfi 標(biāo)記,這就意味著這是堆棧路徑中***層的內(nèi)部幀,我們可以移除在它之上的所有幀。
回想上面的代碼,內(nèi)嵌 Assertion 用來(lái)判斷對(duì)象是不是有合適的長(zhǎng)度(Length)。傳遞 ssfi 到這個(gè) Assertion 中,要避免重置我們要將其作為起始指示器的堆棧幀,并且使先前的 addChainableMethod 在堆棧中保持可見(jiàn)狀態(tài)。
這看起來(lái)可能有點(diǎn)復(fù)雜,現(xiàn)在我們重新回顧一下,我們想要移除沒(méi)有用的堆棧幀都做了什么工作:
- 當(dāng)我們運(yùn)行一個(gè) Assertion 時(shí),我們?cè)O(shè)置它本身來(lái)作為我們移除其后面堆棧幀的標(biāo)記。
- 這個(gè) Assertion 開(kāi)始執(zhí)行,如果判斷失敗,那么從剛才我們所存儲(chǔ)的那個(gè)標(biāo)記開(kāi)始,移除其后面所有的內(nèi)部幀。
- 如果有內(nèi)嵌 Assertion,那么我們必須要使用包含當(dāng)前 Assertion 的方法作為移除后面堆棧幀的標(biāo)記,即放到 ssfi 中。因此我們要傳遞當(dāng)前 ssfi(起始堆棧函數(shù)指示器)到我們即將要新創(chuàng)建的內(nèi)嵌 Assertion 中來(lái)存儲(chǔ)起來(lái)。
點(diǎn)擊《深入理解 JavaScript Errors 和 Stack Traces》閱讀原文。
【本文是51CTO專欄作者“胡子大哈”的原創(chuàng)文章,轉(zhuǎn)載請(qǐng)聯(lián)系作者本人獲取授權(quán)】