1、setTimeout()

JavaScript中最基本的异步实现方式是使用setTimeout()定时器,把一个函数延迟若干毫秒后执行。setTimeout()是JavaScript全局对象中的方法,可以直接进行使用。在设置好延迟时间后,传给setTimeout()的回调函数就会等待执行,setTimeout()后面的代码会继续执行。当时间到后,setTimeout()里的函数就会尽快开始执行。

要创建一个定时器,可以给setTimeout()传递两个参数,分别是定时结束后要执行的函数和延迟时间毫秒数,例如下方示例在3秒后打印"hello world",且在setTimeout()下方打印了一行测试字符串,代码如下:

     function log(){console.log("hello world")}
     setTimeout(log,1000*3);
     console.log("开始");

执行代码会先打印出"开始"字符串,然后过3秒后会打印出"hello world"。需要注意的是,即使把setTimeout()的延迟时间设置为0,它里边的函数也不会立即执行,而是同样的在打印"开始"之后才执行。

如果想给setTimeout()中的函数传递参数,可以通过第3个参数传递,它是一个变长参数,需要多少参数就传递多少个,例如setTimeout(func,100,arg1,arg2)。

setTimeout()有一个返回值,是随机生成的定时器ID,把它保存下来之后,后边可以通过clearTimeout()取消定时,只需把ID传递给它。例如下方创建了一个3秒的定时器,但是在2秒后把它取消了,代码如下:

     let timer=setTimeout(()=>console.log("不会执行"),1000*3);
     setTimeout(()=>clearTimeout(timer),1000*2);

上边的代码没有任何输出,因为创建的timer定时器还没执行就被取消了。

2、stInterval()

setInterval()与setTimeout()类似,不同的是,setTimeout()在定时结束后只执行一次,而setInterval()可以根据设置的时间间隔反复执行,例如下方示例把setInterval()的时间间隔设置为1s,然后打印当前时间,代码如下:

     setInterval(()=>{
       let date=new Date();
       console.log(date.toLocaleTimeString());
     },1000);

setInterval()如果不取消则会一直执行,取消的方式和setTimeout()一样,setInterval()也会返回一个ID,后续可以通过clearInterval()取消。setInterval()同样可接收第3个变长参数用于给其中的回调函数传递参数。

3、Promise

JavaScript是事件驱动(Event-Driven)的编程模型,也就是说它会通过监听事件的触发,来执行指定的代码。在浏览器中可以给HTML元素添加事件监听器,当用户使用鼠标单击或触发其他事件时,事件监听中的回调函数就会执行,且事件对象会作为参数传递给回调函数。这个过程是异步的,事件监听不会阻塞线程,从而不影响HTML的解析,以及页面元素的响应。

在Node.js中,文件的读写都是异步的,例如使用File API读取文件时,它接收一个回调函数,当文件读取完成之后,会把数据传递给回调函数。这些事件监听回调和文件读取回调都不会立即执行,而是会一直等待用户的行为和数据读取的进度,当完成之后才执行回调函数,同时在等待的过程中也不会影响其他代码的执行。

不过使用回调函数编写的代码非常不容易阅读,如果有多个嵌套的回调,在视觉上就会形成回调地狱(Callback Hell),例如下方的伪代码:

     Database.connect(config,(err)=>{
       if(error){/*处理错误*/}
       const db=Database.db(dbName);
       db.insert(data,(err,result)=>{
          if(error){/*处理错误*/}
         db.find({},(err,data)=>{
            if(error){/*处理错误*/}
            data.forEach(item=>{
              item.collection.map(col=>{
                const[id,...rest]=col;
                return rest;
             })
           })
         })
       })
     })

可以看到上方代码非常难以阅读,回调函数嵌套了很多层级,并且有很多缩进。另外在处理异常情况时,error对象中的错误信息也需要在每个回调中进行处理。

在ES6中出现的Promise对象解决了这个问题,使用它可以编写更清晰易读的异步代码。Promise对象改变了回调的传递方式,改为使用平行的方式来处理回调,因而不会形成回调地狱。这一点需要特别注意,Promise只是单纯地改变了现有异步操作的处理方式,并不是创建了一个新的异步操作。JavaScript自身不能像Java等语言一样开启新的线程,以便异步执行里边的代码,这是因为JavaScript是单线程的,需要使用JavaScript运行环境所提供的、已编写好的异步API实现异步操作,例如发送网络请求的fetch()方法、Node.js中文件的读取、数据库的操作等。使用Promise不能实现这种异步操作,只是对异步操作进行了包装。

那么Promise到底是什么呢?JavaScript中的大部分异步操作使用回调函数来处理异步结果,可以从上边的例子中看出来,回调函数的形式难以阅读,并且难以集中处理错误,所以Promise就对这种回调函数的形式进行了改良,支持链式调用,让代码从嵌套关系变为平行关系,并提供了统一处理错误的机制。Promise本身代表着一项异步操作的执行状态,这个操作随时可能会完成或出错停止,需要通过Promise提供的API来处理完成后或出错后的处理逻辑。

3.1、创建Promise

一般地,在开发过程中绝大多数情况下会调用已经封装好的异步操作的Promise版本,例如使用fetch()加载远程服务器数据,在很少的情况下需要自己创建Promise,除非现有的异步操作只支持callback形式,此时需要把它转换为Promise形式。不过,通过自行创建Promise可以了解它的底层是怎么运作的。

之前介绍过了setTimeout()的用法,用于把某个函数延迟若干毫秒后执行,这里利用它模拟耗时的操作,在等待1s之后返回数据5,并使用Promise包装这个操作,代码如下:

     const p=new Promise((resolve)=>{
       setTimeout(()=>{
         resolve(5);
       },1000);
     });

