上一个模块我们学习了 JS 数组相关的知识,那么从这一讲开始就进入一个崭新的模块,也是 JS 学习过程中比较重要的部分——JS 异步编程。

JS 的异步编程可以说在日常的前端业务开发中经常出现,你需要深刻理解其思路,才能更好地加以运用。

先一起来回想一下,我们在日常开发中都用过哪些 JS 异步编程的方式?总结起来无外乎有这几种:回调函数、事件监听、Promise、Generator、async/await,这几种 JS 的编程方式都是异步编程。回调函数方式是最早的 JS 异步编程的方式,后随着 ES 标准的发展,Promise、Generator 和 async/await 接连出现。关于这几种方式,我会在后面的课时中分别为你介绍。这一讲的目的是帮你回顾,并对此形成一个整体的认识。

那么在课程开始前请你先回想一下:

  1. 同步编程和异步编程的区别在哪里?
  2. 回调地狱有哪些方法可以解决?

我首先为你解答与此相关联的问题。

什么是同步?

所谓的同步就是在执行某段代码时,在该代码没有得到返回结果之前,其他代码暂时是无法执行的,但是一旦执行完成拿到返回值之后,就可以执行其他代码了。换句话说,在此段代码执行完未返回结果之前,会阻塞之后的代码执行,这样的情况称为同步。

什么是异步?

所谓异步就是当某一代码执行异步过程调用发出后,这段代码不会立刻得到返回结果。而是在异步调用发出之后,一般通过回调函数处理这个调用之后拿到结果。异步调用发出后,不会影响阻塞后面的代码执行,这样的情形称为异步。

JS 编程中为什么需要异步?

我们都知道 JavaScript 是单线程的,如果 JS 都是同步代码执行意味着什么呢?这样可能会造成阻塞,如果当前我们有一段代码需要执行时,如果使用同步的方式,那么就会阻塞后面的代码执行;而如果使用异步则不会阻塞,我们不需要等待异步代码执行的返回结果,可以继续执行该异步任务之后的代码逻辑。因此在 JS 编程中,会大量使用异步来进行编程,这也是我要专门讲解这部分的原因。

JS 异步编程方式发展历程

说完了异步编程的基本概念,下面我们按照时间顺序来看一下 JS 异步编程的实现方式。

回调函数

从历史发展的脉络来看,早些年为了实现 JS 的异步编程,一般都采用回调函数的方式,比如比较典型的事件的回调,或者用 setTimeout/ setInterval 来实现一些异步编程的操作,但是使用回调函数来实现存在一个很常见的问题,那就是回调地狱。

这里我列举了一种现实开发中会遇到的场景,我们来看一下代码。

  1. fs.readFile(A, 'utf-8', function(err, data) {
  2. fs.readFile(B, 'utf-8', function(err, data) {
  3. fs.readFile(C, 'utf-8', function(err, data) {
  4. fs.readFile(D, 'utf-8', function(err, data) {
  5. });
  6. });
  7. });
  8. });

从上面的代码可以看出,其逻辑为先读取 A 文本内容,再根据 A 文本内容读取 B,然后再根据 B 的内容读取 C。为了实现这个业务逻辑,上面实现的代码就很容易形成回调地狱。回调实现异步编程的场景也有很多,比如:

  1. ajax 请求的回调;
  2. 定时器中的回调;
  3. 事件回调;
  4. Nodejs 中的一些方法回调。

异步回调如果层级很少,可读性和代码的维护性暂时还是可以接受,一旦层级变多就会陷入回调地狱,上面这些异步编程的场景都会涉及回调地狱的问题。下面我们来看一下针对上面这个业务场景,改成 Promise 来实现异步编程,会是什么样子的呢?

Promise

为了解决回调地狱的问题,之后社区提出了 Promise 的解决方案,ES6 又将其写进了语言标准,采用 Promise 的实现方式在一定程度上解决了回调地狱的问题。

我们还是针对上面的这个场景来看下先读取 A 文本内容,再根据 A 文本内容读取 B 文件,接着再根据 B 文件的内容读取 C 文件。我们看这样的实现通过 Promise 改造之后是什么样的,请看代码。

  1. function read(url) {
  2. return new Promise((resolve, reject) => {
  3. fs.readFile(url, 'utf8', (err, data) => {
  4. if(err) reject(err);
  5. resolve(data);
  6. });
  7. });
  8. }
  9. read(A).then(data => {
  10. return read(B);
  11. }).then(data => {
  12. return read(C);
  13. }).then(data => {
  14. return read(D);
  15. }).catch(reason => {
  16. console.log(reason);
  17. });

