JavaScript权威指南 第13章 异步JavaScript

JavaScript权威指南 第13章 异步JavaScript

  • 13章 异步JavaScript
    • 13.1 使用回调的异步编程
      • 13.1.1 定时器
      • 13.1.2 事件
      • 13.1.3 网络事件
      • 13.1.4 Node中的回调与事件
    • 13.2 期约
      • 13.2.1 使用期约
        • 使用期约处理错误
      • 13.2.2 期约链
      • 13.2.2 解决期约
      • 13.2.4 再谈期约和错误
        • catch和finally方法
      • 13.2.5 并行期约
      • 13.2.6 创建期约
        • 基于其他期约的期约
        • 基于同步值的期约
        • 从头开始创建期约
      • 13.2.7 串行期约
    • 13.3 async和await
      • 13.3.1 await表达式
      • 13.3.2 async 函数
      • 13.3.3 等候多个期约
      • 13.3.4 实现细节
    • 13.4 异步迭代
      • 13.4.1 for/await 循环
      • 13.4.2 异步迭代器
      • 13.4.3 异步生成器
      • 13.4.4 实现异步迭代器

13章 异步JavaScript

13.1 使用回调的异步编程

在最基本的层面上,JavaScript异步编程是使用回调实现的。回调就是函数,可以传给其他函数。而其他函数会在满足某个条件或发生某个(异步)事件时调用(“回调”)这个函数。回调函数被调用,相当于通知你满足了某个条件或发生了某个条件,有时这个调用还包含函数参数,能够提供更多细节。通过具体的示例会更容易理解这些,接下来的几个小姐将演示不同形式的基于回调的异步编程,包括客户端JavaScript和Node。

13.1.1 定时器

一种最简单的异步操作就是在一定事件过后运行某些代码。如11.10节所示,可以使用setTimeout()函数来实现这种操作。

setTimeout()函数的第一个参数是一个函数,第二个参数是以毫秒为单位的时间间隔。

setTimeout()只会调用一次指定的回调函数,不传参数,然后就没事了。如果你编写了一个确实会检查更新的函数,那可能需要重复运行它。此时可以使用setInterval()而非setTimeout():

//1分钟后调用checkForUbdates,然后每过1分钟就调用一次
let updateIntervalId=setInterval(checkForUpdates,60000);

//setInterval()返回一个值,把这个值传给clearInterval()
//可以停止这种重复调用(类似地,setTimeout()也返回一个值,
//可以把它传给clearTimeout())
function stopCheckingForUpdates(){
    clearInterval(updateIntervalId);
}

13.1.2 事件

客户端JavaScript编程几乎全都是事件驱动的。也就是说,不是运行某些预定义的计算,而是等待用户做一些事情,然后响应用户的动作。用户在按下键盘按键、移动鼠标、单击鼠标或轻点触摸屏设备时,浏览器会生成事件。事件驱动的JavaScript程序在特定上下文中为特定类型的事件注册回调函数,而浏览器在指定的事件发生时调用这些函数。这些回调函数叫做事件处理程序或者事件监听器,是通过addEventListener()注册的:

//要求浏览器返回一个对象,表示与下面的
//CSS选择符匹配的HTML

在这个示例这种,applyUpdate()是一个假想的回调函数,假设是我们在某个地方实现的。调用document.querySelector()会返回一个对象,表示网页中单个特定的元素。在这个元素上调用addEvenListener()可以注册回调函数。addEvenListener()的第一个参数是一个字符串,指定要注册的事件类型(在这里是一次鼠标单击或轻点触摸屏)。如果用户单击或轻点了网页中指定的那个元素,浏览器就会调用applyUpdate()回调函数,并给它传入一个对象,其中包含有关事件的详细信息(例如事件发生的时间和鼠标指针的坐标)。

13.1.3 网络事件

JavaScript编程中另一个常见的异步操作来源是网络请求。浏览器中运行的JavaScript可以通过类似下面的代码从Web服务器获取数据:

function getCurrentVersionNumber(versionCallback){  //注意回调参数
    let request=new XMLHttpRequest();
    request.open("GET","http://www.example.com/api/version");
    request.send();
    
    //注册一个将在响应到达时调用的回调
    request.onload=function(){
        if(request.status===200){
            //如果HTTP状态没问题,则取得版本号并调用回调
            let currentVersion=parseFloat(request.responseText);
            versionCallback(null,currentVersion);
        }else{
            //否则,通过回调报告错误
            versionCallback(response.statusText,null);
        }
    };
    //注册另一个将在网络出错时调用的回调
    request.onerror=request.ontimeout=function(e){
        versionCallback(e.type,null);
    };
}

客户端JavaScript代码可以使用XMLHttpRequest类及回调函数来发生HTTP请求并异步处理服务器返回的响应。这里定义的getCurrentVersionNumber()函数(可以想象13.1.1节讨论的那个假想的checkForUpdates()函数会使用它)会发送HTTP请求并定义事件处理程序,后者在收到服务器响应或者超时或其他错误导致请求失败时会被调用。

注意上面的代码示例并没有像之前的示例一样调用addEvenListener()。对于大多数Web API(包括XMLHttpRequest)来说,都可以通过在生成事件的对象上调用addEvenListener()并将相关事件的名字传给回调函数来定义事件处理程序。不过,一般也可以通过将回调函数赋值给这个对象的一个属性来注册事件监听器。我们在上面的示例代码中也是这么做的,即把函数赋值给onload、onerror和ontimeout属性。按照惯例,像这样的事件监听器属性的名字总是以on开头。相较而言,addEventListener()是一种更灵活的技术,因为它支持多个事件处理程序。不过假如你没有别的代码会给同一个对象的同一个事件再注册监听器,就可以简单一点,把相应的属性设置为你的回调。

关于这个示例中的getCurrentVersionNumber()函数,还有一点需要注意。因为发送的是异步请求,所以它不能同步返回调用者关心的值(当前版本号)。为此,调用者给它传了一个回调函数,在结果就绪或错误时会被调用。在这里,调用者提供了一个接收两个参数的回调函数。如果XMLHttpRequest正常工作,则getCurrentVersionNumber()调用回调时会给第一个参数传null,把版本号作为第二个参数。否则,如果发生错误,则getCurrentVersionNumber()调用回调时将错误细节作为第一个参数,将null作为第二个参数。

13.1.4 Node中的回调与事件

Node.js服务器端JavaScript环境底层就算异步的,定义了很多使用回调和事件的API。例如,读取文件内容的默认API就算异步的,会在读取文件内容后调用一个回调函数:

const fs=require("fs");    //"fs"模块有文件系统相关的API
let options={            //保存程序选项的对象
    //默认选项可以写在这里
}

//读取配置文件,然后调用回调函数
fs.readFile("config.json","utf-8",(err,text)=>{
    if(err){
        //如果有错误,显示一条警告消息,但仍然继续
        console.warn("Could not read config file:",err);
    }else{
        //否则,解析文件内容并赋值给选项对象
        Object.assign(options,JSON.parse(text));
    }
    
    //无论是上面情况,都会启动运行程序
    startProgram(options);
});