上述代码使用Promise构造函数创建了一个Promise对象,构造函数接收1个回调函数作为参数,这个回调函数又被称为执行器(Executor),它接收两个参数,分别为resolve()和reject()函数,示例中只用到了resolve(),稍后再介绍reject()的用法。在介绍resolve()的含义之前,先了解一下Promise的3种状态。

  1. fulfilled:表示操作已经成功完成,并准备返回结果。
  2. rejected:表示操作执行失败,代码可能有异常或人为地调用了reject()。
  3. pending:如果状态既不是fulfilled也不是rejected,则为pending状态,表示操作执行中。

使用new创建Promise对象之后,执行器中的代码会立即执行,此时Promise为pending状态,当调用resolve()函数之后,会把Promise的pending状态改为fulfilled状态,类似地,reject()函数会把它从pending改为rejected状态。fulfilled和rejected状态统称为settled,可以认为是完成状态(无论是成功还是失败)​。

示例中执行器的代码使用了setTimeout()把resolve()的调用延后了1s,所以此Promise的执行时长大约为1s。resolve()函数接收1个参数,用于表示Promise的返回值,这样在调用resolve()并返回执行结果之后,就可以在后边获取这个结果并执行一些其他操作了。resolve()可以接收任何类型的值,包括另一个Promise对象。

获取返回结果可以使用Promise对象暴露出来的then()方法,它接收一个回调函数,回调函数的参数即为Promise返回的结果。例如获得Promise返回的5并打印到命令行,代码如下:

     //chapter12/promise1.js
     p.then((value)=>console.log(value));//在1s后打印出结果5.

传给then()的回调函数中的value即为Promise对象中resolve()参数的值:5。如果不需要在then()的回调中使用返回值,则可以省略参数。上述的代码也可以直接跟在Promise对象后边,代码如下:

     new Promise((resolve)=>{/*...*/}).then((value=>{/*...*/}))

在这个例子中,setTimeout()才是真正的异步操作,它是浏览器或Node.js运行环境提供给开发者使用的,而这里的Promise只是对setTimeout()进行了包装,这样可以用Promise的方式执行setTimeout()。其他的异步操作,例如旧版的fetch():XMLHttpRequest,以及Node.js中的大部分API,都可以用这种方式封装为Promise版本。

基于Promise的这个特点,Promise执行器中的代码是同步执行的,如果在执行器中编写了同步代码,例如使用超大数字的for循环,它同样会阻塞(Block)代码的执行,代码如下:

     const p=new Promise((resolve)=>{
       console.log(in promise...);
       for(let i=0;i<10000000000;i++){}
       resolve();
     })
     console.log("start");

代码输出结果如下:

     in promise...
     start

可以看到在for循环结束之前,最外层的console.log("start")不会被执行。

3.2、链式调用

如果异步代码需要分多步才能完成任务,且每个任务都互相依赖,则使用普通回调函数的形式需要嵌套多层,而使用Promise的链式调用方式可以把嵌套的回调函数改成平行关系。传递给Promise的then()方法的回调函数会返回一个全新的Promise,可以在它的基础上继续调用Promise中的方法。如果then()中的回调函数有return语句,则它的返回值就会作为新的Promise执行器中的resolve()参数的值,后边可以继续使用then()获取这个值并执行其他的操作。后面的then()在获取之前then()的返回值时有3种情况:

  1. 如果返回值是普通类型的值,则这个新的Promise会立即完成(Resolved),后边then中的代码也会立即执行。
  2. 如果返回的是一个Promise对象,则会等待该Promise执行完成之后再执行它后边then()中的回调函数。
  3. 如果then()中的回调函数里没有return语句或返回undefined,则仍然会返回新的Promise,这样下一个then()中的回调函数的参数就没有值了。

这里需要提一下,Promise除了这3种状态之外,还有两种执行结果:已完成(Resolved)和未完成(Unresolved)。一个已完成的Promise可能是任何一种状态,fulfilled、rejected或pending。当Promise调用resolve()或reject()时,就代表该Promise的执行结果是已完成,它们会分别把Promise的状态设置为fulfilled或rejected。当Promise本身已完成,但是还需要等待其他Promise执行时,例如给resolve()传递另一个Promise作为参数,那么第1个Promise为pending状态,但是本身是已完成的(Resolved)。另一个未完成的Promise则只可能是pending状态,后续随时可能通过调用resolve()或reject()把它变为已完成状态。

     new Promise((resolve)=>{
       setTimeout(()=>{
         resolve(5);
       },1000);
     })
       .then((value)=>{                   //第1个then
         console.log(value);
         return 10;
       })
       .then((value)=>{                   //第2个then
         console.log(value);
         return new Promise((resolve)=>{
            setTimeout(()=>{
              resolve(15);
           },3*1000);
         });
       })
       .then((value)=>{                   //第3个then
         console.log(value);
       })
       .then(()=>{                        //第4个then
         console.log("done");
       });

代码中首先创建了一个Promise,1s后返回5,之后分别使用了4个then()进行链式调用:

  1. 第1个then()打印了Promise返回的5并返回了一个普通的数字类型的值10。相当于返回了new Promise(resolve=>resolve(10))。
  2. 第2个then()打印了第1个then()的10,然后返回了一个新的Promise,该Promise会在3s后返回15。
  3. 第3个then()在3s后,即第2个then()返回的Promise完成之后打印出15。这个then()中没有返回值。
  4. 第4个then()因为第3个then()没有返回值,所以传递给它的回调函数没有参数,它直接打印出"done"。

