Javascript asynchronous programming hyper-evolution

Js is a single-threaded language, and this was decided upon from the beginning of js design, and will not change in the future. So JS doesn’t have the same synchronous mutex problem as multithreading. But single threading also means you can only do one thing at a time. If some task takes too long, the entire application will be stopped until the task is complete. To solve this problem, JS introduced asynchronous programming.

Asynchronous programming can be used to process I/O operations that cannot obtain immediate results, such as network requests and file reads and writes, ensuring that applications are not stuck. As history rolls on, asynchronous programming has evolved, with callbacks, promises, Generator, Async/Await, and now it is possible to write asynchronous code like synchronous code.

Synchronous vs Asynchronous

Synchronization is when you finish one task and then move on to the next. It’s linear, so it’s easy to understand. Asynchronism, on the other hand, involves breaking up a task into parts, executing one part, then the other, and then coming back to the rest when the time is right. It’s this discontinuous execution that makes writing asynchronous code so difficult.

Take the following code for example:

function syncTask() {
  console.log("start sync task");
  console.log("end sync task");
}

function asyncTask() {
  console.log("start async task");
  setTimeout(() = > {
    console.log("end async task");
  }, 1000);
}

function doSomething(fn) {
  console.log("start");
  fn();
  console.log("end");
}

// Synchronize tasks
doSomething(syncTask);
// start
// start sync task
// end sync task
// end

// Asynchronous tasks
doSomething(asyncTask);
// start
// start async task
// end
// end async task
Copy the code

If fn is passed in as a synchronous task, the second console.log in doSomething will not execute until it finishes executing, whereas if FN is an asynchronous task, console.log will not wait for the asynchronous code to finish executing.

The callback function

Callbacks are the most basic and primitive method of asynchronous operations. Take sending requests as an example:

const fetchData = (url, callback) = > {
  const xhr = new XMLHttpRequest();

  xhr.onreadystatechange = () = > {
    if (xhr.readyState === 4 && (xhr.status === 200 || xhr.status === 204)) { callback(xhr.response); }}; xhr.open("get", url, true);
  xhr.send();
};

fetchData("/api/example".(data) = > {
  console.log("result: ", data);
});
Copy the code

We encapsulate the XMLHttpreque-related configuration in the fetchData function and pass in a callback function to process the response result. This approach splits the processing logic in several different places, which is hard to understand, but has the benefit of simplicity.

The callback function has the fatal drawback of creating callback hell. If you need to send a request/API /example-a, then send a request/API /example-b based on the result, then send a request/API /example-c based on the result, then send a request/API /example-c based on the result. The final result is what we want, and the code looks like:

fetchData("/api/example-a".(a) = > {
  fetchData(`/api/example-b? a=${a}`.(b) = > {
    fetchData(`/api/example-c? b=${b}`.(c) = > {
      console.log("result: ", c);
    });
  });
});
Copy the code

You can see that the parts are highly coupled, the structure is chaotic, and it is difficult to catch exceptions using try/catch. It’s a mess.

Promise

Time went on, and with the advent of Promise, asynchronous JS programming took a big step forward. A Promise is a Promise that will return an asynchronous result at some point in the future, we can decide how to use the result, and once the Promise is fulfilled, the state will not change. See Promise objects for more details on how promises are used.

We can Promise the asynchronous operation encapsulated above:

const fetchData = (url) = > {
  return new Promise((resolve) = > {
    const xhr = new XMLHttpRequest();

    xhr.onreadystatechange = () = > {
      if (xhr.readyState === 4 && (xhr.status === 200 || xhr.status === 204)) { resolve(xhr.response); }}; xhr.open("get", url, true);
    xhr.send();
  });
};

fetchData("/api/example").then((data) = > {
  console.log("result: ", data);
});
Copy the code

Of course, modern browsers also use fetch, which is based on promises and is more modern than traditional XMLHttpRequest. Using FETCH makes it easier to complete requests:

fetch("/api/example").then((response) => {
  console.log("response: ", response);
});
Copy the code

Promise also provides an error-catching mechanism to catch exceptions during execution via the then method second argument, or catch method:

const p1 = new Promise((resolve, reject) = > {
  throw Error("error");
}).catch((error) = > {
  console.log("error: ", error);
});
Copy the code

So what’s the difference between a callback and a Promise? For example, if we go to the bank to withdraw some money, we want to buy something after we get the money. So buying something is an asynchronous operation of withdrawing money (after all, bank efficiency……) Operations after completion. Using a Promise means that the bank will tell us to come and get the money when we withdraw it. We can deal with it after we get the money. We can either buy something or decide not to buy it. But using callback functions, we put the shopping this matter entrusted to the bank, the bank after take out money to help us to buy things, but now that is our pass DiaoHan digital back to other functions, so the trigger of our callback function is controlled by other functions, the function may not call our callback, may also be called multiple times, So the bank might buy something we want, or it might buy multiple copies:

// callback
withdrawMoney(buySomeStuffs);
// promise
withdrawMoney().then(buySomeStuffs);
Copy the code

As you can see, Promise reverses control and resolves the trust problem nicely.

In addition, Promise also has chain-calling and value penetration, which is a good way to solve callback hell:

fetchData("/api/example-a")
  .then((a) = > {
    return fetchData(`/api/example-b? a=${a}`);
  })
  .then((b) = > {
    return fetchData(`/api/example-c? b=${b}`);
  })
  .then((c) = > {
    console.log("result: ", c);
  });
Copy the code

Promise.all also enables multiple concurrent requests for data:

Promise.all([fetchData("/api/example-a"), 
             fetchData("/api/example-b"), 
             fetchData("/api/example-c")])
  .then(([a, b, c]) = > {
    console.log("result: ", a, b, c); });Copy the code

Promise is the cornerstone of modern ASYNCHRONOUS JS programming. A series of asynchronous programming advances followed, but promises weren’t perfect either: once triggered, they couldn’t be cancelled, and the chain also scattered logic into the Promise syntax, making it hard to read.

Generator

The ultimate goal is to write asynchronous code as if it were synchronous code. With the help of the Generator, we can almost do this. The most important feature of the Generator is that it can control the execution of functions and surrender control. See the syntax of Generator functions for more details on how to use Generator.

The main difference between Generator functions and normal functions is that the function name is marked with an asterisk and the yield keyword can be used internally:

function* gen() {
  console.log("gen 1");
  yield 1;
  console.log("gen 2");
  yield 2;
  console.log("gen 3");
  return 3;
}

const it = gen();

console.log("main 1");
console.log(it.next()); //{value: 1, done: false}

console.log("main 2");
console.log(it.next()); //{value: 2, done: false}

console.log("main 3");
console.log(it.next()); //{value: 3, done: true}
Copy the code
  1. callgenFunction returns an iterator
  2. By calling thenextReturn can be restoredgenFunction execution, and when encountered during executionyieldKeyword suspends execution, returns results, and surrenders control

With Generator functions, we can alternate execution of global code and Generator function code. That’s the logic of asynchronous programming. It is the ability to pause and resume execution that makes Generator suitable for asynchronous programming.

Generaor is internally implemented through coroutines. Coroutines are more lightweight than threads. They can be regarded as tasks on a thread. Multiple coroutines can exist on a thread, but only one can be executed at the same time. And switching coroutines consumes less resources than threads.

If coroutine B is started from coroutine A, then coroutine A is the parent coroutine of coroutine B

The coroutine flow diagram of the above code is as follows:

It should be emphasized that each coroutine has its own call stack. When a coroutine gains execution rights, the engine first saves the current call stack information of the parent coroutine and then restores the call stack information of the child thread.

In the previous example, yield returned synchronous code. If yield is followed by a Promise of an asynchronous request, the result can be obtained via the Promise’s then method and passed by next for the Generator to regain execution and continue:

function* gen() {
  const a = yield fetchData("/api/example-a");
  const b = yield fetchData(`/api/example-b? a=${a}`);
  const c = yield fetchData(`/api/example-c? b=${b}`);
  return c;
}

const it = gen();
it.next().value.then((a) = > {
  it.next(a).value.then((b) = > {
    it.next(b).value.then((c) = > {
      it.next(c);
    });
  });
});
Copy the code

Of course, it is very troublesome to write the actuator manually. In practical development, we can use it together with a tool library like CO:

function* gen() {
  const a = yield fetchData("/api/example-a");
  const b = yield fetchData(`/api/example-b? a=${a}`);
  const c = yield fetchData(`/api/example-c? b=${b}`);
  return c;
}

co(gen).then((result) = > {
  console.log("result: ", result);
});
Copy the code

As you can see, our asynchronous code is basically indistinguishable from our synchronous code after cooperating with the actuator. To catch an exception, simply use try/catch:

function* gen() {
  let a;
  try {
    a = yield fetchData("/api/example-a");
  } catch (error) {
    a = "default";
  }

  const b = yield fetchData(`/api/example-b? a=${a}`);
  const c = yield fetchData(`/api/example-c? b=${b}`);
  return c;
}

co(gen).then((result) = > {
  console.log("result: ", result);
});
Copy the code

Async/Await

It is already easy to write asynchronous code as synchronous code with A Generator for CO, but the quest for perfection is endless, and generators do have some drawbacks:

  • use*andyieldThis relatively weird syntax is not semantic enough
  • There is no built-in actuator, other libraries need to cooperate with the use

Thus, soon after Generator was introduced Async/Await appeared, which is equivalent to a Generator with an executor and is syntactically more semantic:

async function gen() {
  let a;
  try {
    a = await fetchData("/api/example-a");
  } catch (error) {
    a = "default";
  }

  const b = await fetchData(`/api/example-b? a=${a}`);
  const c = await fetchData(`/api/example-c? b=${b}`);
  return c;
}

gen().then((result) = > {
  console.log("result: ", result);
});
Copy the code

Use the async keyword instead of * and the await keyword instead of yield. Just call it like a normal function. Async functions return Promise objects by default, making it easy to handle asynchronous results.

See Async functions for more details on Async/Await usage.

conclusion

With the gradual development and improvement of front-end asynchronous programming, now asynchronous programming is no longer so painful, on the contrary, become easy and enjoyable. But at the same time, new grammars bring new learning costs, so as developers, we have to constantly improve ourselves.

If you have any comments or suggestions on this article, welcome to discuss and correct!