深入理解 JavaScript 錯誤和堆疊追蹤

w3ctech發表於2017-04-24

有時候人們並不關注這些細節,但這方面的知識肯定有用,尤其是當你正在編寫與測試或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已經從堆疊的頂部移除,只剩下了ab

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

你甚至還可以在catchfinally程式碼塊中巢狀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);

在上面的程式碼片段中,我突出強調了與我們現在相關的程式碼。讓我們從呼叫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中所發生的事情:

  1. 當我們執行斷言時,我們將它自己的方法作為移除堆疊中的下一個呼叫幀的參考
  1. 斷言失敗時,我們會移除所有我們在參考幀之後儲存的內部呼叫幀。
  1. 如果存在巢狀的斷言。我們必須依舊使用當前斷言的父方法作為刪除下一個呼叫幀的參考點,因此我們把當前的ssfi(起始函式指示器)傳遞給我們所建立的斷言,以便它可以儲存。

如果你想更深入的瞭解它, 我也強烈推薦你閱讀@米貝的評論

保持聯絡

如果你有任何疑問,想法或者不認同我寫的任何內容,你都可以在下面的評論中分享你的想法,或者在twitter)上和我交流。如果我犯了錯誤,我很樂意聽到你要說的話,並做出任何改正。

感謝閱讀!

相關文章