• How JavaScript works: Event loop and the rise of Async programming + 5 ways to better coding with Async /await
  • Originally written by Alexander Zlatkov
  • The Nuggets translation Project
  • Permanent link to this article: github.com/xitu/gold-m…
  • Translator: Spring Snow
  • Proofreader: AthenA0304tvchan

Welcome to the fourth article in a series exploring JavaScript and its core elements. In identifying and describing these core elements, we will also share some rules of thumb that we follow when building SessionStack. A JavaScript application should remain robust and high-performance to remain competitive.

If you missed the first three chapters you can find them here:

  1. An overview of the engine, runtime, and call stack
  2. Dive into the V8 engine and five tips for writing better code
  3. Memory management and four common solutions to memory leaks

This time we’ll expand on the first article, reviewing the drawbacks of programming in a single-threaded environment and how to overcome them to build a great JavaScript UI. As usual, at the end of this article we will share 5 tips on how to write more concise code using async/await.

Why does a single thread limit us?

In the first article, we thought about what happens when a function call on the call stack takes us a lot of time.

For example, imagine that your browser is running a complex image conversion algorithm.

When the call stack has a function executing, the browser can’t do anything — it’s blocked. This means that the browser can’t render the page, can’t run any other code, and it just gets stuck. Here’s the problem — your app is no longer efficient and satisfying.

Your application is stuck.

In some cases, this may not be a serious problem. But this is actually a much bigger problem. Once your browser starts running lots and lots of tasks on the call stack, it’s likely to go unanswered for a long time. At this point, most browsers will take a solution that throws an error and asks you if you want to terminate the page:

It’s ugly, and it ruins your user experience:

The unit block of a JavaScript program

You might write your JavaScript code in a.js file, but your program must be made up of several code blocks, only one of which can be executed now, and the rest will be executed later. The most common unit block is a function.

One of the biggest things new JavaScript developers don’t understand is that later code doesn’t necessarily execute after the current code executes. In other words, tasks that cannot be completed immediately by definition will be executed asynchronously, which means that the blocking problem mentioned above may not occur as you would expect.

Let’s take a look at the following example:

// ajax(..) Is any Ajax function provided by any library
var response = ajax('https://example.com/api');

console.log(response);
// 'response' will not be the response of the response because Ajax is asynchronous
Copy the code

As you’re probably aware, standard Ajax requests don’t happen synchronously, which means that Ajax (..) The function does not assign to the response variable until it returns anything.

An easy way to “wait” for an asynchronous function to return its result is to use the callback function:

ajax('https://example.com/api'.function(response) {
    console.log(response); // 'response' now has a value
});
Copy the code

Note: While it is actually possible to implement Ajax requests synchronously, it is best never to do so. If you use synchronous Ajax requests, your JavaScript application will block — the user can’t click, enter data, navigate, or scroll. This will prevent any user interaction. This is a very bad practice.

This is what syncing looks like, but don’t do it and ruin your Web application:

// Assume you are using jQuery
jQuery.ajax({
    url: 'https://api.example.com/endpoint'.success: function(response) {
        // This is your callback
    },
    async: false // This is a bad idea
});
Copy the code

Our use of Ajax requests is just one example. You can actually execute any code asynchronously.

SetTimeout (callback, milliseconds) can also be executed asynchronously. What the setTimeout function does is set an event (timeout) to be triggered. Take a look:

function first() {
    console.log('first');
}
function second() {
    console.log('second');
}
function third() {
    console.log('third');
}
first();
setTimeout(second, 1000); // call 'second' after 1000ms
third();
Copy the code

The console will print something like this:

first
third
second
Copy the code

Parsing event loop

Let’s start with the odd statement that while JavaScript allows asynchronous code (like setTimeout), until ES6, JavaScript itself never had any direct concept of asynchracy. The JavaScript engine only executes one program at any one time.

See our previous chapter for more details on how JavaScript engines work (especially V8 engines).

So, who tells the JS engine to execute your program? In fact, the JS engine doesn’t run on its own — it runs in a host environment, which for most developers is a typical browser and Node.js. In fact, JavaScript is now used on everything from robots to light bulbs. Each device represents a host environment for a different type of JS engine.

