Jason Pan

潘忠显 / 2021-04-20


“JavaScript 工作原理”系列文章是翻译和整理自 SessionStack 网站的 How JavaScript works。因为博文发表于2017年,部分技术或信息可能已经过时。本文英文原文链接,作者 Alexander Zlatkov,翻译 潘忠显

How JavaScript works: exceptions + best practices for synchronous and asynchronous code

Alexander Zlatkov

Alexander ZlatkovFollow

Jan 5 · 9 min read

img

Errors in the browser console

This is post # 20 of the series, dedicated to exploring JavaScript and its building components. In the process of identifying and describing the core elements, we also share some rules of thumb we use when building SessionStack, a JavaScript application that needs to be robust and highly-performant to help companies optimize the digital experience of their users.

If you missed the previous chapters, you can find them here:

Overview

In computing, error detection is a technique that enables a reliable flow during the execution of the program.

One approach to error detection is error checking. This method maintains normal program flow with subsequent explicit checks for anomalies, which are reported using specific return values, an auxiliary global variable, or floating-point status flags.

An exception is the occurrence of an anomaly during the program execution, which interrupts its normal flow. Such an interruption triggers the execution of a pre-registered exception handler.

Exceptions can happen both on the software and the hardware level.

Exceptions in JavaScript

A single JavaScript application may have to run on many different operating systems, browsers + plugins, and devices. No matter how many tests you wrote, supporting such an environment of possibilities will ultimately lead to errors.

From an end-user’s standpoint, JavaScript is dealing with errors by just failing silently. Things are a bit more complicated under the hood.

A JavaScript code throws an exception when a particular statement generates an error. Instead of executing the next code statement, the JavaScript engine checks for the existence of exception handling code.

If no exception handlers have been defined, the engine returns from the function that threw the exception. This process is repeated for each function on the call stack until it finds an exception handler. If no exception handler is found and there are no more functions on the stack, the next function on the callback queue is added to the stack by the event loop.

When an exception occurs, an Error Object is created and thrown.

Types of Error Objects

There are nine types of built-in error objects in JavaScript, which are the foundation for exception handling:

You can also define custom error types by inheriting some of the built-in error types.

Throwing Exceptions

JavaScript allows developers to trigger exceptions via the throw statement.

if (denominator === 0) {
    throw new RangeError("Attempted division by zero");
}

Each of the built-in error objects takes an optional “message” parameter that gives a human-readable description of the error.

It’s important to note that you can throw any type of object as an Exception, such as Numbers, Strings, Arrays, etc.

These are all valid JavaScript statements.

There are benefits to using the built-in error types instead of other objects since some browsers give special treatment to them, such as the name of the file which caused the exception, the line number, and the stack trace. Some browsers, like Firefox, are populating these properties for all types of objects.

Handling Exceptions

Now we will see how to make sure that exceptions don’t crash our apps.

The “try” Clause

JavaScript, quite similar to other programming languages, has the try, catch, finally statements, which gives us control over the flow of exceptions on our code.

Here is a sample:

try {
    // a function that potentially throws an error
    someFunction();
} catch (err) {
    // this code handles exceptions
    console.log(e.message);
} finally {
    // this code will always be executed
    console.log(finally);
}

The try clause is mandatory and wraps a block of code that potentially can throw an error.

The “catch” Clause

It is followed by a catch block, which wraps JavaScript code that handles the error.

The catch clause stops the exception from propagating through the call stack and allows the application flow to continue. The error itself is passed as an argument to the catch clause.

Commonly, some code blocks can throw a different kind of exception, and your application can potentially act differently depending on the exception.

JavaScript exposes the instanceof operator that can be used to differentiate between the types of exceptions:

try {
  If (typeof x !== number) {
       throw new TypeError(x is not a number);
  } else if (x <= 0) {
       throw new RangeError(x should be greater than 0);
  } else {
       // Do something useful
  }
} catch (err) {
    if (err instanceof TypeError) {
      // Handle TypeError exceptions
    } else if (err instanceof RangeError) {
      // Handle RangeError exceptions
  } else {
      // Handle all other types of exceptions
  }
}

It’s a valid case to re-throw an exception that has been caught. For example, if you catch an exception, the type of which is not relevant to you in this context.

The “finally” Clause

The finally code block is executed after the try and catch clauses, regardless of any exceptions. The finallyclause is useful for including clean up code such as closing WebSocket connections or other resources.

Note that the finally block will be executed even if a thrown exception is not caught. In such a scenario, the finally block is executed, and then the engine continues to go through the functions in the call stack in order until the exception is handled properly or the application is terminated.

Also important to note is that the finally block will be executed even if the try or catch blocks execute a return statement.

Let’s look at the following example:

function foo1() {
    try {
        return true;      
    } finally {
        return false;
    }
} 

By invoking the foo1() function, we get false as result, even though the try block has a return statement.

The same applies if we have a return statement in a catch block:

function foo2() {
    try {
        throw new Error();      
    } catch {
        return true;
    } finally {
        return false;
    }
} 

Invoking foo2() also returns false.

Handling Exceptions in Asynchronous Code

We won’t go into detail about the internals of async programming in JavaScript here, but we will see how handling exceptions is done with “callback functions”, “promises”, and “async/await”.

async/await

Let’s define a standard function that just throws an error:

 async function foo() {
     throw new Error();
 }

When an error is thrown in an async function, a rejected promise will be returned with the thrown error, equivalent to:

return Promise.Reject(new Error())

Let’s see what happens when foo() is invoked:

try {
    foo();
} catch(err) {
    // This block won’t be reached.
} finally {
    // This block will be reached before the Promise is rejected.
}

