你不知道的 JS 毛病和挪用栈基本知识

本文首发知乎专栏:《前端周刊》。全文共 6988 字,读完需 10 分钟,速读需 3 分钟。经由过程理会 JS 中挪用栈的事情机制,解说毛病抛出、处置惩罚的准确姿态,以及毛病客栈的猎取、清算处置惩罚要领,愿望人人对这个少有人关注但极为有用的知识点能够有所明白和掌握。合适的进修对象是初中级 JS 工程师。

大多数工程师能够并没注重过 JS 中毛病对象、毛病客栈的细节,纵然他们天天的一样平常事情会面对不少的报错,部份同砚甚至在 console 的毛病眼前一脸懵逼,不晓得从何最先排查,如果你对本文解说的内容有体系的相识,就会自在许多。而毛病客栈清算能让你有用去掉噪音信息,聚焦在真正主要的处所,另外,如果明白了 Error 的种种属性究竟是什么,你就能够更好的应用他。

接下来,我们就直奔主题。

挪用栈的事情机制

在讨论 JS 中的毛病之前,我们必需明白挪用栈(Call Stack)的事情机制,实在这个机制异常简朴,如果你对这个已一清二楚了,能够直接跳过这部份内容。

简朴的说:函数被挪用时,就会被加入到挪用栈顶部,实行终了以后,就会从挪用栈顶部移除该函数,这类数据组织的关键在于后进先出,即人人所熟知的 LIFO。比方,当我们在函数 y 内部挪用函数 x 的时刻,挪用栈从下往上的递次就是 y -> x

我们再举个代码实例:

function c() {
    console.log('c');
}

function b() {
    console.log('b');
    c();
}

function a() {
    console.log('a');
    b();
}

a();

这段代码运转时,起首 a 会被加入到挪用栈的顶部,然后,由于 a 内部挪用了 b,紧接着 b 被加入到挪用栈的顶部,当 b 内部挪用 c 的时刻也是相似的。在挪用 c 的时刻,我们的挪用栈从下往上会是如许的递次: a -> b -> c。在 c 实行终了以后,c 被从挪用栈中移除,掌握流回到 b 上,挪用栈会变成:a -> b,然后 b 实行完以后,挪用栈会变成:a,当 a 实行完,也会被从挪用栈移除。

为了更好的申明挪用栈的事情机制,我们对上面的代码稍作修正,运用 console.trace 来把当前的挪用栈输出到 console 中,你能够以为console.trace 打印出来的挪用栈的每一行涌现的原因是它下面的那行挪用而引发的。

function c() {
    console.log('c');
    console.trace();
}

function b() {
    console.log('b');
    c();
}

function a() {
    console.log('a');
    b();
}

a();

当我们在 Node.js 的 REPL 中运转这段代码,会获得以下的效果:

Trace
    at c (repl:3:9)
    at b (repl:3:1)
    at a (repl:3:1)
    at repl:1:1 // <-- 从这行往下的内容能够疏忽,由于这些都是 Node 内部的东西
    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 内部挪用 console.trace 的时刻,挪用栈从下往上的组织是:a -> b -> c。如果把代码再稍作修正,在 bc 实行完以后挪用,以下:

function c() {
    console.log('c');
}

function b() {
    console.log('b');
    c();
    console.trace();
}

function a() {
    console.log('a');
    b();
}

a();

经由过程输出效果能够看到,此时打印的挪用栈从下往上是:a -> b,已没有 c 了,由于 c 实行完以后就从挪用栈移除了。

Trace
    at b (repl:4:9)
    at a (repl:3:1)
    at repl:1:1  // <-- 从这行往下的内容能够疏忽,由于这些都是 Node 内部的东西
    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 对象的 prototype 具有以下属性:

  • constructor – 担任该实例的原型组织函数;

  • message – 毛病信息;

  • name – 毛病的名字;

上面都是规范属性,有些 JS 运转环境还供应了规范属性以外的属性,如 Node.js、Firefox、Chrome、Edge、IE 10、Opera 和 Safari 6+ 中会有 stack 属性,它包含了毛病代码的挪用栈,接下来我们简称毛病客栈毛病客栈包含了发生该毛病时完全的挪用栈信息。如果您想相识更多关于 Error 对象的非规范属性,我猛烈发起你浏览 MDN这篇文章

抛出毛病时,你必需运用 throw 关键字。为了捕捉抛出的毛病,则必需运用 try catch 语句把能够失足的代码块包起来,catch 的时刻能够吸收一个参数,该参数就是被抛出的毛病。与 Java 中相似,JS 中也能够在 try catch 语句以后有 finally,不管前面代码是不是抛出毛病 finally 内里的代码都邑实行,这类言语的罕见用处有:在 finally 中做些清算的事情。

另外,你能够运用没有 catchtry 语句,然则背面必需跟上 finally,这意味着我们能够运用三种差别情势的 try 语句:

  • try ... catch

  • try ... finally

  • try ... catch ... finally

try 语句还能够嵌套在 try 语句中,比方:

try {
    try {
        throw new Error('Nested error.'); // 这里的毛病会被本身紧接着的 catch 捕捉
    } 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 对象的毛病会让你的处境很为难。

如果我们有以下的代码:

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

如果 runWithoutThrowing 的挪用者传入的函数都能抛出 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 funcThatThrowsString() {
    throw 'I am a String.';
}

runWithoutThrowing(funcThatThrowsString);

这段代码运转时,runWithoutThrowing 中的第 2 次 console.log 会抛出毛病,由于 e.message 是未定义的。这些看起来好像没什么大不了的,但如果你的代码须要运用 Error 对象的某些特定属性,那末你就须要做许多分外的事情来确保一切正常。如果你抛出的值不是 Error 对象,你就不会拿到毛病相干的主要信息,比方 stack,虽然这个属性在部份 JS 运转环境中才会有。

Error 对象也能够向其他对象那样运用,你能够不必抛出毛病,而只是把毛病通报出去,Node.js 中的毛病优先回调就是这类做法的典范类型,比方 Node.js 中的 fs.readdir 函数:

const fs = require('fs');

fs.readdir('/example/i-do-not-exist', function callback(err, dirs) {
    if (err) {
        // `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);
    }
});

另外,Error 对象还能够用于 Promise.reject 的时刻,如许能够更轻易的处置惩罚 Promise 失利,比方下面的例子:

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

毛病客栈的裁剪

Node.js 才支撑这个特征,经由过程 Error.captureStackTrace 来完成,Error.captureStackTrace 吸收一个 object 作为第 1 个参数,以及可选的 function 作为第 2 个参数。其作用是捕捉当前的挪用栈并对其举行裁剪,捕捉到的挪用栈会记录在第 1 个参数的 stack 属性上,裁剪的参照点是第 2 个参数,也就是说,此函数之前的挪用会被记录到挪用栈上面,而以后的不会。

让我们用代码来申明,起首,把当前的挪用栈捕捉并放到 myObj 上:

const myObj = {};

function c() {
}

function b() {
    // 把当前挪用栈写到 myObj 上
    Error.captureStackTrace(myObj);
    c();
}

function a() {
    b();
}

// 挪用函数 a
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 -> b,由于我们在 b 挪用 c 之前就捕捉了挪用栈。如今对上面的代码稍作修正,然后看看会发作什么:

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)

在这段代码内里,由于我们在挪用 Error.captureStackTrace 的时刻传入了 b,如许 b 以后的挪用栈都邑被隐蔽。

如今你能够会问,晓得这些究竟有啥用?如果你想对用户隐蔽跟他营业无关的毛病客栈(比方某个库的内部完成)就能够试用这个技能。

总结

经由过程本文的形貌,相信你对 JS 中的挪用栈、Error 对象、毛病客栈有了清楚的熟悉,在碰到毛病的时刻不在忙乱。如果对文中的内容有任何疑问,迎接在下面批评。想晓得这个人接下来会写些什么?迎接定阅我的知乎专栏:《前端周刊》

Happy Hacking

    原文作者:wangshijun
    原文地址: https://segmentfault.com/a/1190000008621990
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