One thing all environments have in common is a built-in mechanism for an event loop that calls the JS engine each time over time to handle the execution of multiple blocks in the program.

This means that the JS engine is just an environment where arbitrary JS code is executed on demand. It is the environment around it that schedules these events (JS code execution).

So, for example, when your JavaScript program makes an Ajax request to fetch data from the server, you write “response” in a function (callback), and the JS engine tells the host environment: “Hey, I’m going to pause execution now, but when you complete the network request and get the data, come back and call this function.”

The browser then sets up to listen for network responses, and when it gets something back to you, it will insert the callback into the event loop queue and execute it.

Let’s look at the picture below:

You can learn more about memory heaps and call stacks in the previous chapter.

What are these Web apis in the picture? Essentially, they’re threads that you can’t access, but you can call them. They are part of the browser’s parallel startup. If you’re a node.js developer, these are some of the C++ apis.

What exactly is the cycle of events?

The event loop has a simple task — to monitor the call stack and callback queue. If the call stack is empty, it fetches the first event in the queue, pushes it into the call stack, and then runs it.

Such an iteration is called a tick in the event loop. Each event is a callback function.

console.log('Hi');
setTimeout(function cb1() { 
    console.log('cb1');
}, 5000);
console.log('Bye');
Copy the code

Let’s execute this code and see what happens:

  1. The state is clean. The browser console is clean and the call stack is empty.

  1. console.log('Hi')Added to the call stack.

  1. console.log('Hi')Be implemented.

  1. console.log('Hi')Removed from the call stack.

  1. setTimeout(function cb1() { ... })Added to the call stack.

  1. setTimeout(function cb1() { ... })The execution. The browser creates a timer (part of the Web API) and starts counting down.

  1. setTimeout(function cb1() { ... })It completes itself and is then removed from the call stack.

  1. console.log('Bye')Added to the call stack.

  1. console.log('Bye')The execution.

  1. console.log('Bye')Removed from the call stack.

  1. After at least 5000ms, the timer completes and then the callback will be madecb1Push into the callback queue.

  1. The event loop is fetched from the callback queuecb1And push it onto the call stack.

  1. cb1Be executed and then putconsole.log('cb1')Push the call stack.

  1. console.log('cb1')Be implemented.

  1. console.log('cb1')Removed from the call stack.

  1. cb1Removed from the call stack.

A quick review:

Interestingly, ES6 specifies how the event loop should work, which means that it is technically within the responsibility of the JS engine and is no longer part of the host environment. One of the main reasons for this change is the introduction of Promises in ES6, which requires direct, subtle control over scheduling operations on event loop queues (we’ll discuss them in more detail later).

SetTimeout (…). How does it work

SetTimeout (…) Your callbacks are not automatically placed in the event loop queue. It sets a timer. When the timer expires, the host environment places your callback in the event loop queue so that it can be picked up and executed later in the loop. Look at the following code:

setTimeout(myCallback, 1000);
Copy the code

This does not mean that myCallback will be executed after 1,000ms; rather, it will be added to the event queue after 1,000ms. However, this queue may have some events added earlier — your callback will be waiting to be executed.

There are many articles and tutorials that introduce asynchronous code starting with setTimeout(callback, 0). Ok, now you know what the event loop does and how setTimeout works: Calling setTimeout with the second argument being 0 delays the callback until the call stack is empty.

Take a look at the following code:

console.log('Hi');
setTimeout(function() {
    console.log('callback');
}, 0);
console.log('Bye');
Copy the code

Although the waiting event is set to 0, the result of the browser console will look like this:

Hi
Bye
callback
Copy the code

What are Jobs in ES6?

ES6 introduces a new concept called Job Queue. It is a layer above the event loop queue. You’ll most likely encounter Promises when dealing with asynchrony (we’ll talk about them later).

Let’s briefly introduce this concept now so that when we talk about Promises’ asynchronous behaviors, you can understand how these behaviors are scheduled and handled.