Node的fs.readFile()函数以接收两个参数的回调作为最后一个参数。它会异步读取指定文件,然后调用回调。如果读取文件成功,它会把文件内容传给第二个参数。如果发生错误,它会把错误传给回调的第一个参数。在这个实例中,我们把回调写成了一个箭头函数,对于这种简单操作,箭头函数即简洁又自然。

Node也定义了一些基于事件的API。下面这个函数展示了在Node中如何通过HTTP请求获取URL的内容。它包含两层事件监听器的异步代码。注意,Node使用on()方法而非addEvenListener()注册事件监听器

const https=require("https");

//读取URL的文本内容,将其异步传给回调
function getText(url,callback){
    //对URL发送一个HTTP GET请求
    request=https.get(url);
    
    //注册一个函数处理“response”事件
    request.on("response",response=>{
        //这个响应事件意味着收到了响应头
        let httpStatus=response.statusCode;
        
        //此时并没有收到HTTP响应体
        //因此还要注册几个事件处理程序,以便收到响应体时被调用
        response.setEncoding("utf-8");    //应该收到Unicode文本
        let body="";                      //需要在这里累计
        
        //每个响应体快就绪时都会调用这个事件处理程序
        response.on("data",chunk=>{body+=chunk;});
        
        //响应完成时会调用这个事件处理程序
        response.on("end",()=>{
            if(httpStatus===200){         //如果HTTP响应码没问题
                callback(null,body);      //把响应体传给回调
            }else{
                callback(httpStatus,null);
            }
        });
    });
    
    //这里也为底层网络错误注册了一个事件处理程序
    request.on("error",(err)=>{
        callback(err,null);
    });
}

13.2 期约

期约是一个对象,表示异步操作的结果。这个结果可能就绪也可能未就绪。而期约API在这方面故意含糊:没有方法同步取得期约的值,只能要求期约在值就绪时调用一个回调函数。假设我们要定义一个像上一节中getText()函数一样的异步API,但希望它基于期约,没有回调参数,返回一个期约对象。然后调用者可以在这个期约对象上注册一个或多个回调,当异步计算完成时,它们会被调用。

由此,在最简单的情况下,期约据说一种处理回调的不同方式。不过,使用期约也有实际的好处。基于回调的异步编程有一个现实问题,就是经常会出现回调多层嵌套的情形,找出代码缩进过多以致难以阅读。期约可以让这种嵌套回调以一种更线性的期约链形式表达出来,因此更容易阅读和推断。

回调的另一个问题难以处理错误。如果一个异步函数(或异步调用的回调)抛出异常,则该异常没有办法传播到异步操作的发起者。异步编程的一个基本事实就是它破坏了异常处理。对此,一个补救方式是使用回调参数严密跟踪和传播错误并返回值。但这样非常麻烦,容易出错。期约则标准化了异步错误处理,通过期约链提供了一种让错误正确传播的途径。

期约表示的是一次异常计算的未来结果。不过,不能使用它们表示重复的异步计算。本章后面,我们会用期约写一个代替setTimeout()的函数。但是,不能使用期约代替setInterval(),因为后者会重复调用回调函数,而这并不是设计期约所考虑的用例。类似地,可以使用期约代替XMLHttpRequest对象的“加载”事件处理程序,因为回调只会被调用一次。但显然不能使用期约代替HTML按钮对象的“单击”事件处理程序,因为我们通常允许用户多次单击按钮。

13.2.1 使用期约

自从核心JavaScript语言支持期约以后,浏览器也开始基于期约的API。上一节我们实现了一个getText()函数,它能发送异步HTTP请求,并将HTTP响应体传给以字符串形式指定的一个回调函数。想象以下这个函数还有一个变体叫getJSON(),它不接收回调参数,而是把HTTP响应体解析成JSON格式并返回一个期约。本章后面会实现这个getJSON()函数,现在我们先来看看怎么使用这个返回期约的辅助函数:

getJSON(url).then(jsonData=>{
    //这是一个回调函数,它会解析得到JSON值
    //之后被异步调用,并接收该JSON值作为参数
});

getJSON()像指定的URL发送一个异步HTTP请求,然后再请求结果待定期间返回一个期约对象。这个期约对象有一个实例方法叫then()。回调函数并没有被直接传给getJSON(),而是传给了then()方法。当HTTP响应到达时,响应体会被解析为JSON格式,而解析后的值会被传给作为then()的参数的函数。

可以把这个then()方法想象成客户端JavaScript中注册事件处理程序的addEvenListener()方法。如果多次调用一个期约对象的then()方法,则指定的每个函数都会在预期计算完成后被调用。

不过,与很多事件监听器不同,期约表示的是一次计算,每次通过then()方法注册的函数都只会被调用一次。有必要指出的是,即便调用then()时异步计算已经完成,传给then()的函数也会被异步调用。

在最简单的语法层面,then()方法是期约独有的特性,而直接把.then()附加给返回期约函数调用是一种惯用方法,不需要先把期约对象赋值给中间某个变量。

以动词开头来命名返回期约的函数以及使用期约结果的函数也是一种惯例。遵循这个惯例可以中间代码的可读性:

//假设你有一个类似的函数可以显示用户简介
function displayUserProfile(profile){
    /*  省略实现细节 */
}

//下面演示了如何在返回期约的函数中使用这个函数
//注意,这行代码读起来就像一句英语一样容易理解
getJSON("/api/user/profile").then(displayUserProfile);
使用期约处理错误

异步操作,尤其是那些涉及网络的操作,通常都会有多种失败原因。健壮的代码必须处理各种无法避免的错误。

对期约而言,可以通过给then()方法传第二个函数来实现错误处理:

getJSON("/api/user/profile").then(displayUserProfile,handleProfileError);

期约表示在期约对象被创建之后发生的异步计算的未来结果。因为计算是在返回期约对象之后执行的,所以没办法让该计算像以往那样返回一个值,或者抛出一个可以捕获的异常。我们传给then()的函数可以提供一个替代手段。同步计算在正常结束后会向调用者返回计算结果,而基于期约的异步计算在正常结束后,则会把计算结构传给作为then()的第一个参数的函数。

同步计算出错会抛出一个异常,而该异常会沿调用栈向上一直传播带一个处理它的catch子句。而异步计算在运行时,它的调用者依据不在调用栈里,因此如果出现错误,根本没办法向调用者抛回异常。

为此,基于期约的异步计算把异常(通常是某种Error对象,尽管不是必需的)传给作为then()的第二个参数的函数。因此对于上面的代码而言,如果getJSON()正常结束,它会把计算结果传给displayUserProfile()。如果出现了错误(如用户没有登陆、服务器下线、用户网络中断、请求超时等),则getJSON()会把Error对象传给handleProfileError()。

实际开发中,很少看到给then()传两个函数的情况。因为在使用期约时,还有一种更好也更符合传统的错误处理方式,可以先考虑以下如果getJSON()正常结束但displayUserProfile()中发生错误会怎么样。回调函数在getJSON()返回时是被异步调用的,因此也是异步执行的,不能明确地抛出一个异常(因为调用栈里没有处理这种异常的代码)。

处理这个代码中错误的更符合传统的方式如下:

getJSON("/api/user/profile").then(displayUserProfile).catch(handleProfileError);