从上面的代码可以看出,针对回调地狱进行这样的改进,可读性的确有一定的提升,优点是可以将异步操作以同步操作的流程表达出来,避免了层层嵌套的回调函数,但是 Promise 也存在一些问题,即便是使用 Promise 的链式调用,如果操作过多,其实并没有从根本上解决回调地狱的问题,只是换了一种写法,可读性虽然有所提升,但是依旧很难维护。不过 Promise 又提供了一个 all 方法,对于这个业务场景的代码,用 all 来实现可能效果会更好。

下面我们就来看一个用 all 来实现的代码片段。

  1. function read(url) {
  2. return new Promise((resolve, reject) => {
  3. fs.readFile(url, 'utf8', (err, data) => {
  4. if(err) reject(err);
  5. resolve(data);
  6. });
  7. });
  8. }
  9. Promise.all([read(A), read(B), read(C)]).then(data => {
  10. console.log(data);
  11. }).catch(err =>
  12. console.log(err)
  13. );

这样改造之后是不是比上面直接使用 Promise 来实现,看起来更清晰一目了然了?下面我们再来看下另外一种 JS 的异步编程方式,同样也是 ES6 才引入的 Generator 的方式。

Generator

Generator 也是一种异步编程解决方案,它最大的特点就是可以交出函数的执行权,Generator 函数可以看出是异步任务的容器,需要暂停的地方,都用 yield 语法来标注。Generator 函数一般配合 yield 使用,Generator 函数最后返回的是迭代器。如果你对迭代器不太了解,可以自行补习一下这部分内容。

下面我们来看一下 Generator 的简单使用,请看这段代码。

  1. function* gen() {
  2. let a = yield 111;
  3. console.log(a);
  4. let b = yield 222;
  5. console.log(b);
  6. let c = yield 333;
  7. console.log(c);
  8. let d = yield 444;
  9. console.log(d);
  10. }
  11. let t = gen();
  12. t.next(1);
  13. t.next(2);
  14. t.next(3);
  15. t.next(4);
  16. t.next(5);

从上面的代码中可以看到输出结果,第一次的 next 虽然执行了但是并未输出结果,后面的每次执行 next 会把参数传入然后打印出来,等到最后一次 next 对应的 yield 执行完之后,控制台会打印 “{value: undefined, done: true}” 的输出结果,标识该 Generator 函数已经执行完毕,即 done:true。

那么看完了 Generator 方式之后,我们再来研究最后一种 async/await 的实现方式。

async/await

ES6 之后 ES7 中又提出了新的异步解决方案:async/await,async 是 Generator 函数的语法糖,async/await 的优点是代码清晰(不像使用 Promise 的时候需要写很多 then 的方法链),可以处理回调地狱的问题。async/await 写起来使得 JS 的异步代码看起来像同步代码,其实异步编程发展的目标就是让异步逻辑的代码看起来像同步一样容易理解。

我们来看下 async/await 的基本用法,代码如下所示。

  1. function testWait() {
  2. return new Promise((resolve,reject)=>{
  3. setTimeout(function(){
  4. console.log("testWait");
  5. resolve();
  6. }, 1000);
  7. })
  8. }
  9. async function testAwaitUse(){
  10. await testWait()
  11. console.log("hello");
  12. return 123;
  13. }
  14. console.log(testAwaitUse());

执行上面的代码,从结果中可以看出,在正常的执行顺序下,testWait 这个函数由于使用的是 setTimeout 的定时器,回调会在一秒之后执行,但是由于执行到这里采用了 await 关键词,testAwaitUse 函数在执行的过程中需要等待 testWait 函数执行完成之后,再执行打印 hello 的操作。但是如果去掉 await ,打印结果的顺序就会变化。

因此,async/await 不仅仅是 JS 的异步编程的一种方式,其可读性也接近于同步代码,让人更容易理解。

总结

这一讲我带你把 JS 的异步编程方式回顾了一遍。希望通过这几个方式的讲解,你能够对 JS 异步编程形成一个全局的认识,其中关于 Promise、Generator 和 async/await 相关知识点,在后面的课程中我会专门针对每一个知识点进行更详细的剖析和挖掘。

下面我整理了一个表格,是对本讲内容的简单总结。

13 | 异步编程(上):JS 异步编程都有哪些方案? - 图1

现阶段,如果你能自己实现一个完善的 Promise,其实也可以满足面试以及日常工作的基本需求了。

如果你对此有不理解的地方,可以大致列举出来,在接下来的课程中也好有针对性地进行学习。下一讲我会和你详细聊聊 Promise 相关内容,期待你能从中学到更多的东西,并且能够很好地运用起来。

我们下一讲再见。