Imagine that a job queue is a queue that follows the end of each tick in the event queue. Some asynchronous operations may occur during a tick in the event loop queue, which does not result in an entire new event being added to the event loop queue, but rather an item (that is, a job) being added to the end of the job queue for the current tick.

This means that you can add a feature that will be executed later, and you can rest assured that it will be executed before anything else.

Jobs also enable more jobs to be added to the end of the same queue. In theory, a “loop” of a job (a job that keeps adding other jobs, etc.) could loop indefinitely, depleting the necessary resources for the program to tick through the next event loop. Conceptually, this is the same as if you were writing long-running code or an infinite loop (like while (true)).

Jobs are a bit like “hacks” for setTimeout(callback, 0), but they introduce a more explicit and guaranteed order of execution: execute later, but as soon as possible.

The callback

As we all know, callbacks are by far the most common way to express and manage asynchrony in JavaScript programs. Indeed, callbacks are the most basic asynchronous pattern in JavaScript. Countless JS programs, even very complex ones, use callbacks as the basis for asynchrony.

Callbacks are not without their drawbacks. Many developers are trying to find better asynchronous patterns. However, you can’t use any abstraction effectively if you don’t understand the underlying reality.

In the next chapter, we’ll dig deeper into these abstractions to show why more complex asynchronous patterns (to be discussed in a future post) are necessary and even recommended.

Nested callbacks

Look at the following code:

listen('click'.function (e){
    setTimeout(function(){
        ajax('https://api.example.com/endpoint'.function (text){
            if (text == "hello") {
	        doSomething();
	    }
	    else if (text == "world") { doSomethingElse(); }}); },500);
});
Copy the code

We have a chain of three functions nested together, each step representing a step in an asynchronous sequence.

This code is called callback hell. But “callback hell” apparently has nothing to do with nesting/indentation. This is a deeper problem.

First, we’re waiting for a “click” event, then we’re waiting for the timer to fire, and then we’re waiting for the Ajax response to return, at which point it might be repeated again.

At first glance, this code seems to decompose into successive steps:

listen('click'.function (e) {
	// ..
});
Copy the code

And then:


setTimeout(function(){
    // ..
}, 500);
Copy the code

And then:

ajax('https://api.example.com/endpoint'.function (text){
    // ..
});
Copy the code

Finally:

if (text == "hello") {
    doSomething();
}
else if (text == "world") {
    doSomethingElse();
}
Copy the code

So does it seem more natural to express your asynchronous code in such a sequential way? There’s got to be a way to do that, right?

Promises

Look at the following code:

var x = 1;
var y = 2;
console.log(x + y);
Copy the code

This is a simple piece of code: it sums x and y, and then prints it out on the console. But what if the value of x or y is to be determined? For example, we need to go to the server to retrieve the values of x and y before using them. Then, there are two functions loadX and loadY, which get the values of x and y from the server, respectively. Finally, sum is used to add up the values of x and y.

That’s what it looks like (pretty ugly, isn’t it?). :

function sum(getX, getY, callback) {
    var x, y;
    getX(function(result) {
        x = result;
        if(y ! = =undefined) { callback(x + y); }}); getY(function(result) {
        y = result;
        if(x ! = =undefined) { callback(x + y); }}); }// a synchronous or asynchronous function that gets the value of 'x'
function fetchX() {
    // ..
}


// a synchronous or asynchronous function that gets the value of 'y'
function fetchY() {
    // ..
}
sum(fetchX, fetchY, function(result) {
    console.log(result);
});
Copy the code

The key point here is — in this code, x and y are future values, and we also write sum(…). Function, and from the outside it doesn’t care if x or y is available right now.

Of course, this callback-based approach is crude and has many drawbacks. This is just the first step toward understanding future values and not having to worry about when they will be available.

Promise value

Let’s look at this brief example of how to express X + Y in Promises:

function sum(xPromise, yPromise) {
	// `Promise.all([ .. ] ) 'Accept an array of Promises,
	// And return a new Promise object to wait for them
	// All done
	return Promise.all([xPromise, yPromise])

	// When the promise is completed, we can get it
	// the values of 'X' and 'Y' and calculate them
	.then(function(values){
		// 'values' is a promise from the previous completion
		// Message array
		return values[0] + values[1]; }); }FetchX () and fetchY() return promises, have their own values
