深刻理解 JavaScript Errors 和 Stack Traces

封面圖片

譯者注:本文做者是著名 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

Stack是如何工做的

在咱們談到 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原型,用戶能夠擴展其含義,以建立本身的 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語句內嵌在catchfinally塊中:

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);
    }
});

使用 Stack Trace

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中。這就是爲何打印在控制檯上的只有ab,並且是下面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)

當咱們傳遞bError.captureStackTraceFunction裏時,它隱藏了b和在它以上的全部堆棧幀。這就是爲何堆棧路徑裏只有a的緣由。

看到這,你可能會問這樣一個問題:「爲何這是有用的呢?」。它之因此有用,是由於你能夠隱藏全部的內部實現細節,而這些細節其餘開發者調用的時候並不須要知道。例如,在 Chai 中,咱們用這種方法對咱們代碼的調用者屏蔽了不相關的實現細節。

真實場景中的 Stack Trace 處理

正如我在上一節中提到的,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在堆棧中保持可見狀態。

這看起來可能有點複雜,如今咱們從新回顧一下,咱們想要移除沒有用的堆棧幀都作了什麼工做:

  1. 當咱們運行一個 Assertion 時,咱們設置它自己來做爲咱們移除其後面堆棧幀的標記。

  2. 這個 Assertion 開始執行,若是判斷失敗,那麼從剛纔咱們所存儲的那個標記開始,移除其後面全部的內部幀。

  3. 若是有內嵌 Assertion,那麼咱們必需要使用包含當前 Assertion 的方法做爲移除後面堆棧幀的標記,即放到ssfi中。所以咱們要傳遞當前ssfi(起始堆棧函數指示器)到咱們即將要新建立的內嵌 Assertion 中來存儲起來。

最後我仍是強烈建議來閱讀一下 @meeber的評論 來加深對它的理解。


我最近正在寫一本《React.js 小書》,對 React.js 感興趣的童鞋,歡迎指點

相關文章
相關標籤/搜索