这行代码意味着getJSON()正常返回的结果仍然会传给dispalyUserProfile(),但getJSON()和displayUserProfile()在执行时发生的任何错误(包括displayUserProfile抛出的任何异常)都会传给handleProfileError()。这个catch()方法只是对调用then()时以null作为第一个参数,以指定的错误处理函数作为第二个参数的一种简写形式。

13.2.2 期约链

期约有一个最重要的有点,就是以线性then()方法调用链的形式表达一连串的异步操作,而无须把每个操作嵌套在前一个操作的回调内部。例如,下面是一个假想的期约链:

fecth(documentURL).then(                 //发送HTTP请求
    response=>response.json()).then(     //获取JSON格式的响应体
    document=>{
        return render(document);         //把文档显示给用户
    }).then(rendered=>{                  //在取得渲染的文档后
    cacheInDatabase(rendered);           //把它渲染在本地数据库中
}).catch(error=>handle(error));          //处理发生的错误

以上代码说明期约链更容易表达一连串异步操作。在此,我们并不打算讨论这个实例的期约链,而是要继续探讨使用期约链发送HTTP请求。

本章前面,我们看到了在JavaScript中使用XMLHttpRequest对象发送HTTP请求的示例。那个名字有点怪的对象有着古老而简陋的API,很大程度上以及被更新的基于期约的Fetch API(参见15.11.1节)取代。这个新HTTP API的最简单形式就是函数fetch()。传给它一个URL,它返回一个期约。这个期约会在HTTP响应开始到达且HTTP状态和头部可用时兑现:

fetch("/api/user/profile").then(response=>{
    //在期约解决时,可以访问HTTP状态和头部
    if(response.ok&&
       response.headers.get("Content-Type")==="apllication/json"){
        //在这里可以做什么?现在还没有得到响应体
    }
});

在fecth()返回的期约兑现时,传给它的then()方法的函数会被调用,这个函数会收到一个Response对象。通过这个响应对象可以访问请求状态和头部,这个对象也定义了text()和json()等方法,通过它们分别可以取得文本和JSON格式的响应体。不过,虽然最初的期约兑现了,但响应体尚未到达,因此取得响应体的text()和json()方法本身也返回期约。下面是使用fetch()和response.json()方法取得HTTP响应体的幼稚方式:

fetch("api/user/profile").then(response=>{
    response.json().then(profile=>{      //获取JSON格式的响应体
        //在响应体到达时,它会自动被解析为
        //JSON这个格式并传入这个函数
        displayUserProfile(profile);
    });
});

说这是使用期约的幼稚方式,是因为我们像嵌套回调一样嵌套了它们,而这违背了期约的初衷。使用期约的首选方式是像以下代码这样写成一串期约链:

fetch("api/user/profile").then(response=>{
    response.json();
}).then(profile=>{
    displayUserProfile(profile);
});

这段代码中的方法调用忽略了传给方法的参数:

fetch().then().then()

像这样在一个表达式中调用多个方法,我们称其为方法链。我们直到,fetch()函数返回一个期约对象,而这个链上的第一个.then()调用了返回的期约对象上的一个方法。不过链中还有第二个.then(),这意味着第一个then()方法调用本身必须返回一个期约。

有时候,当API被设计为使用这种方法链时之后有一个对象,它的每个方法都返回对象本身,以便后续调用。然而这并不是期约的工作方式。我们在写.then)调用链时,并不会在一个期约上注册多个回调。相反,每个then()方法调用都返回一个新期约对象。这个新期约对象在传给then()的函数执行结果结束才会兑现。

我们再回到上面那个原始fetch()链的简化形式。如果我们在别的地方定义了要传给then()的函数,那么可以把代码重构为如下所示:

fetch(theURL)              //任务1,返回期约1
    .then(callback1)       //任务2,返回期约2
    .then(callback2);      //任务3,返回期约3

下面我们逐步剖析这段代码。

  1. 第1行,调用fetch()并传入一个URL。这个方法会向该URL发送一个HTTP GET请求并返回一个期约。我们称这个HTTP请求为“任务1”,称这个期约为“期约1”。
  2. 第2行,调用期约1的then()方法,传入callback1函数,我们希望这个函数在期约1兑现时被调用。这个then()方法会把我们的回调保存早某个地方,并返回一个新期约。我们称这一步返回的新期约为“期约2”,并说“任务2”在callback1被调用时开始。
  3. 第3行,调用期约2的then()方法,传入callback2函数,我们希望这个函数在期约2兑现时被调用。这个then()方法会记住我们的回调并返回另一个期约。我们说“任务3”在callback2被调用时开始,并称最后这个期约为“期约3”,但实际上并不需要给它命名,因为根本不会使用它。
  4. 当这个表达式一开始执行,前面3步将同步发生。然后在第1步创建的HTTP请求通过互联网发出时有一个异步暂停。
  5. 终于,HTTP响应开始到达。fetch()调用的异步逻辑将HTTP状态个头部包装到一个Response对象中,并将这个兑现作为值兑现期约1。
  6. 期约1兑现后,它的值(Response对象)会传给callback1()函数,此时任务2开始。这个任务的职责是以给定的Response对象作为输入,获取JSON格式的响应体。
  7. 假设任务2正常结束,即成功解析HTTP响应体并生成了一个JSON对象。然后这个JSON对象被用于兑现期约2。
  8. 兑现期约2的值在传给callback2()函数时变成了任务3的输入。然后任务3以某种方式把数据显示给用户。任务3完成时(假设正常结束),期约3也会兑现。但由于我们并未给期约3注册回调,因此在它落定时什么也不会发生。此时这个异步计算链结束。

13.2.2 解决期约

在上一节逐步分析抓取URL的期约链时,我们说到了期约1、期约2和期约3。但实际 上这里还有第4个期约对象。而这也引出了我们关于“解决”(resolve)期约意味着什么的重要讨论。

我们知道,fetch()返回一个期约兑现,在兑现时,它会把一个Response兑现传给我们注册的回调函数。这个Response对象有.text()、.json()以及其他方法,用于获取不同格式的HTTP响应体。但由于响应体可能并未到达,这些方法必需返回期约对象。在我们研究的这个示例中,“任务2”调用.json()方法并返回它的值。这个值就是第4个期约对象,也就是callback1()函数的返回值。

下面我们再写一次抓取URL的代码,这一次使用冗余和非惯用方法,以便回调和期约更加明显:

function c1(response){       //回调1
    let p4=response.json();
    return p4;
}

function c2(profile){        //回调2
    displayUserProfile(profile);
}

let p1=fetch("/api/user/profile");     //期约1,任务1
let p2=p1.then(c1);                    //期约2,任务2
let p3=p2.then(c2);                    //期约3,任务3

为了让期约链有效工作,任务2的输出必需成为任务3的输入。在该示例中,任务3的输入是从URL抓取到响应体又解析生成的JSON对象。不过,正如刚才所说,回调c1的返回值并不是一个JSON对象,而是表示该JSON对象的期约p4。这看起来好像矛盾了,但并没有:在p1兑现后,c1被调用,任务2开始。但当p2兑现时,c2被调用,任务3开始。不过,c1被调用时任务2开始并不意味着任务2一定在c1返回时结束。期约就是用于管理异步任务的,如果任务2是异步的(这里确实是),那么它在回调返回时就不会结束。

下面我们可以讨论真正掌握期约需要理解的最后一个细节了。当把回调c传给then()方法时,then()返回期约p,并安排好在将来某个时刻异步调用c。届时,这个回调执行某些计算并返回一个值v。当这个回调返回值v时,p就以这个值得到解决。当期约以一个非期约值解决时,就会立即以这个值兑现。因此如果c返回非期约值,即该返回值就变成了p的值,然后p兑现,结束。可是,如果这个返回值v是一个期约,那么p会得到解决但未兑现。此时,p要等到期约v落定之后才能落定。如果v兑现了,那么p也会以相同的值兑现。如果v被拒绝了,那么p也会以相同的理由被拒绝。这就是期约“解决”状态的含义,即一个期约与另一个期约发生了关联(或“锁定”了另一个期约)。此时我们并不自带p将会兑现还是被拒绝。但回调c以及无法控制这个结果了。说p得到了“解决”,意思就是现在它的命运完全取决于期约v会怎么样。

好,我们再回到抓取URL的示例。当c1返回p4时,p2得到解决。但解决并不等同于兑现,因此任务3还不会开始。当HTTP响应体全部可用时,.json()方法才可以解析它并以解析后的值兑现p4。p4兑现后,p2也会自动以该解析后的JSON值兑现。此时,解析后的JSON兑现被传给c2,任务3开始。
JavaScript权威指南 第13章 异步JavaScript_第1张图片

13.2.4 再谈期约和错误

本章前面,我们介绍过可以给.then()方法传第二个回调函数,而这第二个函数会在期约被拒绝时调用。在这种情况发生时,传给第二个回调函数的参数是一个值(通常是一个Error对象),表示拒绝理由。我们也知道给一个.then()方法传两个回调是很少见的(甚至并非惯用方法)。事实上,基于期约的错误一般是通过给期约链添加一个.catch()方法调用来处理的。既然我们已经了解了期约链,现在可以更详尽地讨论错误处理了。在讨论之前,我想强调一点:细致的错误处理在异步编程中确实非常重要。在同步代码中,如果不编写错误处理逻辑,你至少会看到异常和栈追踪信息,从而能够查找出错的原因。而在异步代码中,未处理的异常不会得到报告,错误只会静默发生,导致它们更难调试。好消息是.catch()方法可以让处理错误更容易。

catch和finally方法

期约的.catch()方法实际上是对null为第一个参数,以错误处理回调为第二个参数的.then()调用的简写。对于任何期待期约p和错误回调c,以下两行代码是等价的:

p.then(null,c);
p.catch(c);

之所以应该首选.catch()简写形式,一方面是因为它更简单,另一方面是因为它的名字对应try/catch异常处理语句的catch子句。如前所述,传统的异常处理在异步代码中并不适用。当同步代码出错时,我们可以说一个异常会“沿着调用栈向上冒泡”,直到碰上一个catch块。而对于异步期约链,类似的比喻可能是一个错误“沿着期约链向下流淌”,直到碰到一个.catch()调用。

在ES2018中,期约对象还定义了一个.finally()方法,其用途类似try/catch/finally语句的finally子句。如果你在期约链中添加一个.finally()调用,那么传给.finally()的回调会在期约落定时被调用。无论这个期约是兑现还是被拒绝,你的回调都会被调用,而是调用时不会给它传任何参数,因此你也无法知晓期约是兑现了还是被拒绝了。但假如你需要在任何情况下运行一些清理代码(如关闭打开的文件或网络连接),那么.finally()回调是做这件事的理想方式。与.then()和.catch()一样,.finally()也返回一个新期约对象。但.finally()回调的返回值通常会被忽略,儿解决或拒绝调用finally()的期约的值一般也会用来解决或拒绝.finally()返回的期约。不过,如果.finally()回调抛出异常,就会用这个错误值拒绝.finally()返回的期约。

前几节展示的URL抓取代码并没有做任何错误处理。下面我们来改正一下,并将其重构为一个更接近现实的版本:

fetch("/api/user/profile").then( //发送http请求
    response=>{                  //在状态和头部就绪时调用
        if(!response.ok){        //如果遇到404 Not Found或类似的错误
            return null;         //可能用户未登录,返回空简介
        }
        
        //检查头部以确保服务器发送给我们的是JSON
        //如果不是,说明服务器坏了,这是一个严重错误
        let type=response.headers.get("content-type");
        if(type!=="application/json"){
            throw new TypeError(`Expected JSON, got ${type}`);
        }
        
        //如果看到了,说明状态码是2xx,内容类型也是JSON
        //因此我们可以安心地返回一个期约,表示解析响应体
        //之后得到的JSON对象
        return response.json();
    }).then(profile=>{       //调用时传入解析后的响应体或null
    if(profile){
        displayUserProfile(profile);
    }else{                   //如果遇到了404错误并返回null,则会走到这里
        displayLoggedOutProfilePage();
    }
}).catch(e=>{
    if(e instanceof NetworkError){
        //fetch()在互联网连接故障时会走到这里
        displayErrorMessage("Check your internet connection.");
    }else if(e instanceof TypeError){
        //在上面抛出TypeError时会走到这里
        displayErrorMessage("Something is wrong with our server!");
    }else{
        //走到这里说明发生了意料之外的错误
        console.error(e);
    }
})

关于以上代码有两点需要说明一下。第一,注意这个错误对象是以常规、同步throw语句抛出的,而该错误最终在期约链中北与各.catch()方法调用处理。这充分说明为什么应该尽量适用这种简写形式,而不是给.then()传第二个参数。同时也说明了为什么在期约链末尾添加一个.catch()调用是个惯例。

在结束错误处理之前,我想再指出一点。尽管在期约链末尾加上一个.catch()来清理(或至少记录)链调用中发生的错误是一个惯例,再期约链的任何地方使用.catch()也是完全有效的。如果期约链的某一环会因错误而失败,而该错误属于某种可恢复的错误,不应该停止后续代码的运行,那么可以在链中插入一个.catch()调用,得到类似如下的代码:

startAsyncOperation().then(
    doStageTwo).catch(recoverFromStageTwoError).then(
    doStageThree).then(
    doStageFour).catch(
    logStageThreeAndFourErrors);

记住,传给.catch()的回调只会在上一环的回调抛出错误时才会被调用。如果返回调正常返回,那么这个.catch()回调就会被跳过,之前回调返回的值会成为下一个.then()回调的输入。还有,.catch()回调不仅仅可以用于报告错误,还可以处理错误并从错误中恢复。一个错误只要传给了.catch()回调,就会停止在期约链中向下传播。.catch()回调可以抛出新错误,但如果正常返回,那这个返回值就会用于解决或兑现与之关联的期约,从而停止错误传播。

13.2.5 并行期约

我们需要并行执行多个异步操作。函数Promise.all()可以做到这一点。Promise.all()接收一个期约兑现作为输入,返回一个期约。如果输入期约中的任意一个拒绝,返回的期约也将拒绝;否则,返回的期约会以每个输入期约兑现值的数组兑现。例如,假如你想抓取多个URL的文本内容,可以使用如下代码:

//先定义一个URL数据
const urls=[/* 零或多个URL */];
//然后把它转换为一个期约对象的数组
promises=urls.map(url=>fetch(url).then(r=>t.text()));
[]
//现在用一个期约来并行运行数组中的所有期约
Promise.all(promises).then(bodies=>{
    /* 处理得到的字符串数组 */
}).catch(e=>console.error(e));

Promise.all()实际上比刚才描述的稍微灵活一些。其输入数组可以包含期约对象和非期约值。如果这个数组的某个元素不是期约,那么它就会被当成一个已兑现期约的值,被原封不动地复制到输出数组中。

由Promise.all()返回的期约会在任何一个输入期约被拒绝时拒绝。这会在第一个拒绝发生时立即发生,此时其他期约的状态可能还是待定。在ES2020,Promise.allSettled()也接收一个输入期约的数组,与Promise.all()一样。但是,Promise.allSettled()永远不拒绝返回的期约,而是会等所有输入期约全部落定后都兑现。这个返回的期约解决为一个对象数组,其中每个对象都对应一个输入期约,且都有一个status属性,值为fulfilled或rejected。如果status属性值为fulfilled,那么该对象还会有一个value属性,包含兑现的值。而如果status属性值为rejected,那么该对象还会有一个reason属性,包含对应期约的错误或拒绝理由:

Promise.allSettled([Promise.resolve(1),Promise.reject(2),3]).then(results=>{
    console.log(results[0]);
    console.log(results[1]);
    console.log(results[2]);
});
=>VM1978:2 {status: "fulfilled", value: 1}
=>VM1978:3 {status: "rejected", reason: 2}
=>VM1978:4 {status: "fulfilled", value: 3}

你可能偶尔想同时运行多个期约,但只关心第一个兑现的值。此时,可以使用Promise.race()而不是Promise.all()。Promise.race()返回一个期约,这个期约会在输入数组中的期约有一个兑现或拒绝时马上兑现或拒绝(或者,如果输入数组中有非期约值,则直接返回其中第一个非期约值)。

13.2.6 创建期约

在前面的几个示例中,我们一直使用返回期约的函数fetch(),因为它是浏览器内置返回期约的一个最简单的函数。我们关于期约的讨论也建立在假想的返回期约的函数getJSON()和wait()上。让函数返回期约是很有用的,本节将展示如何创建你自己基于期约的API。特别地,我们会看到getJSON()和wait()的实现。

基于其他期约的期约

如果有其他返回期约的函数,那么基于这个函数写一个返回期约的函数很容易。给定一个期约,调用.then()就可以创建(并返回)一个新期约。因此,如果以已有的fetch()函数为起点,可以像下面这样实现getJSON():

function getJSON(url){
    return fetch(url).then(response=>response.json());
};
基于同步值的期约

有时候,我们可能需要实现一个已有的基于期约的API,并从另一个函数返回期约,尽管要执行的计算实际上并不涉及操作。在这种情况下,静态方法Promise.resolve()和Promise.reject()可以帮你达成目的。Promise.resolve()接收一个值作为参数,并返回一个会立即(但异步)以该值兑现的期约。类似地,Promise.reject()也接收一个参数,并返回一个以该参数作为理由拒绝的期约(明确一下:这两个静态方法返回月份参数,并返回一个以该参数作为理由拒绝的期约(明确一下:这两个静态方法返回的期约在被返回时并未兑现或拒绝,但它们会在当前同步的代码块运行结束后立即兑现或拒绝。通常,这会在几毫米之后发生,除非有很多特定的异步代码等待运行)。

我们在13.2.3节讨论过,解决期约不等于兑现期约。调用Promise.resolve()时,我们通常会传入兑现值,创建一个花开就兑现为该值的期约兑现。但这个方法的名字并不叫Promise.fulfill()。如果把期约p1传给Promise.resolve(),它会返回一个新期约p2,p2会立即解决,但要等到p1兑现或被拒绝时才会兑现或被拒绝。

写一个基于期约的函数,其中值是同步计算得到的,但使用Promise.resolve()异步返回是可能的,但不常见。不过在一个异步函数中包含同步执行的代码,通过Promise.resolve()和Promise.reject()来处理这些同步操作的值倒是相当常见。特别地,如果在开始异步操作前检测错误条件(如环参数值),那可以通过返回Promise.reject()常见的期约来报告该错误(这种情况下也可以同步抛出一个错误,但这种做法并不推荐,因为这样依赖,函数的调用中为了处理错误既要写同步的catch子句,还要使用异步的.catch()方法)。最后,Promise.resolve()有时候也可以用来创建一个期约链的第一个期约。稍后可以看到几个这样使用它的示例。

从头开始创建期约

对于getJSON()和getHignScore(),我们都是一开始调用一个现有函数得到初始期约,然后再铜鼓调用该期约的.then()方法创建并返回新期约。如果我们不能使用一个返回期约的函数作为起点,那么怎么写一个返回期约的函数呢?这时候,可以使用Promise()构造函数,给它传一个函数作为唯一参数。传的这个函数需要写成接收两个参数,按惯例要将它们命名为resolve和reject。构造函数同步调用你的函数并为resolve和reject参数传入对应的参数值。调用你的函数后,Promise()构造含返回新创建的期约。这个返回的期约由你传给Promise()构造函数的函数控制。你传的函数应该执行某些异步操作,然后调用resolve函数解决或兑现返回的期约,或者调用reject函数拒绝返回的期约。你的函数不一定非要执行异步操作,可以同步调用resolve或reject,但此时创建的期约仍会异步解决、兑现或拒绝。

如果单凭阅读代码,可能很难理解把一个函数传给构造含,而构造函数又把其他函数传给这个函数。也许看几个示例就好理解了。下面是本章前面几个示例中用到的居于期约的wait()函数的实现:

function wait(duration){
    //创建并返回期约
    return new Promise((resolve,reject)=>{   //这两个函数控制期约
        //如果参数无效,拒绝期约
        if(duration<0){
            reject(new Error("Time travel not yel implemented"));
        }
        
        //否则,异步等待,然后解决期约
        //setTimeout调用resolve()时未传参
        //这意味着新期约会以undefined值兑现
        setTimeout(resolve,duration);
    });
}

注意,用来控制Promise()构造函数创建的期约命运的那对函数叫resolve()和reject(),不是fulfill()和reject()。如果把一个期约传给resolve(),返回的期约将会解决为该期约。不过,通常在这里都会传一个非期约值,这个值会兑现返回的期约。

示例13-1:异步getJSON()函数

const http=require("http");

function getJSON(url){
    //创建并返回一个新期约
    return new Promise((resolve,reject)=>{
        //向指定的URL发送一个HTTP GET请求
        request=http.get(url,response=>{  //收到响应时调用
            //如果HTTP状态码不对,拒绝这个期约
            if(response.statusCode!==200){
                reject(new Error(`HTTP status ${response.statusCode}`));
                response.resume();     //这样不会导致内存泄漏
            }
            //如果响应不对同样拒绝
            else if (response.headers["content-type"]!=="application/json"){
                reject(new Error("Invalid content-type"));
                reponse.resume();  //不造成内存泄漏
            }else{
                //否则,注册事件处理程序读取响应体
                let body="";
                response.setEncoding("utf-8");
                response.on("data",chunk=>{body+=chunk});
                response.on("end",()=>{
                    //接收全部响应体后,尝试解析它
                    try{
                        let parsed=JSON.parse(body);
                        //如果解析成功,兑现期约
                        resolve(parsed);
                    }catch(e){
                        //如果解析失败,拒绝期约
                        reject(e);
                    }
                });
            }
        });
        
        //如果收到响应之前请求失败(如网络故障),
        //我们也可以拒绝期约
        request.on("error",error=>{
            reject(error);
        });
    });
}

13.2.7 串行期约

使用Promise.all()可以并行执行任意数量的期约,而期约链则可以表达一连串固定数量的期约。不过,按顺序运行任意数量的期约有点棘手。比如,假设我们有一个要抓取URL数组,但为了避免网络过载,你想一次只抓取一个URL。假如这个数组是任意长度,内容也未知,那就提前把期约链写出来,而是要像以下代码这样动态构建:

function fetchSequentially(urls){
    //抓取URL时,要把响应体保存在这里
    const bodies=[];
    
    //这个函数返回一个期约,它只抓取一个URL响应体
    function fetchOne(url){
        return fetch(url).then(response=>
                               response.text()).then(body=>{
            //把响应体保存到数组,这里故意
            //省略了返回值(返回undefined)
            bodies.push(body);
        });
    }
    
    //从一个立即(以undefined值)兑现的期约开始
    let p=new Promise.resolve(undefined);
    
    //现在循环目标URL,构建任意长度期约链,
    //链的每个环节都会拿取一个URL的响应体
    for(url of urls){
        p=p.then(()=>fetchOne(url));
    }
    
    //期约链的最后一个期约兑现,响应体数组
    //也已经就绪。因此,可以加个这个bodies数组通过期约
    //返回。注意,这里并未包含任何错误处理程序
    //我们希望把错误传播给调用者
    return p.then(()=>bodies);
}

有了这个fetchSequentially()函数定义,就可以像使用前面演示的Promise.all()并行抓取,按顺序依次抓取每个URL:

fetchSequentially(urls).then(bodies=>{
    /* 处理抓到的字符串数组 */
}).catch(e=>console.error(e));

fetchSequentially()函数实现会创建一个返回后立即兑现的期约。然后基于这个初始期约构建一个线性的长期约链并返回链中的最后一个期约。这有点类似摆好一排多米诺骨牌,然后推倒第一张。

还有一种(可能更简练)的实现方式。不是事先创建期约,而是让每个期约创建并返回下一个期约。换句话说,不是创建并连缀一串期约,而是创建解决为其他期约的期约。这种方式创建的就不是多米诺骨牌形式的期约链了,而是像俄罗斯套娃一样一系列相互嵌套的期约。此时,我们的代码可以返回第一个(最外层的)期约,知道它最终会兑现(或拒绝)为序列中最后一个(最内层的)期约兑现(或拒绝)的值。下面这个promiseSequense()函数是一个通用函数,不限于抓取URL。之所以把它放到讨论期约的最后,是因为它比较复杂。特别要注意promiseSequence()内部地那个函数,看起来它是在递归地调用滋生,但因为这个“递归”调用是通过.then()方法完成地,所以不会有任何传统递归的行为发生:

//这个函数接收一个输入值数组和一个promiseMaker函数
//对输入数组中的任何值x,promiseMaker(x)都应该返回
//一个兑现为输出值的期约。这个函数返回一个期约,该期约
//最终会兑现为一个包含计算得到的输出值的数组
//
//promiseSequence()不是一次创建所有期约然后让它们
//并行运行,而是没错只运行一个期约,直到上一个期约兑现
//之后,才会调用promiseMaker()计算下一个值
function promiseSequense(inputs,promiseMaker){
    //为数组创建一个可以修改的私有副本
    inputs=[...inputs];
    
    //这是要用作期约回调的函数
    //它的伪递归魔术是核心逻辑
    function handleNextInput(outputs){
        if(inputs.length===0){
            //如果已经没有输入值了,则返回输出值的数组
            //这个数组最终兑现这个期约,以及所有之前
            //以及解决但尚未兑现的期约
            return outputs;
        }else{
            //如果还有要处理的输入值,那么我们将返回一个期约对象,
            //把当前期约解决为一个来自新期约的未来值
            let nextInput=inputs.shift();  //取得下一个输入值
            return promiseMaker(nextInput).then(//计算下一个输出值
                //然后用这个新输出值创建一个新输出值的数组
                output=>outputs.concat(output))
            //然后“递归”,传入新的,更长的输出值的数组
            .then(handleNextInput);
        }
    }
    
    //从一个以空数组兑现的期约开始
    //使用上面的函数作为它的回调
    return Promise.resolve([]).then(handleNextInput);
}

这个promiseSequence()故意写成通用的。我们可以像下面这样使用它抓取多个URL的响应:

//传入一个URL,返回一个以该URL的响应体文本兑现的期约
function fetchBody(url){
    return fetch(url).then(r=>r.text());
}

//使用它依次抓取一批URL的响应体
promiseSequense(urls,fetchBody).then(bodies=>{
    /* 处理字符串数组 */
}).catch(console.error);
Promise {<fulfilled>: undefined}

13.3 async和await

ES2017新增了两个关键字:async和await,代表异步JavaScript编程范式的迁移。这两个新关键字极大简化了期约的使用,允许我们像编写因特网络请求或其他异步事件而阻塞的同步代码一样,编写基于期约的异步代码。虽然理解期约的工作原理仍然很重要,但在通过asyns和await使用它们时,很多复杂性(有时候甚至连期约自身的存在感)都消失了。

正如本章前面所讨论的,异步代码不能像常规代码那样返回一个值或者抛出一个异常。这也是期约会这么涉及的原因所在。兑现期约的值就像一个同步函数返回的值,而拒绝期约的值就像一个同步函数抛出的值。后者的相似性通过.catch()方法的命名变得很明确。async和await接收基于期约的高效代码并且隐藏期约,让你的异步代码像低效阻塞的同步代码一样任意理解和推理。

13.3.1 await表达式

await关键字接收一个期约并将其转换为一个返回值或一个抛出的异常。给定一个期约p,表达式await p会一直等到p落定。如果p兑现,那么await p的值就是兑现p的值。如果p被拒绝,那么await p表达式就会抛出拒绝p的值。我们通常并不会使用await来接收一个保存期约的变量,更多的是把它放在一个会返回期约的函数调用前面:

let response=await fetch("/api/user/profile");
let profile=await response.json();

这里的关键是要明白,await关键字并不会导致你的程序阻塞或者在指定的期约落定前上面都不做。你的代码仍然是异步的,而await只是掩盖了这个事实。这意味着任何使用await的代码本身都是异步的。

13.3.2 async 函数

因为任何使用async的代码都是异步的,所以有一条重要的规则:只能在以async关键字声明的函数内部使用await关键字。以下是使用async和await将本章前面的getHignScore()函数重写之后的样子:

async function getHignScore(){
    let response=await fetch("/api/user/profile");
    let profile=await response.json();
    return profile.highScore;
}

把函数声明为async意味着该函数的返回值将是一个期约,即便函数体中不出现期约相关的代码。如果async函数会正常返回,那么作为该函数真正返回值的期约兑现将解决为这个明显的返回值。如果async函数会抛出异常,那么它返回的期约对象将以该异常被拒绝。

这个getHignScore()函数前面加了async,因此它会返回一个期约。由于它返回期约,所以我们可以对它使用await关键字:

displayHighScore(await getHighScore());

不过要记住,这行diamond只有在它位于另一个async函数内部时才能运行!你可以在async函数中嵌套await表达式,多深都没关系。但如果是在顶级或因为某种原因在一个非async函数内部,那么就不能使用await关键字,而是必须以常规方式来处理返回的期约:

getHignScore().then(displayHignScore).catch(console.error);

可以对任何函数使用async关键字。例如,可以在function关键字作为语句和作为表达式时使用,也可以对箭头函数和类对象字面量中的简写方法使用(关于不同函数的各种写法,可以参考第八章)。

13.3.3 等候多个期约

假设我们使用async写重写了getJSON()函数:

async function getJSON(url){
    let response=await fetch(url);
    let body=await response.json();
    return body;
}

再假设我们想使用这个函数抓取两个JSON值:

let value1=await getJSON(url1);
let value2=await getJSON(url2);

以上代码的问题在于它不必顺序执行。这样写就意味着必须等到抓取第一个URL的结果才会开始抓取第二个URL的值。如果第二个URL并不依赖从第一个URL抓取的值,那么应该可以尝试同时抓取两个值。这个示例显示了async函数本质上是基于期约的。要等候一组并发执行的async函数,可以像使用期约一样直接使用Promise.all():

let [value1,value2]=await Promise.all([getJSON(url1),getJSON(url2)]);

13.3.4 实现细节

最后,为了理解async函数的工作原理,有必要了解以下后台都发生了什么。

假设你写了这样一个的async函数:

async function f(x){ /* 函数体 */}

可以把这个函数想象成一个返回期约的包装函数,它包装了你原始函数的函数体:

function f(x){
    return new Promise(function(resolve,reject){
        try{
            resolve((function(x){/* 函数体 */})(x));
        }catch(e){
            reject(e);
        }
    });
}

像这样以语法转换的形式解释await关键字比较困难。但可以把await关键字想象成分隔代码体的记号,它们把函数体分隔成相对独立的同步代码块。ES2017解释器可以把函数体分隔成一系列独立的子函数,每个子函数都将被传给位于它前面的以await标记的那个期约的then()方法。

13.4 异步迭代

本章开始先讨论了基于回调和事件的异步编程,之后再介绍期约时,也强调过它知识和单次运行的异步计算,不适合与重复性异步事件来源一起使用,例如setInterval()、浏览器中的click事件,或者Node流的data事件。由于一个期约无法用于连续的异步事件,我们也不是使用常规的async函数和await语句来处理这些事件。

不过,ES2018为此提供了一个解决方案。异步迭代器与第十二章描述的迭代器类似,但它们是基于期约的,而是hi用时要配合一个新的for/of循环:for/await。

13.4.1 for/await 循环

Node 12的可读流实现了异步可迭代。这意味着可以像下面这样使用for/await循环从一个流中读取连续的数据块:

const fs=require("fs");

async function parseFile(filename){
    let stream=fs.createReadStream(filename,{encoding:"utf-8"});
    
   for await(let chunk of stream){
       parseChunk(chunk);    //假设parseChunk()是再其他地方定义的
   }
}

与常规的await表达式类似,for/await循环也是基于期约的。大体上来说,这里的异步迭代器会产生一个期约,而for/await循环等待该期约兑现,将兑现值赋给循环变量,然后再运行循环体之后再从头开始,从迭代器取得另一个期约并等待新期约兑现。

假设有如下URL数组:

const urls=[url1,url2,url3];

可以对每个URL调用fetch()以取得一个期约的数组:

const promises=urls.map(url=>fetch(url));

在本章前面我们看到过,此时可以使用Promise.all()来等待数组中的所有期约兑现。但假设我们希望第一次抓取的结果尽快可用,不想因此而等待抓取其它URL(当然,也许第一次抓取的时间是最长的,因此这样不一定比使用Promise.all()更快)。数组是可迭代的,因此我们使用常规的for/of循环来迭代这个期约数组:

for(const promise of promises){
     response=await promise;
     handle(response):
}

这个示例代码使用了常规的for/of循环和一个常规迭代器。但由于这个迭代器返回期约,所以我们也可以使用新的for/await循环让代码更简单:

for await(const response of promises){
    handle(response);
}

这里的for/await循环只是把await调用内置在循环中,从而让代码稍微简洁了一点。但这两个示例做的事情是一样的。关键在于,这两个示例只能在以async声明的函数内部才能使用。从这方面来说,for/await循环与常规的await表达式没什么不同。

不过,最重要的是应该知道,在这个示例中我们是对一个常规的迭代器使用了for/await。如果是完全异步的迭代器,那么还会更有意思。

13.4.2 异步迭代器

让我们来回顾第12章的术语,可迭代兑现是可以在for/of循环中使用的对象。它以一个符号名字Symbol.iterator定义了一个方法,该方法返回一个迭代器对象。这个迭代器对象有一个next()方法,可以反复调用它获取可迭代对象的值。迭代器对象的这个next()方法返回迭代结果对象。迭代结果对象有一个value属性或一个done属性。

异步迭代器与常规迭代器非常相似,但有两个重要区别,第一,异步可迭代对象以符号Symbol.asyncIterator而非Symbol.iterator实现了一个方法(如前所示,for/await与常规迭代器兼容,但它更适合异步可迭代对象,因此会在尝试Symbol.iterator法前先尝试Symbol.asyncIterator方法(如前所示,for/await与常规迭代器兼容,但它更适合异步可迭代对象,因此会在尝试Symbol.iterator法前先尝试Symbol.asyncIterator方法)。第二,异步迭代器的next()方法返回一个期约,解决为一个迭代器结果对象,而不是直接返回一个迭代器结果对象。

JavaScript权威指南 第13章 异步JavaScript_第2张图片

13.4.3 异步生成器

如前12章所述,实现迭代器的最简单方式通常是使用生成器。同理,对于异步迭代器也是如此,我们可以使用声明为async的生成器函数来实现它。声明为async的异步生成器同时具有异步函数和生成器的特性,即可以像在常规异步函数中一样使用await,也可以像在常规生成器中一样使用yield。但通过yield生成的值会自动包装到期约中。就连异步生成器的语法也是async function和function *的组合:async function *。下面这个示例展示了使用异步生成器和for/await循环,通过循环代码而非setInterval()回调函数实现以固定的时间间隔重复运行代码:

//一个基于期约的包装函数,包装setTimeout()以实现等待
//返回一个期约,这个期约会在指定的毫秒数之后兑现
function eslapsedTime(ms){
    return new Promise(resolve=>setTimeout(resolve,ms));
}

//一个异步迭代器函数,按照指定的时间间隔
//递增并生成指定(或无穷)个数的计数器
async function* clock(interval,max=Infinity){
    for(let count=1;count<=max;count++){   //常规for循环
        await eslapsedTime(interval);      //等待时间流逝
        yield count;                       //生成计数器
    }
}

//一个测试函数,使用异步迭代器和for/await
undefined
async function test(){              //使用async声明,以便使用for/await
    for await(let tick of clock(300,100)){  //循环100次,每次间隔300ms
        console.log(tick);
    }
}

13.4.4 实现异步迭代器

除了使用异步生成器实现异步迭代器,还可以直接实现异步迭代器。还需要定义一个包含Symbol.asyncIterator()方法的对象,该方法要返回一个包含next()方法的对象,而这个next()方法要返回解决为一个迭代器结果对象的期约。在下面的代码中,我们重新实现了前面示例中的clock()函数。但它在这里并不是一个生成器,只是会返回一个异步可迭代对象。注意这个示例中的next()方法,它并没有显示返回期约,我们只是把它声明为了async next():

function clock(iterval,max=Infinity){
    //一个setTimeout的期约版,可以实现等待
    //注意参数是一个绝对时间而非时间间隔
    function until(time){
        return new Promise(resolve=>setTimeout(resolve,time-Date.now()));
    }
    
    //返回一个异步可迭代对象
    return{
        startTime:Date.now(),  //记住开始时间
        count:1,               //记住第几次迭代
        async next(){          //方法使其成为迭代器
            if(this.count>max){      //该结束了吗
                return {done:true};  //表示结束的的迭代结果
            }
            
           //计算下次迭代什么时间开始
            let targetTime=this.startTime+this.count*interval;
            
            //迭代该时间到来
            await until(targetTime);
            
            //在迭代结果对象中返回计数器的值
            return{value:this.count++};
        },
        
        //这个方法意味着这个迭代器同时也是一个可跌打对象
        [Symbol.asyncIterator](){
            return this;
        }
    };
}

这个基于迭代器的clock()函数修复了基于生成器版本的一个缺陷。注意,在这个更新的代码中,我们使用的是每次迭代应该开始的绝对时间减去当前时间,得到要传给setTimeout()的时间间隔。如果在for/await循环中使用clock(),这个版本会更精确地按照指定的时间间隔循环迭代。因为这个事件间隔包含了循环体运行的事件。不过这恶鬼修复不仅仅与计时精度有关。for/await循环在下一次迭代开始之前,总会等待一次迭代返回的期约兑现。但如果不是在for/await循环中使用异步迭代器,那你可以在任何适合调用next()方法。对于基于生成器的clock()版本,如果你连续调用3次next()方法,就可以得到3个期约,而这3个期约将几乎同时兑现,而这可能并非你想要的。在这里实现的这个基于迭代器的版本则没有这个问题。

异步迭代器的优点是它允许我们表示异步事件或数据流。前面讨论的clock()函数写起来相当简单,因为其中的异步性源于我们决定的setTimeout()调用。但是,在面对其他异步源时,比如事件处理程序的触发,要实现异步迭代器就会困难很多。因为通常我们只有一个事件处理程序处理程序响应事件,但每次调用迭代器的next()方法都必须返回一个独一无二的期约兑现。而在第一个期约解决之前很有可能出现多次调用next()的情况。这意味着任何异步迭代器都必须能在内部维护一个期约队列,让这些期约按照异步事件发生的顺序依次解决。如果把这个期约队列的逻辑封装到一个AsyncQueue类中,再基于这个类编写异步迭代器就简单多了。

下面定义的这个AsyncQuque类包含一个队列类应有的enqueue()和dequeu()方法。其中,dequeue()方法返回一个期约而不是一个实际的值,这意味着在尚未调用的enqueue()之前调用dequeue()是没有问题的。这个AsyncQueue类也是一个异步迭代器,有意设计为与for/await循环配合使用,其循环体会在每次入队一个新值时运行一次(AsyncQueue类有一个close()方法,一经调用就不能再向队列中加入值了。当一个关闭的队列变空时,for/await循环会停止循环)。

注意,AsyncQueue类的实现没有使用async和await,而是直接使用期约。实现diamond有点复杂,你可以通过它来测试自己对本章那么大篇幅所介绍内容的理解。即使不能完全理解这个AsyncQueue的实现,也要看一看它后面的那个更短的示例,它基于AsyncQueue实现一个简单但非常有意思的异步迭代器。

/**
* 一个异步可迭代队列类。使用enqueue()添加值,
* 使用dequeue()移除值。dequeue()返回一个期约,
* 这意味着,值可以在入队之前出队。这个类实现了
* [Symbol.asyncIterator]和 next(),因而可以
* 与for/await()循环一起配合使用(这个循环在调用
* close()方法前不会终止)
*/
class AsyncQueue{
    constructor(){
        //以及入队尚未出队的值保存在这里
        this.values=[];
        //如果期约出队时它们对应的值尚未入队
        //九八那些期约的解决方法保存在这里
        this.resolvers=[];
        //一旦关闭,任何值都不能在入队
        //也不会再返回任何未兑现的期约
        this.closed=false;
    }
    
    enqueue(value){
        if(this.closed){
            throw new Error("AsyncQueue closed");
        }
        if(this.resolvers.length>0){
            //如果这个值已经有对应的期约,则解决该期约
            const resolve=this.resolvers.shift();
            resolve(value);
        }else{
            //否则,让它去排队
            this.values.push(value);
        }
    }
    
    dequeue(){
        if(this.values.length>0){
            //如果有一个排队的值,为它返回一个解决期约
            const value=this.values.shift();
            return Promise.resolve(value);
        }
        else if(this.closd){
            //如果没有排队的值,而且队列已关闭
            //返回一个解决为EOS(流终止)标记的期约
            return Promise.resolve(AsyncQuque.EOS);
        }else{
            //否则,返回一个未解决的期约
            //将解决方法排队,以便后面使用
            return new Promise((resolve)=>{
                this.resolvers.push(resolve);
            });
        }
    }
    
    close(){
        //一旦关闭,任何值都不能再入队
        //因此以EOS标记解决所有带决期约
        while(this.resolvers.length>0){
            this.resolvers.shift()(AsyncQueue.EOS);
        }
        this.closed=true;
    }
    
    //定义这个方法,让这个类成为异步可迭代对象
    [Symbol.asyncIterator](){ return this; }
    
    //定义这个方法,让这个类成为异步迭代器
    //dequeue()返回的期约解决为一个值
    //或者再关闭时解决为EOS标记。这里,我们
    //需要返回一个解决为迭代器结果对象的期约
    next(){
        return this.dequeue().then(value=>(value===AsyncQueue.EOS)?{value:undefined,done:true}:{value:value,done:false});
    }
}
undefined
//deque()方法返回的标记值,在关闭时表示“流终止”
AsyncQueue.EOS=Symbol("end-of -stream");

因为这恶鬼AsyncQueue类定义了异步迭代的基础,所以我们可以创建更有意思的异步迭代器,只要简单地对值异步排队即可。下面这个示例使用AsyncQueue产生了一个浏览器事件流,可以通过for/await循环来处理:

//把指定文档元素上指定类型地事件推入一个AsyncQueue对象,
//然后返回这个队列,以便将其作为事件流来使用
function eventStream(elt,type){
    const q=new AsyncQueue();     //创建一个队列
    elt.addEvenListener(type,e=>q.enqueue(e));  //入队事件
    return q;
}

async function handleKeys(){
    //取得一个keypress事件流,对每个事件都执行一次循环
    for await (const event of eventStream(document,"keypress")){
        console.log(event.key);
    }
}

你可能感兴趣的:(前端基础,JavaScript,异步编程,javascript,前端,es6)