// value, maybe * now * is ready
// It may take * a while *.
sum(fetchX(), fetchY())

// We got this from the returned promise
// The sum of two numbers.
// Now we continuously call 'then(...) 'To wait for what has been done
/ / promise.
.then(function(sum){
    console.log(sum);
});
Copy the code

This code can see two layers of Promises.

FetchX () and fetchY() are called directly, and their return values (promises!) Are passed to the sum (…). . These promises stand for values that may be ready now or in the future. But each promise’s own norms are the same. We interpret the values of x and y in a time-independent way. They are future values for some time.

The second promise is sum(…). Create (through promise.all ([…] ) and return, we call then(…) To wait for the return. When the sum (…). When the operation is complete, the sum of the future values is ready to be printed out. We hide in sum(…) The logic inside the function that waits for future values of x and y.

Note: in sum(…) Function, promise.all ([…] Creates a promise that waits for the completion of promiseX and promiseY. Chain call.then(…) To create another promise that returns values[0] + values[1] and executes immediately (plus the result of the addition). Therefore, we have sum(…) Then (…) added after the call. — At the end of the code above — actually executes after the second promise returns, not the first promise.all ([…] ) created a promise. And, although we don’t have the second then(…) We do the chain call later, but it also creates a promise that we can observe or use. The chain calls for promises are explained in more detail below.

Promises, then(…) The first method is called when it has completed (as we used earlier), and the other method is called when it has failed:

sum(fetchX(), fetchY())
.then(
    / / is complete
    function(sum) {
        console.log( sum );
    },
    / / failure
    function(err) {
    	console.error( err ); // bummer!});Copy the code

If I made an error in getting x or y, or I failed in adding, sum(…) The returned promise will be a failed state and will pass the failed promise value to then(…). The second callback processing.

Because Promises encapsulate a time-dependent state — waiting for an internal value to complete or fail — Promises are time-independent from the outside, so Promises can be combined in a predictable way, without regard to underlying timings or outcomes.

Moreover, once a Promise’s state is established, it never changes state — at which point it becomes an immutable value — and can then be observed as many times as necessary.

Promises in chained form are very useful:

function delay(time) {
    return new Promise(function(resolve, reject){
        setTimeout(resolve, time);
    });
}

delay(1000)
.then(function(){
    console.log("after 1000ms");
    return delay(2000);
})
.then(function(){
    console.log("after another 2000ms");
})
.then(function(){
    console.log("step 4 (next Job)");
    return delay(5000);
})
// ...
Copy the code

Calling Delay (2000) creates a promise that completes at 2000ms, and we return the first then(…). This results in the second then(…) The promise will wait for another 2000ms to execute.

Note: Because promises can’t change their state once completed, they can safely be passed anywhere because they can’t be changed accidentally or maliciously. This is especially true for solutions that listen on promises in multiple places. It is impossible for one side to influence the outcome of what the other side is monitoring. Immutability may sound like an academic topic, but it is the most fundamental and important aspect of Promise design and should not be ignored.

Do you use Promise?

One of the most important things about Promises is to make sure that some values are real Promises. In other words, does it have a value like a Promise?

Promises are made by new Promise. Statement, you might think that p instanceof Promise would tell you a Promise. Well, not really.

Mainly because another browser window (such as iframe) takes a Promise value, has its own Promise class, and is different from current or other Windows, it is not accurate to use instance to distinguish promises.

Furthermore, a framework or library can choose its own Promise instead of using ES6’s native Promise implementation. In fact, you’ll probably use third-party Promise libraries in older browsers that don’t support Promises.

Devour abnormal

If a JavaScript exception error, such as TypeError or ReferenceError, is thrown during any process of creating a Promise or observing its results, the exception will be caught and it will change the state of the Promise to failed.

Such as:

var p = new Promise(function(resolve, reject){
    foo.bar();	  // Sorry, 'foo' is not defined
    resolve(374); // will not execute :(
});

p.then(
    function fulfilled(){
        // will not execute :(
    },
    function rejected(err){
        // 'err' is the line 'foo.bar()'
	// Raised 'TypeError' exception object.});Copy the code

If a Promise has ended, but is listening for results (then(…)) What happens when a JS exception occurs when the callback function in the. Even if the error isn’t lost, you might be a little surprised at how it’s handled. Unless you dig a little deeper:

var p = new Promise( function(resolve,reject){
	resolve(374);
});

p.then(functionfulfilled(message){ foo.bar(); console.log(message); // do not execute},functionRejected (err){// Does not execute});Copy the code

It looks like the exception in foo.bar() has really been swallowed. Of course, the anomaly was not swallowed. This is a deeper problem. We’re not listening for exceptions. P.t hen (…). Calling it itself returns another promise, which becomes a failed state due to TypeError exceptions.

Handle uncaught exceptions

There are better ways to solve this problem.

The most common is to add done to a Promise. Used to mark the end of the Promise chain. Done (…). Will not create or return a Promise, so pass it to Done (..) The callback to is obviously not going to report the problem to a nonexistent Promise.

If no exception is caught, this is probably what you’d expect: done(..) Any exception in a failed handler throws a global uncaught exception (usually in the developer’s console).

var p = Promise.resolve(374);

p.then(function fulfilled(msg){
    // Numbers do not have string methods,
    // So an error is thrown
    console.log(msg.toLowerCase());
})
.done(null.function() {
    // If an exception occurs, it will be thrown globally
});
Copy the code

What happened to the ES8? Async/await

JavaScript ES8 introduces Async /await, making Promises easier to use. We will briefly describe what async/await gives us and how to use them to write asynchronous code.

So let’s see how async/await works.

Use async function declarations to define an asynchronous function. Such a function returns an AsyncFunction object. The AsyncFunction object represents an asynchronous function that executes the code contained in this function.

When an async function is called, it returns a Promise. When async function returns a value, it is not a Promise, the Promise will be created automatically, and it uses the return value of the function to determine the status. When async throws an exception, Promise uses the thrown value to enter the failed state.

An async function can contain an await expression that suspends execution of the function and waits for a Promise to complete, then resumes execution of the async function and returns the successful value.

You can think of JavaScript promises as Java futures or C# tasks.

The purpose of async/await is to make it easier to write promises.

Let’s take a look at the following example:


// a standard JavaScript function
function getNumber1() {
    return Promise.resolve('374');
}
This function does the same thing as getNumber1
async function getNumber2() {
    return 374;
}
Copy the code

Similarly, a function that throws an exception returns promises that have failed:

function f1() {
    return Promise.reject('Some error');
}
async function f2() {
    throw 'Some error';
}
Copy the code

The keyword await can only be used in async functions and allows you to wait for a Promise synchronously. If we use promises outside of the async function, we still use the then callback:

async function loadData() {
    // 'rp' is a request asynchronous function
    var promise1 = rp('https://api.example.com/endpoint1');
    var promise2 = rp('https://api.example.com/endpoint2');
   
    // Now both requests are triggered,
    // We wait for them to finish.
    var response1 = await promise1;
    var response2 = await promise2;
    return response1 + ' ' + response2;
}
// But if we are not in 'async function'
// We must use 'then'.
loadData().then((a)= > console.log('Done'));
Copy the code

You can also create an async function using the async function expression method. Async expressions are written in much the same way as async declarations. The main difference between a function expression and a function declaration is the function name, which can be omitted in async function expressions to create an anonymous function. An async function expression can be used as an IIFE (immediate execution function) and is executed when it is defined.

It looks like this:

var loadData = async function() {
    // 'rp' is a request asynchronous function
    var promise1 = rp('https://api.example.com/endpoint1');
    var promise2 = rp('https://api.example.com/endpoint2');
   
    // Now both requests are triggered,
    // We wait for them to finish.
    var response1 = await promise1;
    var response2 = await promise2;
    return response1 + ' ' + response2;
}
Copy the code

More importantly, all major browsers support async/await:

If this compatibility is not what you want, then JS converters like Babel and TypeScript can also be used.

Finally, it’s important not to blindly choose the “latest” way to write asynchronous code. It’s more important to understand the internals of asynchronous JavaScript, why it’s so important, and to understand the internals of the method you choose. Each method has its pros and cons in the program.

Five tips for writing maintainable, robust asynchronous code

  1. Clean code: Using async/await lets you write less code. Every time you use async/await you can skip some unnecessary steps: write a.then, create an anonymous function to handle the response, name the response in the callback, for example:
// 'rp' is a request asynchronous functionThe rp (' HTTPS://api.example.com/endpoint1').then(function(data) {
 / /...
});
Copy the code

Contrast:

// 'rp' is a request asynchronous function
var response = awaitThe rp (' HTTPS://api.example.com/endpoint1');
Copy the code
  1. Error handling: Async/await allows us to use the same code structure to handle synchronous or asynchronous errors — famous try/catch statements. Let’s see how Promises are made:

Contrast:

async function loadData() {
    try {
        var data = JSON.parse(await getJSON());
        console.log(data);
    } catch(e) {
        console.log(e); }}Copy the code
  1. Conditional statement:useasync/awaitIt’s much easier to write conditional statements:
function loadData() {
  return getJSON()
    .then(function(response) {
      if (response.needsAnotherRequest) {
        return makeAnotherRequest(response)
          .then(function(anotherResponse) {
            console.log(anotherResponse)
            return anotherResponse
          })
      } else {
        console.log(response)
        return response
      }
    })
}
Copy the code

Contrast:

async function loadData() {
  var response = await getJSON();
  if (response.needsAnotherRequest) {
    var anotherResponse = await makeAnotherRequest(response);
    console.log(anotherResponse)
    return anotherResponse
  } else {
    console.log(response);
    returnresponse; }}Copy the code
  1. The stack frame:async/awaitThe difference is that you can’t tell what went wrong based on the error stack information returned by the Promise chain. Take a look at the following code:
function loadData() {
  return callAPromise()
    .then(callback1)
    .then(callback2)
    .then(callback3)
    .then((a)= > {
      throw new Error("boom");
    })
}
loadData()
  .catch(function(e) {
    console.log(err);
// Error: boom at callAPromise.then.then.then.then (index.js:8:13)
});
Copy the code

Contrast:

async function loadData() {
  await callAPromise1()
  await callAPromise2()
  await callAPromise3()
  await callAPromise4()
  await callAPromise5()
  throw new Error("boom");
}
loadData()
  .catch(function(e) {
    console.log(err);
    / / output
    // Error: boom at loadData (index.js:7:9)
});
Copy the code
  1. Debug:If you use Promises, you know that debugging promises is going to be a nightmare. For example, if you make a breakpoint in.then and use a debug shortcut like “stop-over”, the debugger will not move to the next.then because it will only work on synchronized code. And byasync/awaitYou can debug await calls step by step, like a synchronization function.

Writing asynchronous JavaScript code is important not only for the application itself but also for the library.

For example, the SessionStack logs everything in a Web application, Web site: all DOM changes, user interactions, JavaScript exceptions, stack tracking, network request failures, and debug messages.

All of this happens in your production environment without affecting your user experience. We need to optimize our code a lot to make it as asynchronous as possible so that we can increase the number of events handled by the event loop.

And it’s not just a library! When you want to restore a user’s session in the SessionStack, we have to reproduce all the problems that occurred on the user’s browser, we have to reproduce the entire state, allowing you to jump back and forth on the event axis of the session. To do this, we make extensive use of asynchronous operations provided by JavaScript.

We have a free program that lets you get started for free.

More resources:

  • Github.com/getify/You-…
  • Github.com/getify/You-…
  • Nikgrozev.com/2017/10/01/…

The Nuggets Translation Project is a community that translates quality Internet technical articles from English sharing articles on nuggets. The content covers Android, iOS, React, front-end, back-end, product, design and other fields. If you want to see more high-quality translation, please continue to pay attention to the Project, official Weibo, Zhihu column.