这样在输出结果时,首先等待1s打印出5,并紧接着打印出10,再过3s打印出15并紧接着打印出"done",到这里全部的Promise就完成了。这里需要注意的是,链式调用的每个then()返回的都是全新的Promise对象,并不是最开始的Promise。

这个示例可以看到使用then()链式调用的操作跟嵌套多个回调函数的操作是一样的,只是形式上有很大区别,这里的then()的调用是平行的,且通过返回值的形式把值传递给下一个then(),这种流程就清晰了很多。

再看一个比较实际的例子,假设某个应用需要请求远程服务器上的博客列表JSON数据,地址为"/api/posts",这时可以使用浏览器内置的fetch()方法,它接收一个URL作为参数,在请求结束后返回一个Promise对象,用于获取请求返回的响应数据,代码如下:

     fetch(/api/posts")
       .then((res)=>res.json())
       .then((posts)=>{
         console.log(posts);
       });

代码首先使用了fetch()发送请求,当请求返回时(时间不确定)​,第1个then()中的回调函数会获得响应数据,此时它是一个响应对象,需要调用它的json()方法才能把原始数据转换为JavaScript对象,res.json()会返回一个新的Promise,当它完成时,会执行第2个then(),打印出解析后的文章列表对象,这时整个任务就执行完成了。

3.3、处理异常

上边的例子都没有处理异常情况,本节来看一下当Promise中的代码抛出异常时,该怎么处理。如果使用的是Promise构造函数创建的自定义Promise对象,则首先有可能在执行器中抛出异常,例如下方示例,为了演示,在setTimeout中有意编写了会抛出异常的代码,代码如下:

     new Promise((resolve)=>{
       setTimeout(()=>{
         new Array(NaN);
         resolve(5);
       },1000);
     }).then((value)=>{
       console.log(value);
     });

给Array构造函数传递NaN会抛出RangeError异常,因为它不是有效的数字,不能作为数组的长度。这种在setTimeout()内部出现的异常是无法在Promise外边使用try…catch进行捕获的,只能在setTimeout()内部进行捕获。在有异常抛出之后,resolve()方法就得不到执行了,进而后边的then()也无法执行,但是正常的逻辑应该是在Promise抛出异常后,能够在后边的then()中去处理。

为了达到这个目的,可以使用执行器的第2个参数,即reject()方法,它可以把Promise的状态改为rejected,提示Promise运行失败,并通过reject()的参数传递自定义的失败原因,例如error对象或者错误提示字符串。这时可以在setTimeout()中捕获异常并在catch语句块中调用reject()函数。例如,在上方示例的执行器函数中加上reject参数,并把setTimeout()中的代码改为下例所示,代码如下:

     try{
       new Array(NaN);
       resolve(5);
     }catch{
       reject("指定数组长度时必须是有效数字");
     }

这时再运行代码会提示:未处理的Promise异常:指定数组长度时必须是有效数字,未处理的异常是因为此Promise的异常还没进行捕获并处理。这时可以使用then()中回调函数的第2个参数处理错误,值为reject()中所定义的错误原因,代码如下:

     .then(
      (value)=>{
         console.log(value);
       },
      (error)=>{
         console.log(error);
       }
     );

当Promise出现异常时,then()就会执行第2个回调函数,这里打印出了之前传递的原因:"指定数组长度时必须是有效数字",而且控制台也不提示未处理的Promise异常了。

不过,这样使用then()中第2个回调函数处理错误的形式会使代码变得不易阅读,所以Promise提供了catch()方法专门用于处理异常,它接收1个回调函数作为参数,回调函数的结构与then()的第2个参数一样,相当于then(null,errorHandler),由于new Promise()和then()等返回的都是Promise对象,所以都可以调用catch()。例如使用catch()捕获异常,代码如下:

     .then(
      (value)=>{
         console.log(value);
       }
     )
     .catch((error)=>{
       console.log(error);
     });

代码的输出结果与使用then()处理异常的结果一样。这里应该注意到catch()放到了then()的后边,但是then()中的代码没有执行,反而catch()先执行了,这也是使用catch()的另一个好处,如果Promise中抛出了异常,则这个异常会传播(Propagate)到离它最近的一个catch()中,中间所有的then()都不会执行,而当catch()捕获异常之后,它返回的又是一个全新的Promise,后续又可以使用then()处理catch()中的返回值,例如上方示例把catch()和then()的顺序换个位置,并在catch()中返回10,最后then()就能打印出10了,代码如下:

     //chapter12/promise5_1.js
     .catch((error)=>{
       console.log(error);
       return 10;
     })
     .then((value)=>{
       console.log(value);
     });

输出结果如下:

     指定数组长度时必须是有效数字
     10

可以看到后边then()中回调函数的参数是catch()的返回值。基于这个特性,可以在Promise的调用链中间使用catch()处理特殊的错误,并在最后使用一个catch()统一处理其他错误,例如使用fetch()加载远程服务器数据时,有可能出现网络错误、请求错误(404、500)等,所以可以根据情况处理这些错误,对于其他错误在最后的catch()中统一处理,代码如下:

     fetch("https://jsonplaceholder.typicode.com/posts")
       .then((res)=>{
         const status=res.status;
          if(status>=400){
            throw status;
         }
         return res.json();
       })
       .catch((error)=>{
          if(error===404){
            console.log("未请求到数据");
            return[];
         }
         throw error;
       })
       .then((posts)=>{
         console.log(posts);
       })
       .catch((error)=>{
         console.log(error);
       });

代码中使用fetch()请求博客列表数据,当返回响应对象时,这里首先通过它的status属性获取HTTP响应码,如果是大于400的响应码,则直接把它们作为异常抛出,并针对404这种异常进行特殊处理。在第1个catch()中先判断异常是不是404,如果是则打印"未请求到数据",并返回空的博客列表数组,如果是其他情况,则再次把异常抛出。之后在下一个then()中,打印出博客列表数组,这里如果请求成功则会打印出有数据的数组。如果是404状态,则会打印出空数组,其他异常情况则会跳过这个then()而运行到最后一个catch()中,它简单地打印了error参数的值。该代码如果正常执行会打印出博客列表数组[{…},{…},{…}]​,如果把const status=res.status改为const status=404测试一下,则它会打印出如下结果:

     未请求到数据
     []

这是因为第1个then()抛出了404异常,第1个catch()捕获住了该异常并返回了空的数组,第2个then()打印出了空数组的值,最后一个catch()由于没有异常所以没有执行。如果改为500,则会打印出500,因为第1个then()中抛出了500,而第1个catch()中又继续把500抛出,传播到了最后的catch()中并打印了出来,中间第2个then()不会执行。

利用Promise异常传播特性和catch()方法,可以有针对性地处理单个异常或者多个异常,无论是从哪里抛出来的,而使用传统回调函数的方式只能在每一层分别处理异常。

最后,Promise对象中还有finally()方法,如同try…catch…finally中的finally,可以放在最后执行一些清理和收尾操作,finally()只要Promise的状态为settled,即无论是fulfilled还是rejected都会执行,一般放到调用链的最后边。

3.4、执行多个Promise

如果有多个Promise需要同时执行,例如同时发起多个网络请求、执行多个动画、批量数据库操作等,则根据所要求的返回结果的不同,Promise提供了4种方式执行多个Promise,分别是Promise.all()、Promise.allSettled()、Promise.any()和Promise.race()。接下来分别看一下它们的作用和区别。

3.4.1、Promise.all()

接收一个可迭代的对象(例如数组)作为参数,每个元素为要执行的Promise。Promise.all()会返回一个新的Promise,如果参数中所有的Promise都变为fulfilled,这个新的Promise就会变为resolved,它会把所有的结果按元素的顺序放到数组中并返回。参数数组中的元素也可以是普通的JavaScript数据类型,这样它的值会原样返回结果数组中。Promise.all()用法的代码如下:

     const promise1=new Promise(resolve=>setTimeout(resolve,300,1));
     const promise2=new Promise(resolve=>setTimeout(resolve,100,2));
     const promise3=3;
     Promise.all([promise1,promise2,promise3]).then(values=>{console.log(values)});

promise1会在300ms后返回1,这里使用setTimeout()中的第3个参数来给resolve()函数传递参数,promise2会在100ms后返回2,promise3是基本类型数据,会立即返回,虽然这3个promise的执行顺序是promise3、promise2、promise1,但是因为Promise.all()的返回值是按数组中元素的顺序返回的,即promise1、promise2、promise3,所以上述代码的输出结果为[1,2,3]​。

如果有任意一个Promise发生错误或状态变为rejected,则后续的Promise会停止执行,Promise.all()返回的Promise会变为rejected,并且catch语句中的错误信息,为第1个出错的Promise的原因。例如把promise1改为rejected:const promise1=newPromise((resolve,reject)=>setTimeout(reject,300,"失败")​)​,这时如果运行代码会抛出未捕获的Promise异常,在Promise.all()后边使用catch()可以捕获该异常,代码如下:

     Promise.all([promise1,promise2,promise3])
       .then((values)=>{console.log(values)})
       .catch((error)=>{console.log(error)});

输出结果为"失败"。

3.4.2、Promise.allSettled()

与Promise.all()类似,只是无论Promise是fulfilled还是rejected(Settled)都会返回结果数组中,fulfilled会把结果放入数组中,rejected会把原因放入数组中,且不会影响其他Promise的执行。Promise.allSettled()适合需要知道每个Promise的执行情况的场景,例如把上一小节最后的Promise.all改为使用Promise.allSettled(),代码如下:

     Promise.allSettled([promise1,promise2,promise3]).then((values)=>{
       console.log(values);
     });

它返回的数组如下:

     [
       {status:'rejected',reason:'失败'},
       {status:'fulfilled',value:2},
       {status:'fulfilled',value:3}
     ]

数组中的每个元素都是一个对象,status表示Promise的最终状态,value为正常执行的Promise的结果,reason为发生异常的Promise的原因。

3.4.3、Promise.any()

与Promise.all()不同的是,参数数组中的Promise只要有1种状态变为fulfilled,就会把该Promise的结果返回。Promise.any()返回的Promise中只有单一的结果。如果所有的Promise的状态都为rejected,则Promise.any()会抛出AggregateError,代码如下:

     const promise1=new Promise(resolve=>setTimeout(resolve,300,1));
     const promise2=new Promise(resolve=>setTimeout(resolve,100,2));
     const promise3=3;
     Promise.any([promise1,promise2,promise3]).then(value=>{console.log(value)});
         //3

需要注意,在本书截稿前,只有Chrome 85和Node.js15.0以上版本支持Promise.any(),它是ES2021发布的新特性。

3.4.4、Promise.race()

相当于是any()版的allSettled(),参数数组中的Promise只要有1个Promise状态变为fulfilled或rejected,就会返回它的结果或异常原因。

3.4.5、顺序执行

如果想执行一系列互相依赖的Promise,并使用最后一个Promise的返回值,一般的写法则会在每个Promise后的then()中执行下一个Promise,代码如下:

     const promise1=new Promise((resolve)=>     setTimeout(resolve,300,3));
     const promise2=new Promise((resolve)=>     setTimeout(resolve,200,2));
     const promise3=new Promise((resolve)=>     setTimeout(resolve,400,1));
     promise1.then(()=>promise2).then(()=>promise3).then((value)=>{
       console.log(value);//1
     });

代码中每个then()中的回调函数只是简单地返回了下一个要执行的Promise,实际的场景可能有其他业务逻辑代码。不过,12.4节将要介绍的async/await关键字可以更直观地实现顺序执行。

4、async/await

async/await是继Promise之后在ES2017中新定义的关键字。async用于定义异步函数,await用于获取异步函数的执行结果,它们在语法形式上对Promise进行了修改,使代码编写起来更像是同步式的,阅读起来更加直观,但是底层还是基于Promise实现的。

4.1、定义异步函数

异步函数是使用async关键字定义的函数,除了函数名前边需要加上async之外,在定义上与普通的函数没有什么区别,不过它的返回值会包装成Promise对象。例如定义一个异步函数,代码如下:

     async function getTitle(){
       return"标题"
     }

如果直接调用这个函数并打印返回值,则会输出:Promise{:"标题"},如果想要访问返回值,则需要像Promise一样使用then(),代码如下:

     getTitle().then(title=>console.log(title));

除此之外,在异步函数中可以使用await关键字获取其他Promise或异步函数的执行结果。

4.2、使用await

await关键字的作用相当于then(),Promise完成之后的值会作为await关键字的返回值,可以把它保存到变量中再进行后续操作,代码如下:

     const promise=new Promise(resolve=>setTimeout(resolve,3*1000,"done"));
     async function logResult(){
       const result=await promise;
       console.log(result);
     }
     logResult();

代码中定义了一个promise,在3s后打印出"done"字符串,之后在一个async函数logResult()中,使用了await等待promise的执行结果,并打印出来,代码的最后直接调用了logResult()这个async函数,它没有返回值,所以不需要在后边使用then()。运行代码并等待3s后,控制台就会打印出"done"。这里的await和后边的代码相当于then()中的回调函数,类似下例,代码如下:

     promise.then(result=>console.log(result));

只不过使用await这种方式更符合同步代码的风格。需要注意的是,await只能在异步函数中使用,类似于必须先有Promise才能有then(),如果忘记写async,则程序会抛出异常。

再来看一个例子,之前使用fetch()获取博客文章列表的代码,如果使用async/await则可以使用下方示例的形式,代码如下:

     async function getPosts(){
       const res=await fetch(/api/posts");
       const posts=await res.json();
       return posts;
     }
     getPosts().then(posts=>console.log(posts));

代码最后同样会打印出获取的文章列表数组,不过这里可以看到,之前使用了两个then()分别获取res对象和posts数组,而这里使用await则更像是同步的代码,且两个await是按顺序执行的,也就是说第1个await会等待fetch()的返回结果,在得到结果之后,第2个await才会执行,如果后边有更多的await关键字,则它们都会等待前一个执行完毕之后才会执行。再看12.3.4节顺序执行Promise的例子,这里同样使用之前定义的3个promise,改成使用await的形式顺序执行,代码如下:

     async function execPromises(){
       await promise1;
       await promise2;
       const value3=await promise3;
       return value3;
     }
     execPromises().then(value=>console.log(value));

这里最后打印出的结果同样也是promise3的返回值:1。这种方式就比使用then()的方式清晰了很多。不过,要想使await同时开始执行所有的promise或async函数,可以借助Promise.all()实现,例如使用代码:awaitPromise.all([promise,asyncFunc1(),asyncFunc2()])。

4.3、处理异常

使用async/await处理异常的方式也相当直观,可以使用try…catch语句块包裹await语句,任何一条await抛出异常,都能够被try捕获,并在catch语句块中处理。例如使用fetch()处理网络和请求错误,可以使用下方示例的形式,代码如下:

     async function getPosts(){
       try{
         const res=await fetch("/api/posts");
          if(res.status>=400){
            throw res.status;
         }
         const posts=await res.json();
         return posts;
       }catch(error){
          if(error===404){
            return[];
         }else{
            console.log(error);
         }
       }
     }
     getPosts().then(posts=>console.log(posts));

如果响应状态码是404,则getPosts()会返回空数组,其他状态码则直接使用console.log()打印了出来。如果想分别捕获fetch()和res.json()的异常,则可以把它们分别放到两个try…catch()语句块中。同样地,也可以使用finally()执行一些收尾操作。

5、异步迭代

之前在介绍迭代器和生成器时,了解到迭代器是一个有next()方法的对象,next()方法会返回包含value和done属性的迭代结果对象,如果给一个对象或迭代器设置[Symbol.iterator]属性,则它们就可以使用for…of进行迭代,而生成器是由生成器函数返回的,本身也是可迭代的对象,用于简化迭代器的定义。生成器函数使用yield关键字来产生迭代结果对象。迭代器和生成器函数也支持异步的形式,这样它们的next()方法返回的是一个Promise,后续可以使用forawait…of进行迭代,每次迭代时会在Promise执行完成之后返回迭代到的数据。

给对象定义异步的迭代器可以使用[Symbol.asyncIterator]属性,并给它设置一个迭代器对象,这里继续以生成连续的小写英文字母为例,不过这里设置为每隔1s生成一个字母,代码如下:

     const alphabet={
       [Symbol.asyncIterator](){
         return{
            charCode:97,
            async next(){
              await new Promise((resolve)=>setTimeout(resolve,1000));
              if(this.charCode<123){
                let res={
                  value:String.fromCharCode(this.charCode++),
                  done:false,
                };
                return res;
             }else{
                return{done:true};
             }
           },
         };
       },
     };

因为next()需要返回一个Promise,所以这里直接使用async关键字把它定义成异步函数,这样它的返回值会自动包装成Promise对象,在异步函数中,可以使用await关键字等待Promise的执行,这里的Promise中使用了setTimeout()让后边的代码延迟1s执行。之后可以使用for await…of来迭代alphabet对象,代码如下:

    (async function(){
       for await(let letter of alphabet){
         console.log(letter);
       }
     })();

因为for await…of也必须在异步函数中使用,所以这里使用了自执行函数来简化代码,在for await后边的小括号中,of后边是要迭代的对象,前边是保存迭代结果的变量。这段代码会每隔1s打印出一个字母,直到打印出"z"。

异步生成器函数与普通生成器函数的作用一样,只是使用yield返回的值是Promise,并且需要使用async关键字定义。下面的代码使用异步生成器函数生成a~z的小写字母,代码如下:

     async function*asyncAlphabetGen(){
       for(let charCode=97;charCode<     123;charCode++){
         await new Promise((resolve)=>setTimeout(resolve,1000));
         yield String.fromCharCode(charCode);
       }
     }
    (async function(){
       for await(let letter of asyncAlphabetGen()){
         console.log(letter);
       }
     })();

这段代码的输出结果与上一个示例的输出结果相同,需要注意,在遍历时of后边调用了asyncAlphabetGen()函数,用于迭代它返回的生成器对象,因为生成器本身也是可迭代的对象,所以可以直接对它进行迭代。asyncAlphabetGen()异步生成器函数与普通生成器函数唯一不同的是使用了async关键字,在它内部也可以使用await关键字。

因为生成器函数用于简化迭代器的定义,所以也可以把它作为[Symbol.asyncIterator]的返回值,代码如下:

     [Symbol.asyncIterator](){
       return asyncAlphabetGen();
     }

或者,异步生成器函数的代码也可以直接作为[Symbol.asyncIterator]属性值,代码如下:

     const alphabet={
       async*[Symbol.asyncIterator](){
         for(let charCode=97;charCode<123;charCode++){
            await new Promise((resolve)=> setTimeout(resolve,1000));
            yield String.fromCharCode(charCode);
         }
       },
     };

注意[Symbol.asyncIterator]前边的async和∗,这是异步生成器函数在对象中的简写形式,相当于[Symbol.asyncIterator]:asyncfunction∗(){}。

异步迭代器和生成器函数适合用于在网络请求中连续获取按块进行传递的数据,例如流数据或分页数据。假设获取博客列表的API支持分页的形式查询,每次最多返回20条,现在想要获取前50条博客,如果通过普通的async异步函数获取,则需要调用3次,并在异步函数外部组合最终结果,但是使用异步生成器函数的形式可以直接在内部管理数据,然后在外边使用for await…of就可以实现获取任意数量的博客文章了,代码如下:

     //需要在浏览器中执行
     async function*fetchPosts(){
       let page=   1;
       while(true){
         try{
            const res=await fetch(
             `/api/posts?_page=${page}&_limit=20`
           );
            const posts=await res.json();
            if(posts&&posts.length>0){
             //使用yield*逐一返回posts中的每个元素
              yield*posts;
              page++;
           }else{
              break;
           }
         }catch(error){
            break;
         }
       }
     }

代码中定义了fetchPosts()异步生成器函数,在里边使用page变量保存页码状态,然后在一个死循环中使用fetch()加载数据,URL中的_page用于指定当前页码,_limit用于指定每页加载多少条数据。由于在迭代之前,生成器中的代码会暂停执行,所以while循环也会暂停,而不会占用资源。如果获取的博客列表数组不为空,则直接使用yield∗把数组中的元素取出来逐一返回,如果为空(已获取全部博客文章)​,或者请求出现异常,则中断循环,迭代结束。在使用的时候,可以使用for await…of获取想要的数量,代码如下:

    (async function(){
       let posts=[];
       for await(let post of fetchPosts()){
          if(posts.length<50){
            posts.push(post);
         }
       }
       console.log(posts);
     })();

代码中定义了posts结果数组,只要它的长度在50以内,就继续添加博客文章对象,最后打印posts就可以看到加载的50条数据了,这里通过修改if语句的判断就能够获取任意数量的博客文章了。

6、Event Loop

6.1、调用栈

之前在介绍递归的时候了解到调用栈的概念。首先JavaScript的代码是按顺序从上到下执行的,在JavaScript开始执行全局代码和函数中的代码时,都会创建一个执行上下文(Execution Context),它里边包含要执行的代码和运行时所需要的信息(词法环境)​。执行全局代码所创建的上下文称为全局执行上下文(Global ExecutionContext)。

执行上下文会先压入调用栈中(或称为执行上下文栈,ExecutionContext Stack)​,然后执行它里边的代码,如果代码中调用了其他函数,则被调用的函数又会创建一个新的执行上下文,并压入栈顶,并执行它里边的代码,直到当没有其他函数调用时,JavaScript便会从调用栈的顶部开始顺序返回函数的执行结果,每当一个函数执行完毕,和它相关的执行上下文也就从栈中弹出并销毁了。全局执行上下文相当于一个大的函数,可以认为它总是第1个压入调用栈中,并最后一个执行完毕。来看一个例子,代码如下:

     let a=   10;
     function func1(){
       let b=5;
       return func2(b);
     }
     function func2(b){
         return a+b;
     }
     func1();          //15

代码在运行的时候,会有以下执行过程:

  1. 先创建全局上下文,然后开始执行全局代码:let a=10,定义变量a。
  2. 定义函数func1()和func2(),执行func1()。
  3. 此时有了函数的调用,对于fun1()的调用会创建和它相关的执行上下文,然后压入调用栈中,并开始执行它里边的代码。
  4. fun1()中的代码定义了变量b,并将它赋值为5。
  5. 在return语句中调用了func2()。
  6. 这时就会创建与func2()相关的执行上下文并压入栈中,开始执行func2()中的代码。
  7. func2()中的代码里只有一行return a+b且没有其他函数的调用,在return执行完毕之后,func2()会从调用栈中弹出并销毁,然后把返回值传给func1()。
  8. func1()在返回func2()的调用结果之后也会从调用栈中弹出并销毁。
  9. 此时调用栈为空,全局执行上下文中的func1()的调用会得到结果15,程序运行就结束了。

6.2、Event loop

除了调用栈之外,JavaScript还包含了Event Loop这样一条消息处理队列,它会一直等待接收消息并放入队列中,然后在适当的时间执行这些消息。它的概略结构代码如下:

     while(queue.waitFor M essage()){
       queue.processNextM essage()
     }

消息可以认为是异步的操作,像是setTimeout()、HTML DOM事件、Node.js事件和Promise等,并分为两种,一种是任务(Task),另一种是微任务(Microtask),它们会分别入队到任务队列(TaskQueue)和微任务队列(Microtask Queue)中。

程序执行的开始、setTimeout()、触发的DOM事件、Node.js事件及全局代码的执行都会入队到任务队列中,而与Promise相关的操作会入队到微任务队列中。Event Loop会在调用栈清空且微任务队列中的任务全部执行完毕后进行一次循环,每次循环会按先进先出的顺序处理任务队列中最先入队的一条消息,处理完毕之后如果有新消息添加进来,则会在下一次循环中处理新加入的消息。关于微任务的执行时机稍后再作介绍。

先看一下任务队列的处理流程,这里以setTimeout()为例,它会在计时结束之后把回调函数入队到任务队列。之前在介绍setTimeout()时知道它的第2个参数用于指定要延迟的毫秒数,但是这只是最低的保证,setTimeout()会在指定的延迟时间把回调函数入队到任务队列中,而任务队列需要在当前调用栈中的代码执行完毕之后才进行处理。来看一个例子,代码如下:

     setTimeout(()=>{console.log("done")},0);
     for(let i=0;i<10000000000;i++){}

代码中使用setTimeout()推迟了一个回调函数的调用,回调函数执行时会简单地打印出"done",第2个参数将延迟设置为0ms,但是后面使用了一个耗时的for循环来长时间占用调用栈。代码在执行时,虽然将setTimeout()延迟设置为0,但仍然会等for循环执行结束后才会执行它的回调,下面来看一下它的执行过程。

首先程序将开始执行的任务加入任务队列中并开始处理,执行全局代码,此时会遇到setTimeout(),它的延迟为0,所以直接把它的回调函数作为消息入队到任务队列中。接下来执行for循环,它需要运行一段时间,此时全局代码的执行任务仍未结束,任务队列中的下一条消息console.log("done")无法处理。在for循环执行完毕之后,Event Loop开始处理任务队列中的消息,把console.log("done")放入调用栈中执行并打印出"done",之后把它弹出,程序运行结束。

当使用Promise时,then()中的回调函数会在Promise执行完毕并返回结果后入队到微任务队列中。每当任务队列中的一条消息处理完毕之后,如果调用栈为空,则Event Loop会检查微任务队列中是否有消息,如果有,则会按先进先出的顺序处理所有消息,需要注意的是这里会处理微任务队列中的所有消息,如果在处理过程中又有新的微任务加进来,则新加入的微任务也会被处理,这里与任务队列的执行逻辑不同。当微任务队列清空之后,Event Loop才会进入下一次循环并处理任务队列中的下一条消息(如果有)​。下例演示了任务队列和微任务队列的处理顺序,代码如下:

     setTimeout(()=>{
       console.log(1);
     },0);
     new Promise((resolve)=>{
       resolve(2);
     }).then((value)=>console.log(value));

代码输出结果会先打印2,再打印1,来看一下执行过程。

  1. Event Loop首先执行全局代码,setTimeout()的延迟为0,所以直接入队到任务队列中。
  2. new Promise()构造函数会压入调用栈并执行它里边的resolve(2)函数,当然resolve()函数的调用也需要入栈到调用栈中执行,但是不太相关的函数调用这里就不单独描述了,后边的例子也是如此。
  3. 创建完Promise对象之后,调用它的then()方法并传递回调函数,此时Promise已经使用resolve()返回了执行结果,所以then()中的回调函数入队到微任务队列中。
  4. 调用栈中的代码到现在已执行完毕,且任务队列中的执行全局代码的任务也已完成,接下来微任务中的回调函数会压入调用栈中开始执行,所以先打印出了2。
  5. 在微任务中的回调函数执行完毕并弹出后,微任务队列和调用栈均清空,任务队列中的回调函数压入调用栈执行并打印出1,最后弹出,程序运行结束。

如果把Promise中的resolve延迟10ms执行,使用setTimeout(resolve,10,2),则结果的打印顺序就是"1"、"2"。因为Promise()在10ms之后才能执行完毕,then()中的回调函数还没入队到微任务队列,所以Event Loop在查检微任务队列中的消息时,如果发现没有要处理的消息,就会去处理任务队列中的消息。

如果上例中的Promise中有多个then(),且每个then()都立即返回一个值,则setTimeout中的代码会在所有then()执行完之后再执行,因为每个then()在执行完毕后,后边的then()会立即入队到微任务队列中,导致微任务队列仍有任务,代码如下:

     setTimeout(()=>{
       console.log(1);
     },0);
     new Promise((resolve)=>{
       resolve(2);
     })
       .then((value)=>{
         console.log(value);
         return 3;
       })
       .then((value)=>console.log(value));

输出结果为2 31。

对于async/await,因为它底层也是使用Promise实现的,使用async定义的异步函数相当于Promise中的执行器,它里边的代码也会在调用异步函数时立即执行,而await关键字就相当于使用then(),await下边的代码就相当于then()回调函数中的代码。async/await和promise结合使用的代码如下:

     console.log(1);
     setTimeout(()=>{
       console.log(2);
     },0);
     let p=new Promise((resolve)=>{
       setTimeout(resolve,100,3);
     });
     async function asyncFunc(){
       console.log(4);
       const value=await asyncFunc2();
       console.log(value);
       console.log(5);
     }
     asyncFunc();
     async function asyncFunc2(){
       const value=await p;
       console.log(value);
       return 6;
     }
     console.log(7);

这段代码的输出结果为1 4 7 2 3 6 5。乍一看代码比较复杂,不过按照Event Loop的执行顺序和逻辑一点一点拆分出来就不难了。

  1. 首先全局执行上下文的代码先执行,即先执行最外层的代码。第一行console.log(1)会打印出1。
  2. 接着setTimeout()中的console.log(2)被放入任务队列中。
  3. Promise p执行器中的代码开始执行,启动定时器,3s后把resolve()放入任务队列中。
  4. 调用asyncFunc(),按顺序执行里边的代码,首先打印出4,然后使用了await关键字调用asyncFunc2()函数。
  5. asyncFunc2()中的代码开始按顺序执行,它里边的await和后边的代码相当于p.then(value=>{console.log(value);return 6})。由于p还没有执行完成,所以await后边的代码不会入队到微任务队列中。
  6. 同时asyncFunc()里await asyncFunc2()后边的代码也不会入队到微任务队列中。
  7. 接下来执行全局执行上下文中的console.log(7),并打印出7。
  8. 现在输出结果是1 4 7,全局上下文执行完毕。
  9. 判断微任务队列是否有任务,因为asyncFunc()需等待asyncFunc2(),而asyncFunc2()需等待p,p在3s后才能解析,所以此时微任务队列为空。
  10. 微任务队列为空,所以判断任务队列是否有任务,发现有console.log(2),这时会打印出2。
  11. 等3s后,p执行完毕返回3。asyncFunc2()中的await p得到结果并把后边的代码入队到微任务队列,此时调用栈为空,它会立即开始执行,打印出3,然后返回一个可立即完成的Promise。
  12. 在asyncFunc()中获得await asyncFunc2()执行结果,后边的代码入队到微任务队列执行并打印出asyncFunc2()的结果6,最后打印出5。

最终结果就是1 4 7 2 3 6 5。再来看一个比较复杂的例子,代码如下:

     console.log(1);
     setTimeout(()=>{
       console.log(2);
     },1000);
     new Promise((resolve)=>{
       setTimeout(resolve,1000,3);
     })
       .then((value)=>{
         console.log(value);
       })
       .then(()=>{
         console.log(4);
       });
     async function asyncFunc1(){
       try{
         const v1=await new Promise((resolve)=>resolve(7));
         console.log(v1);
         await asyncFunc2();
       }catch(error){
         console.log(error);
       }finally{
         console.log(8);
       }
       console.log(9);
     }
     asyncFunc1();
     async function asyncFunc2(){
       console.log(5);
       throw 6;
     }
     console.log(10);

输出结果为1 1 0 7 5 6 8 9 2 3 4。有了前边的解释,现在应该会发现Event Loop的执行规律:

  1. 首先看最外层代码,先执行console.log(1)并打印出1,后边newPromise()中没有能立即执行的代码,接下来asyncFunc1()的调用一开始就使用了await,且后边的Promise能够立即完成,因此console.log(v1)加入了微任务队列,最后执行console.log(10)并打印出10。此时调用栈为空。
  2. 全局代码执行完毕后,接下来查看微任务队列。发现有一个console.log(v1),执行它并打印出7。
  3. 紧接着await asyncFunc2()先执行asyncFunc2()的代码,打印出 5,然后抛出了错误6,接着回到asyncFunc1()中,因为try…catch…finally相当于调用Promise.then().catch().finally()且代码中都返回了可立即完成的Promise,所以按顺序放入微任务队列,等调用栈清空后开始按顺序执行,打印出6、8、9。最后一个console.log(9)相当于finally().then()。
  4. 立即可执行的Promise()已经执行完毕,接着看稍晚一点的任务,任务队列中的console.log(2)需要等待1s,而它下边定义的Promise需要3s才能执行完成,微任务队列暂时没有任务,所以在1s后console.log(2)入队到任务队列中并执行,打印出2。
  5. 最后3s的Promise执行完成,没有比它更晚的了,所以它后边的then()按顺序执行并打印出3和4。
  6. 到这里程序就运行结束了。

可以看到,对于Event Loop的执行顺序,只需按代码执行顺序先找到全局执行的代码、函数的调用及Promise执行器中能立即执行的代码,再把这些Promise的回调函数按完成顺序添加到微任务队列中。计时未结束的setTimeout()会在计时结束时把回调函数入队到任务队列,未完成的Promise会在完成后把then()中的回调函数入队到微任务队列。待全局代码执行完毕、调用栈清空后查看微任务队列有没有任务,如果有就先执行其中所有的任务,如果没有就看任务队列是否有可执行的任务,后边重复这个操作,直到最后的任务执行完成为止。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