Since foo() is async, it dispatches a Promise. The code does not wait for the async function, so there is no actual error to be caught at the moment. The finally block is executed and then the Promise rejects.

We don’t have any code that handles this rejected Promise.

This can be handled by just adding the await keyword when invoking foo() and wrapping the code in an async function:

async function run() {
    try {
        await foo();
    } catch(err) {
        // This block will be reached now.
    } finally {
        // This block will be reached at the end.
    }
}
 run();

Promises

Let’s define a function that throws an error outside of the Promise:

function foo(x) {
    if (typeof x !== 'number') {
        throw new TypeError('x is not a number');
    }

    return new Promise((resolve, reject) => {
        resolve(x);
    });
}

Now let’s invoke foo with a string instead of a number:

foo(test)
    .then(x => console.log(x))
    .catch(err => console.log(err));

This will result in an Uncaught TypeError: x is not a number since the catch of the promise is not being able to handle an error that was thrown outside of the Promise.

To catch such errors, you need to use the standard try and catch clauses:

try {
    foo(test)
    .then(x => console.log(x))
    .catch(err => console.log(err));
} catch(err) {
    // Now the error is handed
}

If foo is modified to throw an error inside of the Promise:

function foo(x) {
    return new Promise((resolve, reject) => {
       if (typeof x !== 'number') {
            throw new TypeError('x is not a number');
       }
        resolve(x);
    });
}

Now the catch statement of the promise will handle the error:

try {
    foo(test)
    .then(x => console.log(x))
    .catch(err => console.log(err)); // The error is handled here.
} catch(err) {
    // This block is not reached since the thrown error is inside of a Promise.
}

Note that throwing an error inside a Promise is the same thing as using the reject callback. So it’s better to define foo like this:

function foo(x) {
    return new Promise((resolve, reject) => {
       if (typeof x !== 'number') {
            reject('x is not a number');
       }
        resolve(x);
    });
}

If there is no catch method to handle the error inside the Promise, the next function from the callback queue will be added to the stack.

Callback Functions

There are two main rules for working with the error-first callback approach:

  1. The first argument of the callback is for the error object. If an error occurred, it will be returned by the first err argument. If no error occurred, err will be set to null.
  2. The second argument of the callback is the response data.
function asyncFoo(x, callback) {
    // Some async code...
}

asyncFoo(testParam, (err, result) => {
    If (err) {
        // Handle error.
    }
    // Do some other work.
});

If there is an err object, it’s better not to touch or rely on the result parameter.

Dealing with unhandled exceptions

If your application uses third-party libraries, you have no control over how they deal with exceptions. There are cases when you might want to be able to deal with unhandled exceptions.

Browser

Browsers expose a window.onerror event handler that can be used for this purpose.

Here is how you can use it:

window.onerror = (msg, url, line, column, err) => {
     // ... handle error …
     return false;
};

This is what the arguments mean:

When the function returns true, this prevents the firing of the default event handler.

There can be only one event handler assigned to window.onerror because it is a function assignment, and there can only be one function assigned to an event at a time.

This means that if you assign your own window.onerror, you will override any previous handler that might have been assigned by third-party libraries. This can be a huge problem, especially for tools such as error trackers, as they will most likely completely stop working.

You can easily work around this problem by using the following trick:

var oldOnErrorHandler = window.onerror;
window.onerror = (msg, url, line, column, err) => {
    If (oldOnErrorHandler) {
        // Call any previously assigned handler.   
	oldOnErrorHandler.apply(this, arguments);
    }

    // The rest of your code
}

Тhe code above checks if there was a previously defined window.onerror, and simply calls it before proceeding. Using this pattern, you can keep adding additional handlers to window.onerror.

This approach is highly compatible across browsers (it is supported even in IE6).

An alternative, which doesn’t require replacing handlers, is adding an event listener to the window object:

window.addEventListener('error', e => { 
    // Get the error properties from the error event object 
    const { message, filename, lineno, colno, error } = e; 
});

This approach is much better, and also widely supported (from IE9 onwards).

Node.js

The process object from the EventEmmiter module provides two events for handling errors.

  1. uncaughtException — emitted when an uncaught exception bubbles all the way back to the event loop. By default, Node.js handles such exceptions by printing the stack trace to stderr and exiting with code 1. Adding a handler for this event overrides the default behavior. The correct use of the event is to perform synchronous cleanup of allocated resources (e.g. file descriptors, handles, etc) before shutting down the process. It is not safe to resume normal operation afterwards.
  2. unhandledRejection — emitted whenever a Promise is rejected and no error handler is attached to the promise within a turn of the event loop. The unhandledRejection event is useful for detecting and keeping track of promises that were rejected and which rejections have not yet been handled.
process
    .on('unhandledRejection', (reason, promise) => {
         // Handle failed Promise
    })
    .on('uncaughtException', err => {
        // Handle failed Error   
        process.exit(1);
     });

It’s really important that error handling is properly taken care of within your code. It’s equally important to understand unhandled errors so that you can prioritize and work on them accordingly.

You can do this on your own, which can be quite tricky due to the wide variety of browsers, and all of the different cases that need to be taken care of. Alternatively, you can use some third-party tool to do this for you. No matter what option you choose, it’s very important that you have as much information as possible about the error and the user context on how the error was triggered so that you can easily replicate it.

SessionStack is a solution that lets you replay JavaScript errors as if they happened in your browser. You can visually replay the exact user steps that led to the error, see the device, resolution, network, and all of the data that might be needed to connect the dots.

There is a free trial if you’d like to give SessionStack a try.

img

SessionStack replaying an error.

Resources: