Sometimes people don't pay attention to these details, but this knowledge is certainly useful, especially when you are writing a library related to tests or errors. This week, for example, we have an amazing pull request in our Chai, which greatly improves the way we handle stack traces and provides more information when a user asserts a failure.
Deep understanding of JavaScript errors and stack traces
Manipulating stack records allows you to clean up unwanted data and focus on important things. In addition, when you really understand error and its properties, you will be more confident to use it.
The beginning of this article may be too simple, but when you start working with stack records, it will become a little more complex, so make sure you understand the previous section before you start.
How stack calls work
Before we talk about errors, we have to understand how stack calls work. It's very simple, but it's crucial for what we're going to go into. If you already know this section, please feel free to skip this section.
Whenever a function is called, it is pushed to the top of the stack. When the function is completed, it is removed from the top of the stack.
The interesting thing about this data structure is that the last one to stack will be the first one to be removed from the stack, which is the familiar LIFO (last in, first out) feature.
That is to say, we call function y in function x, so the sequence in the corresponding stack is x y.
Suppose you have the following code:
function c() {
console.log('c');
}
function b() {
console.log('b');
C ();
}
function a() {
console.log('a');
B ();
}
A ();
In the above example, when the a function is executed, a will be added to the top of the stack. Then, when the B function is invoked in the a function, B will be added to the top of the stack. By analogy, the same thing will happen if C is invoked in B.
When C executes, the order of the functions in the stack is a B C
After C is executed, it will be removed from the top of the stack. At this time, the control flow will return to B. after B is executed, it will also be removed from the top of the stack. Finally, the control flow will return to A. after a is executed, a will also be removed from the stack.
We can use console. Trace () to better demonstrate this behavior, which will print out the records in the current stack in the console. In addition, generally speaking, you should read the stack record from top to bottom. Think about where each line of code below is called.
function c() {
console.log('c');
console.trace();
}
function b() {
console.log('b');
C ();
}
function a() {
console.log('a');
B ();
}
A ();
Running the above code on the node repl server will get the following results:
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)
As you can see, when we print the stack in C, the records in the stack are a, B, C.
If we print the stack now in B and after C executes, we will find that C has been removed from the top of the stack, leaving only a and B.
function c() {
console.log('c');
}
function b() {
console.log('b');
C ();
console.trace();
}
function a() {
console.log('a');
B ();
}
A ();
As you can see, there is no C in the stack, because it has finished running and has been ejected.
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)
Conclusion: when a method is called, it will be added to the top of the stack. After execution, it will pop out of the stack.
Error object and error handling
When an error occurs in a program, an error object is usually thrown. The error object can also be used as a prototype, and users can extend it and create custom errors.
The error.prototype object usually has the following properties:
Constructor - constructor of the instance prototype.
Message - error message
Name - wrong name
All of the above are standard attributes, (but) sometimes each environment has its own specific attributes. For example, in node, Firefox, chord, edge, ie 10 +, opera and safari 6 +, there is also a stack attribute containing error stack records. The error stack record contains all the stack frames from its own constructor (at the bottom of the stack) to (at the top of the stack).
If you want to learn more about the specific properties of the error object, I highly recommend this article on MDN.
Throwing an error must use the throw keyword. You must wrap the code that may throw an error in the try code block followed by a catch code block to catch the thrown error.
Just like the error handling in Java, it is also allowed in JavaScript that try / catch code block is followed by a finally code block. Whether or not an exception is thrown in the try code block, the code in the finally code block will execute. After processing, the best practice is to do some cleanup in the finally code block, because whether your operation is effective or not, it will not affect its execution.
(considering) everything mentioned above is a piece of cake for most people. Let's talk about some unknown details.
Try code blocks do not have to be followed by catch, but (in this case) they must be followed by finally. This means that we can use three different forms of try statements:
Try... Catch
try...finally
try...catch...finally
Try statements can be nested with each other as follows:
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.');
}
You can even nest try statements in catch and finally blocks of code:
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.');
}
}
It's also important to note that we don't even have to throw an error object. Although this may seem cool and free, it's not, especially for developers who develop third-party libraries, because they have to deal with the code of users (developers who use libraries). Due to the lack of standards, they are not able to control user behavior. You can't trust users to simply throw an error object, because they don't necessarily do that, they just throw a string or a number (who knows what the user will throw). This also makes it more difficult to deal with the necessary stack traces and other meaningful metadata.
Assume the following code:
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);
If your user passes a function that throws an error object to the runwithoutthrowing function like above (thank goodness), but some people always want to be lazy and throw a string directly, then you're in trouble:
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);
Now the second console.log will print out the error's message is undefined. So it doesn't seem that much, but if you need to make sure that some attributes exist on the error object, or handle the specific attributes of the error object in another way (for example, Chai's throws assertion does), then you need to do more to make sure that it will be paid normally.
In addition, when the value thrown is not an error object, you cannot access other important data, such as stack, which is a property of the error object in some environments.
Errors can also be used like any other object without having to be thrown, which is why they are often used as the first parameter of a callback function (commonly known as err first). This is how it is used in the following FS. Readdir () example.
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);
}
};
Finally, the error object can also be used when rejecting projects. This makes it easier to handle project 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);
}
};
Manipulating stack traces
There are so many details above, and the final play is how to manipulate the stack trace.
This chapter is dedicated to environments like nodejs branch error.capturestacktrace.
The error.capturestacktrace function takes an object as the first parameter, the second parameter is optional, and accepts a function. Capture stack trace captures the current stack trace and creates a stack attribute in the target object to store it. If a second argument is provided, the passed function is treated as the end of the call stack, so the stack trace shows only calls that occurred before the function was called.
Let's illustrate this with examples. First, we capture the current stack trace and store it in a common object.
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)
I don't know if you notice, we first call a (a is on the stack), then we call B (B is on the stack and above a) in a. Then in B we capture the current stack record and store it in myobj. Therefore, the stack will be printed in the order of B a in the console.
Now let's pass a function to error.capturestacktrace as the second parameter to see what happens:
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 emitOn
When B is passed to error.capturestacktracefunction, it hides B itself and all subsequent call frames. So the console just prints out an a.
At this point, you should ask yourself, "what's the use of this?". This is useful because you can use it to hide user independent details of your internal implementation. In Chai, we use it to avoid showing users irrelevant details about how we implement checks and assertions themselves.
Operation stack tracking practice
As I mentioned in the previous section, Chai uses stack manipulation technology to make stack traces more relevant to our users. Here's how we did it.
First, let's look at the constructors of the assertionerror thrown when the assertion fails:
// `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;
}
}
}
As you can see, we use error.capturestacktrace to capture the stack trace and store it in the asserterror instance we are creating (if any), and then we pass a start stack function to it, so as to remove irrelevant call frames from the stack trace. It only shows the internal implementation details of Chai, and finally makes the stack clear.
Now let's take a look at the code @ Meeber submitted in this amazing pr.
Before you start looking at the following code, I have to tell you what the addchainablemethod method does. It adds the chain method passed to it to the assertion, and it also marks the assertion itself with the method containing the assertion and stores it in the variable SSFI (start stack function indicator). This means that the current assertion will be the last call frame in the stack, so we will not show any further internal methods in Chai in the stack. I didn't add the whole code because it did a lot of things, it was a bit tricky, but if you want to read it, click me to read it.
In the following code snippet, we have a logic of longof assertion, which checks whether an object has a certain length. We want users to use it like this: 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);
In the code snippet above, I highlighted the code that is relevant to us now. Let's start by calling this. Assert.
Here is the source code of this.assert method:
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'));
}
}
The assert method is responsible for checking whether the assertion Boolean expression passes. If not, we instantiate an assertionerror. I don't know if you noticed that when instantiating assertionerror, we also passed it a stack trace function indicator (SSFI). If the configured includestack is on, we can show the entire stack trace for the user by passing this.assert itself to it. On the contrary, we only show the contents stored in the SSFI tag and hide more internal implementation details in the stack trace.
Now let's talk about the next line of code related to us
The content source of this page is from Internet, which doesn't represent Alibaba Cloud's opinion;
products and services mentioned on that page don't have any relationship with Alibaba Cloud. If the
content of the page makes you feel confusing, please write us an email, we will handle the problem
within 5 days after receiving your email.
If you find any instances of plagiarism from the community, please send an email to:
info-contact@alibabacloud.com
and provide relevant evidence. A staff member will contact you within 5 working days.