1. 异步编程

众所周知,目前主流的javaScript环境,都是以单线程的模式去执行的javaScript代码,那javaScript采用单线程工作的原因与他最早的设计初衷有关。

最早javaScript这门语言就是一门运行在浏览器端的脚本语言,那他的目的是为了实现页面上的动态交互。

而实现页面交互的核心就是dom操作,那这也就决定了,他必须使用单线程模型,否则就会出现很复杂的线程同步问题。

我们可以设想一下,假定我们在javaScript中同时有多个线程一起工作,那其中一个线程修改了某一个dom元素,而另外一个线程同时又删除了这个元素,那此时我们的浏览器就无法明确,改以哪一个线程的工作结果为准。

所以说为了避免这种线程同步的问题,从一开始javaScript就被设计成了单线程模式工作,那这也就成为了这门语言最为核心的特性之一。

那这里所说的单线程指的就是,在js的执行环境当中,负责执行代码的线程只有一个。

那你可以想象成,在我们的内部只有一个人按照我们的代码去执行任务。那只有一个人,他同时也就只能执行一个任务,那如果说有多个任务的话就必须要排队,然后一个一个依次去完成。

那这种模式他最大的优点就是,更安全,更简单,那缺点也同样很明显,如果说我们遇到一个特别耗时的任务,那后面的这些任务呢,都必须要去排队,等待这个任务的结束。

console.log('foo');

for (let i = 0; i < 100000; i++) {
    console.log('耗时操作');
}

console.log('等待耗时操作结束');

那这也就会导致我们整个程序的执行会被拖延,出现假死的情况。

那为了解决耗时任务阻塞执行的这种问题,javaScript语言将任务的执行模式分成了两种。分别是同步模式(Synchronous)和异步模式(Asynchronous)。

那我们在这里重点要了解的就是在javaScript中与异步编程相关的一些内容,主要包括以下几点。

那首先就是同步模式与异步模式在表象上的一个差异,以及他们各自存在的意义。那其次我们会顺便介绍一下javaScript的单线程他是如何实现的异步模式,其实也就是事件循环和消息队列。然后呢我们再一起总结一下javaScript当中的几种异步编程的方法。再然后呢我们会着重了解ES 2015所提供的Promise 异步编程方案,以及这个过程当中牵扯到的红人无,微任务的相关概念。最后我们会去了解ECMAScript 2015当中提供的generator异步编程解决方案,以及ES 2017当中提供的 Async/Await语法糖,让我们可以写出更扁平的异步代码。

2. 同步模式

那首先我们来看,同步执行模式。

同步模式指的就是我们代码当中任务依次执行,那后一个任务就必须要等待前一个任务结束才能够开始执行,那程序的执行顺序跟我们代码的编写顺序是完全一致的,也就是说这种方式会比较简单。

那在单线程的情况下我们大多数任务都会以同步模式去执行,那注意我们这里说的同步并不是指同时执行,而是排队执行。

那这里我们可以以一段同步模式的代码为例,来去分析一下他的具体执行过程。

console.log('global begin')

function bar() {
    console.log('bar task')
}

function foo() {
    console.log('foo task')
    bar()
}

foo()

console.log('global end')

那开始执行js引擎会把我们整体额代码全部加载进来,然后呢在我们的调用栈当中去压入一个匿名的调用,那这个匿名的调用就可以理解为把全部的代码放到了一个匿名函数当中去执行。

然后他就开始逐行执行我们这里每一行的代码,那首先是第一行,第一行遇到了console.log调用,他就会把这个console.log压入我们的调用栈去执行,那执行过程中我们的控制台打入了对应的消息global begin,然后呢我们这个console.log调用结束,他就弹出了我们这个调用栈。

然后我们的代码继续向下执行。

那紧接着往下是两个函数的声明,那不管是函数还是变量的声明他都不会产生任何的调用,所以说这里的执行会继续往下。

那在往下就是一个foo函数的调用,那对于函数调用它同样要压入调用栈,然后开始执行这个foo函数。

那foo函数一开始是先打印了一个消息,那打印完成过后他调用了bar函数,那这里的bar函数也会被放入到调用栈当中去执行。

那bar函数执行的过程当中又打印了一次,那打印完成我们的bar函数也就执行完成,从调用栈当中bar函数就会被弹出。

然后紧接着我们的foo函数执行也就结束了,他同样会从调用栈中弹出。

那最后再去打印了我们的global end, 然后我们整体的代码全部结束。我们的调用栈就会被清空掉。

那这里的调用栈只是一个更专业的说法,更通俗一点的解释就是js在执行引擎当中维护了一个正在工作的工作表,或者说正在执行的一个工作表。

那在这个里面会记录当前我们正在做的一些事情,那当这个工作表中所有的任务全部被清空过后,那这一轮的工作就算是结束了。

那这是一个纯同步模式下的执行情况。所以说特别容易理解,因为他整个执行过程非常符合我们正常的阅读逻辑或者说思考逻辑。

不过这种排队执行的机制,他也存在一个很严重的问题,就是如果说其中的某一个任务,或者更具体点说就是其中的某一行代码,他执行的时间过长,那他后面的任务就会被延迟。那我们把这种延迟称之为阻塞。

那这种阻塞对于用户而言,就意味着界面会有卡顿,或者说卡死,所以说就必须要有异步模式,来去解决我们程序当中那些无法避免的耗时操作。

例如我们在浏览器端的ajax操作,或者在nodejs当中的大文件读写。那都会需要使用到异步模式去执行,从而去避免我们的代码被卡死。

3. 异步模式

接下来我们再来看异步执行模式,那不同于同步模式的执行方式,异步模式的API是不会等待这个任务的结束才开始执行下一个任务。

对于耗时操作他都是开启过后就立即往后执行下一个任务。

那耗时任务的后续逻辑呢我们会通过回调函数的方式去定义,那在内部呢,我们这个耗时任务完成过后呢就会自动执行我们这里传入的回调函数。

那异步模式对于javaScript非常重要,那如果没有这种模式的话,我们单线程的javaScript语言,他就无法同时处理大量的耗时任务。

而对于开发者而言,单线程模式下面的异步他最大的难点就是代码执行的顺序并不会像同步代码一样通俗易懂。

因为他的执行顺序相对会比较跳跃,那对于这个问题呢,更多的是需要理解和习惯,最好的办法呢就是多看,多练,多思考。

那这里我们同样以一段包含异步调用的代码,来去分析一下,在javaScript当中,异步执行的过程。

那这段代码最外层包含了两个setTimeout, 而在第二个setTimeout函数内部又去使用了一次timeout。

console.log('global begin')

setTimeout(function timer1() {
    console.log('timer1 invoke')
}, 1800)

setTimeout(function timer2() {
    console.log('timer2 invoke')

    setTimeout(function inner() {
        console.log('inner invoke')
    }, 1000)

}, 1000)

console.log('global end')

那因为有异步调用的过程相对会复杂一点,所以说我们这里要介绍到的东西也相对会多一些。

首先是内部API的环境,我们这里是以web平台举例,所以说就是web api,然后是事件循环和一个消息队列。也有人把消息队列称之为回调队列。那他的作用呢我们遇到的时候再说。

那这里整体的执行情况大致呢与我们前面所分析的同步模式情况相同,只不过在遇到一些异步调用时会有一些差异,我们具体来看。

首先他也是加载整体的代码,然后在我们的调用栈当中去压入一个匿名的全局调用,然后我们这里会依次执行每一行代码。

那对于console.log这样的同步api,还是一样的,先压栈然后再执行,执行过程当中打印,打印过后弹栈。

然后再往后就遇到了一个setTimeout调用,那同样也是先将这个setTimeout压入到我们的调用栈,但是这个函数的内部他是异步调用,所以我们需要关心内部API环境到底做了什么事情。

其实在内部的api也非常简单,他就是在内部为这个timer1函数开启了一个倒计时器,然后单独放到一边,那注意这里的倒计时器他是单独工作的,并不会受我们当前的js线程影响。

那从我们开始过后他就已经开始倒数了,只不过呢我们这里是分步骤去演示。那我们就让他在一旁默默的倒数,待会我们再来看倒数完了过后他干的事情。

那开启这个倒计时器过后,对于settimeout函数来讲,他的调用就已经而完成了,所以说代码会继续往下执行。

然后再往下又遇到了一个settimeout调用,那同理也是先压栈,然后开启另一个倒计时器。然后弹栈。

那最后又遇到了一个console.log调用,那打印了消息过后呢,对于整体的这个匿名调用就已经完成了。所以说我们这个调用栈就会被清空掉。

然后这时候Event loop 因为我们调用栈里面已经没有工作了,所以说我们Event loop他就会发挥作用。

那Event loop他其实只做一件事情,就是负责监听调用栈和消息队列,那一但我们调用栈当中所有的任务都结束了,那事件循环就会从消息队列当中取出第一个回调函数,然后压入到调用栈。

只不过此时我们的消息队列当中是空的,他什么都没有,所以说执行就相当于是暂停下来了。

那此时呢我们再来回过头来看一看我们这里的两个倒计时器,那自从前面开启了这两个倒计时过后我们的代码就再也没有管过他们。而是直接往后执行了。

那这里timer1函数所对应的倒计时他应该是倒计1.8s,timer2是1s。那很明显,timer2所对应的倒计时他应该先结束。

那结束过后呢,timer2函数就会被放入到我们消息队列的第一位,那在timer1对应的倒计时结束过后他就会放入到消息队列的第二位。

那一但消息队列中发生了变化,我们的事件循环就会监听到然后就会把消息队列当中的第一个也就是timer2函数取出来,压入到我们的调用栈。继续去执行这个timer2。

那此时对于调用栈来讲的话,相当于开启了新一轮的执行。那执行过程呢与我们刚刚分析的是一致的。

那如果说这个过程中又遇到了有异步调用,他也是相同的情况,先会把他放入到我们api环境里面单独去执行,然后在往后就是不断这样重复。

直到我们的调用栈和消息队列当中都没有需要继续执行的任务了,那整体的代码就结束了。

那如果说我们的调用栈是一个正在执行的工作表,那消息队列就可以理解成一个待办的工作表,而js执行引擎呢就是先去做完调用栈当中所有的任务,然后再通过事件循环从消息队列当中再取一个任务出来。继续去执行。以此类推。

那整个过程呢我们随时都可以往消息队列当中再去放入一些任务,那这些任务呢在消息队列当中会排队等待事件循环。

那以上就是异步调用在javaScript当中的实现过程以及他的一个基本的原理。

那整个过程呢都是通过内部的消息队列和事件循环去实现的,那因为我们这里是分开分析的,所以说你会认为这些步骤都会有一定的先后顺序,其实不是这样的。因为他们各自都有各自的time-line。

例如我们的倒计时器,他开始过后呢就会自动开始倒计时,根本不会管调用栈或者队列当中是什么情况。

只不过我们这分析时,我们如果同步去分析的话你就会觉得特别乱,所以说我们这里特别安排了这样几个时间点。尽量确保我们的执行顺序跟我们的分析顺序是一致的。那这一点呢,你需要额外注意一下。

可能我们接下来的这张图可以更清楚地表述出这一点。

例如我们在js当中。js线程某一时刻他发起了一个异步调用。然后他紧接着往后执行其他的任务。那此时呢,异步线程会单独去执行这个异步任务,然后在执行完这个任务过后会将这个任务的回调放入到消息队列,那js主线程他完成所有的任务过后会再依次执行我们消息队列当中的任务。

那这里呢我们特别需要注意一点的是,javaScript他确实是单线程的,而我们的浏览器他并不是单线程的。

那更具体一点来说就是我们通过javaScript调用的某些内部的api,他并不是单线程的。例如我们这里所使用的的倒计时器,那他内部呢就会有一个单独的线程去负责倒数。在时间到了之后会将我们的回调放入到消息队列。

也就是说这样一个事情他是有单独的线程去做的,我们所说的单线程指的是执行我们代码的那个线程,他是一个线程。

也就是说这些内部的API呢他们会用单独的线程去执行这些等待的操作。

因为我们就拿生活角度来说他有些事情耗时他是必然需要等的,那等总得有一个人去等,那我们这呢,只不过是不会让js线程去等。

那除此以外呢,这里我们所说的,同步也好,异步也好,肯定不是指我们写代码的方式,而是说我们运行环境提供的API, 他到底是以同步模式还是以异步模式的方式去工作。

那对于同步模式的API, 他的特点呢就是这个任务执行完代码才会继续往下走,例如我们的console.log。

对于异步模式的API呢,他就是下达这个任务开启过后的指令就会继续往下执行,那代码是不会在这一行等待任务的结束的。例如我们的setTimeout。

4. 回调函数

正如前面所说,异步模式对于单线程的javaScript语言非常重要。同时也是javaScript的核心特点。

也正是因为大量异步模式的API的关系,所以说我们写出来的js代码相对就没有那么容易读。执行顺序呢,相对来说就会复杂很多,特别是对于复杂的一些异步逻辑。

那从这样一个角度来讲的话,javaScript他实际上是不适合初学者的,但是呢,一般我们可能会有一些传统的固化的逻辑思维,那一但我们打破这种传统的逻辑思维过后,其实也还好,不会有那么夸张。

那接下来我们重点要介绍的就是在js当中那些为异步而生的语法。特别是在ES 2015过后推出的一系列新语法,新特性。

那这些语法,特性呢他们慢慢弥补了javaScript在异步编程这块的不足或者是不变。

那首先我们先来看一下javaScript当中实现异步编程的根本方式。

其实所有的异步编程方案他的根本都是回调函数,那回调函数你就可以把他理解成一件你想要做的事情,你明确知道这件事情应该怎么做,怎么样一步一步的往下做。

但是你并不知道这件事情所依赖的任务什么时候才能完成,所以说最好的办法呢就是把你的这件事的步骤写到一个函数当中交给任务的执行者。

那这个异步任务的执行者他是知道这个任务什么时候结束的,那他就可以在结束过后去帮你执行你想要去做的事情。那这件想要做的事情呢我们就可以理解成回调函数。

那这么说呢可能会比较抽象,我们具体一点,比如说我现在想给我的桌子重新刷一遍漆,那我明确知道我想要怎么去刷,但是呢我没有油漆,那我得让你帮我去买一桶油漆,那你去买油漆实际上需要一定的时间的,而我又会有很多其他的事情要做,所以说我不能在这个地方干等着你,那我就会选择把我们这个桌子应该怎么刷的步骤写到一个纸条上面,然后一起交给你,完了过后我就去忙别的事情了,那你买完油漆回来过后就可以按照我纸条上的步骤,一步一步的去帮我把这个桌子刷好就可以了。

那我们在这样一个例子中,我呢实际上就是异步任务的调用者,而你就是具体的异步任务的执行者,那我给你的纸条也就是写着步骤的这个纸条。他就是我调用者所定义的回调函数。

那我们再以程序当中的ajax请求为例,那我们去调用ajax操作,目的呢就是为了拿到请求结果过后去做一些事情,例如我们把它显示到界面上。

但是呢这个请求他何时能够完成我们并不知道,所以说我们得把得到响应结果之后要去执行的任务定义到一个函数当中,然后内部的ajax在请求完成过后呢,他会自动执行这个任务。

那这种由调用者定义然后交给执行者去执行的函数就被称之为回调函数,具体的用法也非常简单,他就只是把函数作为参数去传递罢了。只不过这种方式的异步代码呢他相对来说特别不利于阅读。而且整个过程执行顺序呢会非常的混乱。

function foo (callback) {
    setTimeout(function () {
        callback()
    }, 3000)
}

foo(function() {
    console.log('这就是一个回调函数')
    console.log('调用者定义这个函数,执行者执行这个函数')
    console.log('其实就是调用者告诉执行者异步任务后应该做什么')
})

那其实除了传递回调函数参数这种方式以外,还有几种常见的实现异步方式,例如事件机制或者发布订阅。

不过我认为这些也都是基于回调函数基础之上的一些变体罢了,所以我们在这就不做具体的探讨了。

5. Promise概述

回调函数可以说是javaScript所有异步编程方式的根基

但是如果我们直接使用传统毁掉的方式去完成复杂的异步流程,那就无法避免大量的异步回调函数嵌套, 那这也就会导致我们常说的回调地狱问题。

那为了避免回调地狱问题,CommonJS社区率先提出了一种叫做Promise的规范,目的就是为异步编程去提供一种更合理更强大的统一解决方案,后来在ES2015当中被标准化进了ES2015语言规范。

那所谓Promise实际上就是一个用来去表示一个异步任务最终结束过后他究竟是成功还是失败。

那就像内部对外界做出了一个承诺,那一开始这个承诺是一种待定的状态,英文叫做pendding,那最终有可能成功,英文叫做Fulfilled,也有可能失败,英文是Rejected。

那例如我承诺给你买一件大衣,那此时你就会等待我这个承诺的结果,也就是说此时我的这个承诺是个待定的状态,那如果确实我买回来了这件大衣,那这个承诺也就成功了,反之不管因为什么原因我没有买回来这件大衣,那这个承诺就是失败了。

那承诺结束过后不管这个承诺最终是达成或者是失败,你都会有想对应的反应,比如说我如果达成了你可能会很感激,那如果说失败了,你又可能会把我臭骂一顿。

那这也就是说,在承诺状态最终明确了过后,都会有相对应的任务会被执行。

而且这种承诺会有一个很明显的特点,就是一旦明确了结果过后就不可能再发生改变了,例如我没有买到大衣,那这个给你买大衣的承诺就是失败了的,他不可能再变成成功。即便是说我以后再给你买了,那也是以后的事情,对于我们一开始的这个承诺,他还是失败的。

那落实到程序上,例如你需要我去帮你发送一个ajax请求,那你就可以理解为我请诺帮你请求一个地址,那这个请求最终有可能成功,那成功我就调用你的onFulfilled回调,那如果请求失败的话,我就会去调用你的onRejected回调。

那这就是Promise的一个概念。

6. Promise 基本用法

下面我们来看Promise的基本用法。

那在代码层面Promise他实际上就是ES2015所提供的一个全局类型,我们可以使用他来构造一个Promise实例,也就是创建一个新的承诺,那这个类型的构造函数,他需要接收一个函数作为参数。

那这个函数就可以理解为一个兑现承诺的逻辑,那这个函数会在构造Promise的过程当中被同步执行。

那在这个函数内部它能够接收到两个参数,分别是resolve和reject,那二者都是一个函数。

那resolve函数的作用呢就是将我们对象的状态修改为Fulfilled也就是成功。

一般我们将异步任务的结果会通过resolve的参数传递出去,那我们这里先传入一个100的固定值。

那reject函数的作用呢就是将这个Promise的状态修改为rejected也就是失败。

那这里失败的参数我们一般传递的是一个错误的对象,用来表示这个承诺他为什么失败,也就是一个理由。那我们这里可以传入一个全新的错误对象。然后错误的描述描述就是promise rejected

const promise = new Promise(function(resolve, reject) {
    // 这里用于兑现承诺
    resolve(100) // 承诺达成
    reject(new Error('promise rejected')) // 承诺失败
})

那因为前面我们说过,Promise的状态, 他一旦确定过后了就不能再被修改了,所以说在这个函数当中最后只能调用二者其一。

那我们这里先注释掉rejected调用,我们只去调用resolve函数。

那promise实例被创建过后呢我们就可以用这个实例的then方法分别去指定onFulfilled和onRejected回调函数。

那then方法传入的第一个参数就是onFulfilled回调函数,也就是成功过后的回调函数,那我们再这个函数当中去打印一下得到的参数。

那第二个参数传入的就是onRejected的回调函数,也就是失败了过后的回调函数。那在这个函数当中我们同样打印一下得到的参数。

const promise = new Promise(function(resolve, reject) {
    // 这里用于兑现承诺
    resolve(100) // 承诺达成
    // reject(new Error('promise rejected')) // 承诺失败
})

promise.then(function(value) {
    console.log('resolved', value)
}, function(error) {
    console.log('rejected', error)  
})

完成以后我们运行一下代码,可以复制到浏览器控制台执行,也可以自定义一个html文件执行。

我们打开控制台,这时候就可以看到resolve传递过来的100被正常打印出来了。

然后我们再回到代码当中,这里我们只去调用一下reject函数,然后注释掉resolve的调用。

const promise = new Promise(function(resolve, reject) {
    // 这里用于兑现承诺
    // resolve(100) // 承诺达成
    reject(new Error('promise rejected')) // 承诺失败
})

promise.then(function(value) {
    console.log('resolved', value)
}, function(error) {
    console.log('rejected', error)  
})

这次打印的就是reject当中所传入的错误对象了。

那需要注意的是,即便我们的promise他当中没有任何的异步操作,那这里then方法当中所指定的回调函数,他仍然会进入到回调队列当中排队。也就是说我们必须要等待这里同步代码全部执行完了才会执行。

那我们可以在后面再加上一个console.log的操作来去证明一下这样一个特点。

那如果这里我们是先打印的一个end,在去打印错误对象,那就证明Promise的回调会进入队列, 在后面执行。

const promise = new Promise(function(resolve, reject) {
    // 这里用于兑现承诺
    // resolve(100) // 承诺达成
    reject(new Error('promise rejected')) // 承诺失败
})

promise.then(function(value) {
    console.log('resolved', value)
}, function(error) {
    console.log('rejected', error)  
})

console.log('end')

那运行过后我们确实可以看到end是率先被打印的。

当然了,对于Promise他的回调执行的时序问题还比较特殊,这个问题我们在后面会有一个专门的章节来说。

7. Promise 使用案例

接下来我们再来看一个使用Promise去封装ajax请求的例子。

那这里我们先来定义一个叫做ajax的函数,那这个函数有一个url参数,用来去接收外界去请求的地址。然后我们在这个函数当中直接对外返回一个Promise对象。就相当于对外做出一个承诺。

那在这个Promise对象执行逻辑当中我们就可以使用XMLHttpRequest对象去发送一个ajax请求。

我们这里设置请求方式给get,请求地址是url, 响应类型为json, 那这个方式是html5中引入的一个新的特性,这样的话我们就可以在请求完成过后,直接拿到一个json对象,而不是一个字符串。

然后呢我们再去注册一下xhr的load事件,这个load事件同样也是html5当中提供的一个新事件。

那这个事件是请求完成过后也就是我们传统的readystatus等于4的这种状态才会执行。

那在请求完成这个事件当中我们应该先去判断请求的状态是不是200,如果是的话那意味着我们请求已经成功了。那我们应该去调用resolve表示我们这个promise已经成功了。那resolve我们传入的就应该是我们请求得到的响应结果。

那反之,如果说请求失败的话我们就应该去调用reject函数去表示promise失败,那这里我们就应该传入一个错误信息对象,那错误信息就是当前的状态文本。

完成以后我们去调用一下xhr的send方法,开始执行这个异步请求。

那这样的话我们这个promise版本的ajax就封装完了。

function ajax (url) {
    return new Promise(function (resolve, reject) {

        var xhr = new XMLHttpRequest()

        xhr.open('GET', url)

        xhr.responseType = 'json'

        xhr.onload = function () {
            if (this.status === 200) {
                resolve(this.response)
            } else {
                reject(new Error(this.statusText))
            }
        }

        xhr.sned()
    })
}

我们这里可以尝试调用一下这个函数,传入一个我们需要请求的地址,这个函数会返回一个promise对象,我们可以通过这个对象的then防范去指定回调。

在成功的回调函数当中我们先去打印一下返回的结果,那在失败的回调函数当中我们去打印一下错误对象。

ajax('/api/users.json').then(function(res) {
    console.log(res)
}, function(error) {
    console.log(error)
})

可以看到这个请求被正常发送了,我们也可以正常通过成功回调函数去拿到响应的结果。

我们再把这个请求的地址修改为一个根本不存在的路径再次保存,那此时打印的一个结果就是一个错误。

那这就是我们针对于promise一个基本的应用。

8. Promise 常见误区

通过前面的尝试我们发现从表象上来看,Promise的本质也就是使用回调函数的方式去定义异步任务结束过后所需要执行的任务,只不过这里的回调函数是通过then方法传递进去的。

而且Promise他将我们的回调分成了两种,分别是成功过后的回调叫做onFulfilled, 还有就是失败过后的回调叫做onRejected。

那这个时候善于思考的朋友一定会想到,既然还是回调函数,那如果说我们需要连续串联执行多个异步任务,那这里也就仍然会出现回调函数嵌套的问题。

例如我们上面的代码需要先去请求一个urls.json的文件拿到全部的url地址,然后我们再去请求其中的某一个url。

那如果按照传统的思考方式我们去请求ajax函数我们肯定会这么做,就是先去调用ajax函数,先去请求urls.json 然后我们再返回的Promise对象的then方法中,我们去传入一个回调函数。

那在这个回调函数当中我们会再去调用一次ajax函数,然后接着去使用then,那如果说有多个连续的请求,我们这里的代码仍然会形成回调地狱, 那Promise也就没有任何的意义, 而且还增加了额外的复杂度。还不如使用传统的回调方式。

ajax('/api/urls.json').then(function (urls) {
    ajax(urls.users).then(function (users) {

    })
})

那其实这种嵌套使用的方式是我们使用promise最常见的误区,那正确的做法实际上是借助于Promise的then方法的链式调用的特点,尽量去保证我们异步任务的扁平化。

9. Promise 链式调用

其实相比于传统回调函数的方式Promise最大的优势就是可以链式调用,这样就能最大成都的避免回调嵌套,那我们具体来看。

回到代码当中,正如我们前面所说的,这里我们使用的then方法,他的作用呢,就是为Promise对象去添加状态明确后的回调函数。

那他的第一个参数是onFulfilled的回调也就是成功过后的回调,那第二个参数是onRejected回调,也就是失败过后的回调。

那其中失败过后的回调是可以省略的,那这个方法最大的一个特点呢就是他的内部也会返回一个Promise对象,那我们这里尝试接收一下then方法返回的对象,我们把它打印出来。

var promise2 = promise.then(function(value) {
    console.log('resolved', value)
}, function(error) {
    console.log('rejected', error)  
})

console.log(primise2); // Promise {<pending>}

可以发现返回的确实也是一个Promise对象。

那按照以往我们对链式调用的认知,那这里返回的Promise应该是当前的这个Promise对象,那这里我们来验证一下。

console.log(promise2 === promise) // false

结果我们会看到,他俩并不是同一个对象,所以说这里的链式调用它并不是以往我们常见的那种在方法内部返回this的方式去实现的链式调用。那这一点是尤其需要注意的。

那这里的then方法呢,他返回的是一个全新的promise对象,那返回全新Promise对象的目的呢就是为了去实现一个promise的链条,也就是一个承诺结束了过后再去返回一个新的承诺。

那每一个承诺都可以负责一个异步任务相互之间又没有什么影响。

那就意味着如果我们这里不断的链式调用then方法,然后这里每一个then方法他实际上都是在为上一个then方法返回的promise对象去添加状态明确过后的回调。

这句话可能会比较绕,我们再说一遍,就是每一个then方法它实际上都是在为上一个then返回的promise对象添加状态明确过后的回调。

那这些promise会依次执行,那这里添加的这些回调函数,自然也就是从前到后,依次执行。

而且我们也可以在then的回调当中手动返回一个promise对象,例如我们在第一个ajax的then中返回一个ajax调用结果,也就是一个promise对象。那下一个then方法他实际上就是为这个promise对象去添加状态明确过后的回调。也就是说这里的ajax调用完成过后他会自动执行下一个then方法当中的回调。

那这样的话我们就可以避免不必要的回调嵌套了,而且以此类推如果说有多个连续的任务就可以使用这种链式调用的方式去避免回调的嵌套,从而尽量保证我们代码的扁平化。

ajax('/api/users.json').then(function (value) {
    return ajax('/api/urls.json')
}).then(function(value) {
    console.log(2222)
})

那如果说我们回调当中返回的不是一个promise而是一个普通的值,那这个值就会作为当前这个then方法返回的这个promise中的值,那我们在下一个then方法中接收的这个回调参数他实际上拿到的就是这样一个值。

那如果我们回调当中没有返回任何值,那默认返回的就应该是一个undefined。

那这里可能相对会感觉有点绕,因为如果我们是第一次接触的话,这个肯定会有一些颠覆,相对于我们之前使用传统回调的方式。

所以说我们这可以再从表象上再去总结一下。

首先第一点就是Promise对象的then方法他会返回一个全新的Promise对象,所以说我们就可以使用链式调用的方式去添加then方法。

其次就是后面的then方法它实际上就是在为上一个then方法当中返回的Promise去注册对应的回调。

那第三就是前面then方法回调函数中的返回值会作为后面then方法回调的参数。

那第四点就是如果说我们在回调方法中返回的是一个Promise对象的话,那后面then方法当中的回调实际上就会等待这个Promise结束,也就说后面的then方法实际上就是相当为我们所返回的这个Promise对象去注册了对应的回调。

10. Promise 异常处理

正如前面所说,Promise的结果一旦失败他就会调用我们在then方法当中去传入的这个onRejected回调函数。

例如我们在这里去请求一个根本不存在的地址,那他就会执行onRejected回调函数。

ajax('/api/users11111.json').then(function(res) {
    console.log(res)
}, function(error) {
    console.log(error)
})

那除此之外如果说我们在Promise执行的过程当中出现了异常,或者是我们手动抛出了一个异常,那onRejected回调也会被执行,那不比如我们在Promise执行的过程中去调一个根本不存在的foo方法。

function ajax (url) {
    return new Promise(function (resolve, reject) {
        foo()
        var xhr = new XMLHttpRequest()

        xhr.open('GET', url)

        xhr.responseType = 'json'

        xhr.onload = function () {
            if (this.status === 200) {
                resolve(this.response)
            } else {
                reject(new Error(this.statusText))
            }
        }

        xhr.sned()
    })
}

运行之后我们就可以看到onRejected方法被执行了,我们手动抛出一个异常也会导致onRejected执行。

new Error()

所以说onRejected回调他实际上就是为Promise当中的异常去做一些处理,那在Promise失败了或者出现异常时候他都会被执行。

其实关于onRejected回调的注册我们还有一个更常见的用法,就是使用Promise实例的catch方法去注册onRejected回调,那我们来尝试一下。

这里我们用then方法去注册onFulfilled回调也就是成功的回调,然后紧接着我们用catch方法去注册onRejected回调。

ajax('/api/users11111.json').then(function(res) {
    console.log(res)
}).catch(function(error) {
    console.log(error)
})

那结果同样也是可以正常执行的。

那其实我们这里的catch方法他其实就是then方法的一个别名,因为我们调用它就相当于调用了then方法,然后我们第一个参数去传递了一个undefined。

那相对来说用catch方法去指定失败回调要更为常见一些,因为这种方式会更适合于链式调用,那具体的原因呢我们继续往下看。

那这里我们要从这两种方式的差异开始说起,那从表象上来看我们用catch方法去注册失败回调跟我们直接在then方法中去注册效果是一样的。他们都能捕获到我们这么Promise他在执行过程中的异常。

但是我们仔细去对比这两种方式,其实他们有很大的差异,那我们在前面说过了,因为每一个then方法他返回的都是一个全新的Promise对象。

那这也就是说我们在后面通过链式调用的方式调用的这个catch它实际上是在给前面then方法返回的Promise对象去指定失败的回调,并不是直接去给第一个Promise对象所指定的。

只不过呢,因为这是同一个Promise链条,那前面Promise的异常会一直传递,所以说我们在这里才能够捕获到第一个Promise当中的异常。

而通过then方法的第二个参数去指定的失败回调函数,那他只是给第一个Promise对象指定的,那也就是说,他只能捕获到这个Promise对象的异常。

那具体在表象上的差异就是,如果我们在then方法当中返回了第二个Promise,而且呢这个Promise执行过程当中出现了异常,那我们使用then的第二个参数去注册的失败回调他是捕获不到第二个Promise的异常的。因为他只是给第一个Promise对象注册的失败回调。

那我们可以具体来尝试一下。

这里我们在第一种方式成功的回调函数当中,我们再去返回一个ajax调用,只不过这里的地址是一个根本不存在的地址,也就是说这个Promise他一定是失败的。

ajax('/api/users.json').then(function(res) {
    console.log(res)
    return ajax('/api/users11111.json')
}, function(error) {
    console.log(error)
})

那运行过后我们在控制台就能看到,这里的异常并没有被我们注册的这个失败回调事件所捕获到。

然后我们再到第二种方式中去做相同的尝试,我们同样也在这个成功的回调中去返回一个错误的ajax调用。

ajax('/api/users.json').then(function(res) {
    console.log(res)
    return ajax('/api/users11111.json')
}).catch(function(error) {
    console.log(error)
})

那这种方式下我们的失败回调就可以正常捕获到这样一个异常,原因就是我们这个失败回调,他是注册在上一个就是then方法所返回的这个Promise对象上的,那这个对象是失败的,所以说他正常能捕获到。

所以说对于链式调用的情况下,我们建议大家使用第二种方式去分开指定成功的回调和失败的回调,因为Promise的链条上任何一个异常都会被一直向后传递,直至被捕获。

那也就是说这种方式更像是给整个Promise链条注册的失败回调,所以说他相对来讲要更通用一些。

除此之外呢,我们还可以在全局对象上去注册一个unhandledrejection事件,去处理那些我们代码当中没有被手动捕获的Promise异常。

那我们在浏览器当中我们应该是把这样一个事件注册在window对象上面。

window.addEventListener('unhandledrejection', event => {
    const { reason, promise } = event;

    console.log(reason, promise)

    // reason => promise 失败原因,一般是一个错误对象
    // promise => 出现异常的Promise对象

    event.preventDefault()
})

那在Node当中我们需要在process对象上去注册这样一个事件,不过这个事件的名称是驼峰命名的,而且呢参数也不太相同。

process.on('unhandledRejection', (reason, promise) => {

    console.log(reason, promise)

    // reason => promise 失败原因,一般是一个错误对象
    // promise => 出现异常的Promise对象
})

那因为这种在全局捕获的方式我一般不推荐大家使用,所以我们就不单独演示了,那更合适的做法呢应该是在代码当中明确的去捕获每一个可能发生的异常。而不是丢给全局统一处理。

11. Promsie 静态方法

在Promise类型当中还有几个静态方法也经常会用到,那我们依次来看一下。

那这个方法的作用就是快速的把一个值转换为一个Promise对象,例如我们通过Promise.resolve传入一个foo的字符串, 那他就直接回返回一个状态为Fulfilled的Promise对象,也就是成功的Promise对象。

Promise.resolve('foo').then(function (value) {
    console.log(value) // foo
})

那这里的foo字符串就会作为这个Promise对象所返回的值,也就是说我们在他的onFulfilled回调当中拿到的参数就是foo这样一个字符串。

那这种方式他完全等价于我们通过new Promise对象的这种方式然后我们在执行函数当中直接去resolve这个foo字符串。

new Promise(function (resolve, reject) {
    resolve('foo')
})

另外,这个方法如果接收到的是另一个Promise对象,那这个Promise对象会被原样返回。

例如我们这里先通过ajax去创建一个promise对象,然后我们再把这个对象传入到Promise.resolve方法当中得到第二个promise对象,我们可以对比发现他们是相等的。

var promise = ajax('/api/users.json')
var promise2 = Promise.resolve(promise)

console.log(promise === promise2) // true

也就是说我们通过Promise.resolve去包装一个promise对象实际上得到的就是原本的这样一个promise。

那还有一个特殊情况就是,如果我们传入的是一个对象而且这个对象也有一个跟promise一样的then方法,也就是说在这个方法中可以接受到onFulfilled和onRejected两个回调,那我们去调用onFulfilled去传入一个值。

Promise.resolve({
    then: function (onFulfilled, onRejected) {
        onFulfilled('foo')
    }
}).then(function (value) {
    console.log(value) // foo
})

那这样一个对象也可以作为一个promise对象被执行,我们在后面的then方法当中,他也能拿到这里所对应传入的值。

那带有这种then方法的对象,可以说是实现了一个叫做thenable的接口,也就是说他是一个可以被then的对象。

那支持这种对象的原因是因为在原生Promise对象还没有普及之前很多时候我们都是使用第三方的库去实现的Promise,那如果说我们现在需要把一些第三方的Promise对象去转换成原生的Promise那就可以借助这样一个机制,因为在第三方的Promise对象当中他也有相同的这种then方法。那我们就可以通过Promise.resolve把他转成一个原生的Promise对象。那这一点仅作为了解就可以了。

那除了Promise.resolve方法还有一个与之对应的叫做Promise.reject方法,那他的作用呢就是快速创建一个一定是失败的Promise对象。

Promise.reject(new Error('rejected')).catch(function (error) {
    console.log(error)
})

那这个方法的参数相对来说就没有那么多情况了,因为我们无论传入什么样的数据,那这个传入的参数都会作为这个promise失败的理由也就是失败的原因。

12. Promise 并行执行

前面介绍的操作都是通过Promise去串联执行多个异步任务,也就是一个任务结束过后再去开启下一个任务。

那相比于传统回调的方式Promise他提供了更扁平的异步编程体验,那如果我们需要同时并行执行多个异步任务,Promise呢也可以提供更为完善的体验。

例如我们在页面中经常涉及到要请求多个接口的情况,那如果说这些请求相互之间没有什么依赖,那我们最好的选择呢就是同时去请求他们,这样避免我们一个一个依次去请求会消耗更多的时间。

那这种并行请求其实很容易实现,我们只需要单独去调用这里的ajax函数就可以了。

ajax('/api/users.json')
ajax('/api/users.json')

但是麻烦的是我们怎么样去判断所有的请求都已经结束了那样一个时机,那传统我们的做法是定义一个计数器,然后每结束一个请求我们让这个计数器累加一下,直到我们这个计数器的数量跟我们任务的数量相等时就表示所有的任务都结束了。

那这种方法呢会非常的麻烦,而且呢我们需要去考虑出现异常的情况,那在这种情况下我们使用Promise类型的all方法就会简单的多,因为这个方法他可以将多个Promise合并成一个Promise统一去管理。

那我们具体来看如何去使用。

Promise.all方法他需要接收的是一个数组,然后数组中的每一个元素都是一个Promise对象,我们可以把这些Promise都看作是一个一个的异步任务。

var promise = Promise.all([
    ajax('/api/users.json'),
    ajax('/api/users.json')
])

那这个方法会返回一个全新的Promise对象, 当内部所有的Promise都完成过后呢,我们所返回的这个全新的Promise才会完成,那此时我们这个Promise对象他拿到的结果就是一个数组,在这个数组中包含着每一个异步任务执行过后的结果。

promise.then(function (values) {
    console.log(values)
}).catch(function (error) {
    console.log(error)
})

那需要注意的是,在这个任务的过程当中,只有当所有的任务都成功结束了我们这里新的Promise他才会成功结束,如果说其中有任何一个任务失败了,那这个Promise就会以失败结束。

那这是一种很好的同步执行多个Promise的方式,那这里我们可以再综合使用一下串联。

我们先去通过ajax去请求一下数据,那这个地址请求完是包含所有地址的对象。然后我们这里通过Object.values方法获取到这个对象所有的属性值,也就是所有的url地址组成的一个数组,那有了这样一个数组过后我们就可以使用数组对象的map方法,将这个字符串数组去转换成一个包含请求任务的promise数组。

那完成以后我们就可以使用Promise.all将这个promise数组组合成一个新的promise 然后return掉。那这样的话,我们就可以在下一个then方法中,我们就可以拿到当前我们这里我们这个Promise数组当中每一个异步请求得到的结果数据。

ajax('/api/urls.json').then(value => {
    const urls = Object.values(value)
    const tasks = urls.map(url => ajax(url))
    Promise.all(tasks)
}).then( values => {
    console.log(values);
})

那这就是我们组合使用串行和并行这两种方式,那这里的执行过程呢肯定是先去请求的所有url地址,然后把我们的urls地址拿到过后会去同时请求urls数组当中所有的地址。

另外呢Promise除了提供了一个叫做all的方法以外,他还提供了一个叫做race的方法,那这个方法呢,他同样可以把多个promise对象组装成一个新的promise对象。

但是呢与Promise.all有所不同的是,Promise.all是等待所有的任务结束后才会结束,而Promise.race他是跟着我们所有任务当中第一个完成的任务一起结束。

也就是说只要有任何一个任务完成了那我们所返回的新的promise对象也会完成。

例如我们这里先去调用ajax函数去发送一个请求,那这样就可以得到一个promise对象,然后我们再去单独的创建一个独立的promise对象,那这个promise对象的内部呢我们使用setTimeout在500毫秒之后去以失败的方式reject。

const request = ajax('/api/posts.json')
const timeout = new Promise(function (resolve, reject) {
    setTimeout(function () {
        reject(new Error('timeout'))
    }, 500)
})

Promise.race([
    request,
    timeout
]).then(value => {
    console.log(value)
}).catch(error => {
    console.log(error)
})

那此时呢如果我们使用Promise.race将这两个promise对象合并到一起,那这样的效果就是如果说我们500毫秒之内这个请求完成了,那我们就可以正常得到响应结果,那如果说500毫秒过后我们这个请求他就没有办法把结果返回回来了。

因为在500毫秒过后呢,我们第二个promise会以失败的方式结束。

而race方法就是以第一个结束的promise为准。我们可以在谷歌浏览器的网路限速中尝试,我们可以选择一个相对较慢的网速。

我们可以看到一个超时的异常出现,这也就是我们经常用来去实现ajax请求超时控制的一种方式。

那总结一下我们这里介绍了两个能够把多个promise对象组合到一起的方法,分别是Promise.all和Promise.rece。

那这两个方法最简单的区别就是Promise.all会等待所组合的所有promise都结束而且是成功结束,才会成功完成。而Promise.race他只会等待第一个结束的任务。

13. Promise 执行顺序

那最后我们再来深入了解一下关于Promise执行时序的问题,也就是执行的顺序。

那正如我们一开始所介绍到的,即便说我们Promise当中并没有任何的异步操作,那他的回调函数仍然会进入到回调队列当中去排队,也就是我们必须要等待当前所有的同步代码执行完了之后才会执行promise当中的回调。

当然了这句话其实不是特别的严谨,我们接着往下看。

那这里我们可以先来尝试一下我们先来直接打印一个global start然后我们再去使用Promise.resolve方法,快速创建一个一定会成功的promise, 那这个操作肯定是没有异步调用的,那在这个promise的回调当中我们去打印一个promise字符串。然后我们在最后面也就是最外侧再打印一个global end。

那此时按照我们刚刚的说法,这里的promise他即便没有任何的异步操作,他的回调仍然会异步调用,那也就是说我们这里的打印顺序应该是先打印global start然后再是global end最后才是promise。

console.log('global start')
Promise.resolve().then(() => {
    console.log('promise')
})
console.log('global end')

我们运行发现确实是这样。

那如果我们在promise的后面再去使用链式调用的方式去传递多个回调,那这里每个回调也应该是依次执行。

console.log('global start')
Promise.resolve().then(() => {
    console.log('promise')
}).then(() => {
    console.log('promise2')
}).then(() => {
    console.log('promise3')
})
console.log('global end')

可以发现运行的结果和我们设想的是一样的,每一个回调都是依次运行的。

最后我们再来尝试一下在Promise之前我们先去使用setTimeout去创建一个传统的异步调用,而且我们这里给延迟时间设置0。

console.log('global start')
setTimeout(() => {
    console.log('setTimeout')
}, 0)
Promise.resolve().then(() => {
    console.log('promise')
}).then(() => {
    console.log('promise2')
}).then(() => {
    console.log('promise3')
})
console.log('global end')

那按照我们之前对ES执行过程的分析我们应该知道这里我们使用setTimout传入的回调的函数,他会立即进入到回调队列中排队。因为延迟时间是0,那进入到回调队列中就是等待下一轮执行。

那此时呢如果按照之前的分析,我们这里的执行过程应该是setTimeout函数先进的队列,然后是Promise回调进队列,所以说这里的执行顺序应该是先打印setTimeout然后再去打印promise。

可是我们运行之后发现并不是这样,这里竟然先打印的是promise然后才是setTimeout, 那究竟是为什么呢?

其实这个地方的原因是因为Promise他的异步执行时序会有一点特殊,那要搞明白Promise执行时序问题之前呢我们先来看一个生活当中的场景。

假设我这里去银行柜台办理存款业务,那办完存款过后我突然想办一张信用卡,那这时我肯定直接告诉银行柜员我的这个临时的需求,而银行柜员他为了提高效率以及我的体验或者客户的体验他肯定不会让我再重新排队。

一般他能够帮我办理的就会顺便一起帮我办理了,那这种行为并不属于插队,只是我在完成主要任务过后临时多了些小插曲。

那在这个例子当中我们在柜台排队办理业务就像是js当中回调队列等待执行的那些任务一样。

那我们队伍当中的每一个人他都对应着回调当中的一个任务,也有人会把这种任务称之为宏任务,这只是一个说法而已。

而宏任务执行过程当中有可能会临时加上一些额外的需求,那这时候对于这些临时额外的需求呢可以选择作为一个新的宏任务重新进入到回调队列中去排队,就像我们有了一个新的需求过后我们重新上后面叫号排队一样。

例如我们这里所使用的的setTimeout的回调他就会作为宏任务再次到回调队列当中排队,那也可以像我刚刚的选择一样,我们直接作为当前这个任务的微任务,这个微任务也是我们在js这个领域的一个说法。就是直接在我当前这个任务结束过后就立即去执行而不是到整个队伍的末尾再重新排队。

这就是宏任务和微任务之间的一个差异,那Promise的回调就是作为微任务执行的,所以说他会在本轮任务结束的末尾去自动执行。那这也就是我们这里为什么先打印的Promise然后再打印的setTimeout的一个原因。

因为setTimeout它是以宏任务的形式进入到回调队列的末尾。

那微任务的概念实际上是在后来才被引入到js当中的,那他的目的就是为了提高我们应用的响应能力,就像是我们在生活当中如果说我们柜台他只允许我们重新排队不允许我们再办理过程中加一些额外的需求的话,那对于我们来讲的话整个这个效率其实会有一个大大的降低,那放在程序的角度实际上是一样的。

那我们在编程的过程中接触到的大部分异步调用的API都会作为宏任务进入到回调队列,而Promise对象,还有一个叫做MutationObserever的对象,还有在Node当中有一个process.nextTick那他们都会作为微任务直接在本轮调用的末尾就执行了。

14. Generator异步方案

那相比于传统回调函数的方式,Promise去处理异步调用最大的优势呢就是可以通过链式调用解决回调嵌套过深的问题。

那使用Promise去处理异步任务的串联执行,他的表现就是一个then然后去处理一个异步调用,最终整体会形成一个任务的链条,从而实现所有任务的串联执行。

ajax('/api/url1').then(value => {
    return ajax('/api/url2')
}).then(value => {
    return ajax('/api/url3')
}).then(value => {
    return ajax('/api/url4')
}).catch(error => {
    console.error(error)
})

但是呢这样写仍然会有大量的回调函数,虽然说他们相互之间没有嵌套但是呢他们还是没有办法达到我们传统同步代码那种可读性。

那如果说我们是传统同步代码的方式,那我们的代码呢,可能是我们下面例子显示的这种样式。

try {
    const value1 = ajax('/api/url1')
    console.log(value1)
    const value1 = ajax('/api/url2')
    console.log(value2)
    const value1 = ajax('/api/url3')
    console.log(value3)
    const value1 = ajax('/api/url4')
    console.log(value4)
} catch (e) {
    console.error(e)
}

那很明显,这种方式去写我们的代码他是最简洁,也是最容易阅读和理解的。那接下来我们就来看两种更优的异步编程写法。

那首先呢就是ES2015提供的Generator, 也就是生成器函数。

那在此之前呢我们已经简单了解过生成器函数的语法和他的一些基本的特点,我们这里呢再快速来看一下。

那首先呢,语法上生成器函数就是在普通函数的基础上多了一个 * 号,那我们去调用一个生成器函数他并不会立即去执行这个函数,而是得到一个生成器对象。

那直到我们手动调用这个对象的next方法,这个函数的函数体才会开始执行。

function * foo () {
    console.log('start')
}

const generator = foo()

generator.next()

那其次我们可以在函数的函数体中随时使用yield关键词向外返回一个值。那我们可以在next方法返回的对象中拿到这样一个值。

另外在返回的对象中还有一个done属性,用来去表示这个生成器是否已经全部执行完了。

function * foo () {
    console.log('start')
    yield 'foo'
}

const generator = foo()

const result = generator.next()
console.log(result) // {value: 'foo', done: false}

而且yield关键词并不会像return语句一样立即去结束这个函数的执行,他只是暂停我们这个生成器函数的执行,直到我们外界下一次去调用我们生成器对象的next方法时他就会继续从yield这个位置向下执行。

另外呢如果说我们调用生成器对象next方法时如果我们传入了一个参数的话,那所传入的这个参数会作为yield这个语句的返回值,也就是说我们在yield的左边实际上是可以接收到这个值的。

function * foo () {
    console.log('start')
    const res = yield 'foo'
    console.log(res) // bar
}

const generator = foo()

const result = generator.next()
console.log(result) // {value: 'foo', done: false}

generator.next('bar')

那除此之外呢,如果说我们在外部手动调用的是生成器对象的throw方法,那这个方法呢就可以对生成器内部就是生成器函数内部去抛出一个异常。那异步在继续向下执行的时候就会得到这个异常。

我们可以通过try catch 去捕获这里得到的异常。

function * foo () {
    console.log('start')

    try {
        const res = yield 'foo'
        console.log(res) // bar
    } catch (e) {
        console.log(e)
    }
}

const generator = foo()

const result = generator.next()
console.log(result) // {value: 'foo', done: false}

// generator.next('bar')
generator.throw(new Error('Generator error'))

那这里我们再来回顾一下这个生成器函数的一个执行过程。

那首先呢我们调用foo函数他并没有立即执行这个函数,只是得到了一个生成器对象。

然后我们第一次去调用这个生成器的next方法那我们这个foo函数的函数体才会开始执行,一直会执行到yield这个关键词所在的位置,把yield后面这个值给他返回出去,然后我们foo函数就会暂停下来,然后yield后面的值就会作为next方法的返回对象里面的value去返回。

然后再往后我们再调用一次next方法,只不过这一次我们传入了一个字符串参数,那这个时候呢foo函数就会从我们刚刚暂停的位置继续往下执行,而我们这里传入的bar字符串会作为yield这个语句的返回值,那foo函数继续执行呢会继续执行到下一个yield的位置。

那如果我们在外部调用的是生成器的throw方法,那这个方法也会让我们生成器函数继续往下执行,不过呢,他的作用是抛出一个异常,所以说我们foo函数继续执行的时候就得到了这样一个异常。

那理解了生成器函数的一个特点,以及他的执行过程过后呢,我们再来看看如何去使用Generator去管理我们的异步流程。

这里我们其实完全可以借助于yield他可以暂停生成器函数执行这样一个特点,来去使用生成器函数来去实现一个更优的异步编程体验,那我们来看具体的实现方式。

那这里我们先定义一个叫做main的生成器函数,然后在这个生成器的内部我们使用yield返回一个ajax函数的调用,那也就是返回了一个promise对象出去。

然后我们接收一下这个yield语句的返回值,我们去把他打印出来。

完成以后我们就可以在外界去调用这个生成器函数去得到一个生成器对象,然后我们再去调用一下这个对象的next方法。

function ajax (url) {
    return new Promise(function (resolve, reject) {

        var xhr = new XMLHttpRequest()

        xhr.open('GET', url)

        xhr.responseType = 'json'

        xhr.onload = function () {
            if (this.status === 200) {
                resolve(this.response)
            } else {
                reject(new Error(this.statusText))
            }
        }

        xhr.sned()
    })
}

function * main () {
    const users = yield ajax('/api/users.json')
    console.log(users)
}

const g = main()
g.next()

那这样的话我们的main函数就会执行到第一个yield的位置,也就是会去执行第一个ajax调用。

那这里next方法返回的对象的value呢就是yield所返回的promise对象, 所以说我们就可以在这个后面通过then的方式去指定这个promise的回调。在这个回调当中就可以拿到这个promise的执行结果。

那此时我们就可以通过再调用一次next把我们得到的这个data传递进去。

const g = main()
const result = g.next()
result.value.then(data => {
    g.next(data)
})

那这样的话我们这个main函数就可以接着继续往下执行了,而且我们传递进去的data会作为我们当前这个yield的返回值,那我们这样的话就可以拿到这个users。

我们运行之后可以发现想要的结果就被打印出来了。

那这样对于promise函数的内部我们就彻底消灭了promise的回调,有了一种近乎于同步代码的体验。

那我们可以再到main函数当中再去添加下一个yield的操作,那这一次呢,我们再去请求另外一个地址,然后呢我们同样把这个结果打印出来。

那此时呢,我们在外部第二次调用next的结果呢他就也会是一个promise对象,所以说我们仍然可以接收到它可以调用它value当中的then方法,按照相同的方式去处理我们这个promise。

function * main () {
    const users = yield ajax('/api/users.json')
    console.log(users)
    const posts = yield ajax('/api/posts.json')
    console.log(posts)
    const urls = yield ajax('/api/urls.json')
    console.log(urls)
}

const g = main()
const result = g.next()
result.value.then(data => {
    const result2 = g.next(data)
    result2.value.then(data => {
        result3 = g.next(data)
        result3.value.then(data => {
        g.next(data)
    })
    })
})

那以此类推,如果我们在main函数中有多次使用yield的方式去返回promise的对象,而且每一次返回的都是promise对象,那我们这里完全就可以不断在结果对象的then当中去调用next直到我们next所返回对象的done属性为true,也就是说我们这个main函数已经完全被执行完了过后再停止。

所以说我们这应该在每次去调用这个then方法之前先去判断一下结果的done属性是不是为true,如果是为true的话那就代表这个生成器已经结束了就没有必要再继续了。

const g = main()
const result = g.next()
result.value.then(data => {
    const result2 = g.next(data)

    if (result2.done) return

    result2.value.then(data => {
        const result3 = g.next(data)

        if (result3.done) return

        result3.value.then(data => {
            g.next(data)
        })
    })
})

那很明显这里我们完全可以使用递归的方式去不断迭代,直到我们返回对象的done属性为true,也就是生成器执行结束了过后我们结束这样一个递归。

那这里我们就尝试以递归的方式来去实现一个更通用的生成器函数执行器。

我们这里先去定一个函数叫做handleResult, 那这个函数我们让他接收一个result参数,那这个result实际上就是next方法返回的那样一个result。

那在这个函数的内部我们首先应该去判断一下这个result的done属性是不是为true,也就是这个生成器是否已经结束了,如果说结束了我们这个handleResult就没必要继续往下执行了,我们就直接return掉。

反之如果说这个生成器没有结束的话,那result的value就应该是一个promise对象,那我们就可以使用这个对象的then方法去处理他的请求结果。

那在请求的这个回调当中我们继续使用next让我们的生成器函数继续往下执行,并且我们把这里得到的数据传递进去。

那这个next方法他返回的又会是下一个result,那我们应该将这个result再次交给当前这个handleResult函数进行递归。

那这样的话我们只需要在外界去调用一下我们这个handleResult然后传入我们第一次next的结果就可以了。

const g = gengertor()
function handleResult (result) {
    if (result.done) return

    result.value.then(data => {
        handleResult(g.next(data))
    })
}

handleResult(g.next())

那后面的话只要我们的生成器不结束,那这个递归就会一直执行下去,会把我们在这个生成器函数当中所有的这种异步调用全部依次执行下去。

当然这里我们还需要去处理一下当这个promise失败过后的处理逻辑,因为目前这种情况下我们根本没有考虑promise失败的问题,那一旦任何一个promise失败了,那这里就会出现一个未捕获的异常。

那其实处理失败也非常简单,我们可以直接在这个promise的then方法中去添加一个失败的回调,那在失败的回调当中我们就可以直接调用生成器对象的throw方法,让这个生成器函数他在继续执行时得到一个异常就可以了。

const g = gengertor()
function handleResult (result) {
    if (result.done) return

    result.value.then(data => {
        handleResult(g.next(data))
    }, error => {
        g.throw(error)
    })
}

handleResult(g.next())

那这样的话我们就可以在main函数的内部通过try catch的方式去捕获这个异常,那得到这个异常之后我们尝试把他打印出来。

我们故意将urls地址写错,测试一下。

function * main () {
    try {
        const users = yield ajax('/api/users.json')
        console.log(users)
        const posts = yield ajax('/api/posts.json')
        console.log(posts)
        const urls = yield ajax('/api/urls11.json')
        console.log(urls)
    } catch (e) {
        console.log(e)
    }
}

运行后可以发现这个异常被捕获到了。

那以上这样一个过程实际上我们就完成了生成器函数的一个执行器,那这样对于这个生成器函数的调用逻辑呢实际上是完全可以复用的,我们可以把它封装成一个公共的函数。

例如我们这里再去定义一个叫做co的函数,那这个函数内部我们去接收一个生成器函数,然后在内部我们按照刚刚的执行逻辑我们去对这个生成器函数去执行。

那这样一个co的函数在下一次你在使用到生成器函数去完成异步编程的时候就可以使用到。

function co (generator) {
    const g = generator()
    function handleResult (result) {
        if (result.done) return

        result.value.then(data => {
            handleResult(g.next(data))
        }, error => {
            g.throw(error)
        })
    }
    handleResult(g.next())
}

co(main)

当然像这样的生成器函数执行器呢在社区当中早就有一个完善的库,就叫做co (https://github.com/tj/co)

那你也可以自己尝试去使用一下,那这中co的异步方案呢实际上在15年之前是特别流行的,但是后来呢因为在语言本身有了async/await过后, 这种方案就相对来讲没有那么普及了。

不过呢使用Generator这个方案,他最明显的一个变化就是让我们异步调用再次回归到扁平化,这也是我们javaScript异步编程发展过程当中很重要的一步。

所以说你不应该仅仅了解他的用法,还应该去理解他是如何工作的,当然了,我们在日后的开发过程中我们可能还是会选择最新的async/await的方式。

15. Async 函数

有了Generator过后,javaScript中的异步编程基本就已经与同步代码有类似的体验了,但是使用Generator这种异步方案我们还需要自己手动去编写一个执行器函数,就像我们在上一个例子当中定义的co函数一样。所以说会比较麻烦。

而在ECMAScript2017的标准当中,新增了一个叫做async的函数,那他同样提供了这种扁平化的异步编程体验。

而且呢他是语言层面标准的异步编程语法,所以说使用起来就会更加的方便一点。

那其实async函数就是生成器函数一种更方便的语法糖,所以说语法上async函数和Generator函数是非常类似的。

我们只需要把生成器函数修改成使用async关键词去修饰的普通函数,然后在内部素有的yield关键字我们把它替换成await就可以了。

// function * main () {
async function main () {
    try {
        // const users = yield ajax('/api/users.json')
        const users = await ajax('/api/users.json')
        console.log(users)
        // const posts = yield ajax('/api/posts.json')
        const posts = await ajax('/api/posts.json')
        console.log(posts)
        const urls = await ajax('/api/urls11.json')
        // const urls = yield ajax('/api/urls11.json')
        console.log(urls)
    } catch (e) {
        console.log(e)
    }
}

那这样的话,我们这样一个函数就是一个标准的async函数了,我们可以直接在外面去调用这个函数。

main()

那执行这个函数的话,内部这个执行过程呢他会跟我们刚刚的Generator函数是完全一样的,所以说他的效果也是完全一致的。

那相比于Generator,async函数最大的好处呢就是他不需要再去配合一个类似于co这样的执行器,因为他是语言层面的标准异步编程语法。

其次呢async函数可以给我们返回一个promise对象,那这样的话就更加利于我们对整体进行控制。

那除此之外呢关于async函数还有一个需要大家注意的点就是,async当中使用的这个await关键词他只能出现在async函数内部,他不能直接在外部,也就是最顶层作用域去使用。

不过呢,关于在最外层直接去使用await的功能呢现在已经在开发了,不久以后有可能会出现在标准当中,那到时候我们去使用async函数就会更加方便一些。

那以上就是我们对async函数基本的了解,那如果说你了解我们刚刚所说的生成器的异步方案,那async函数呢他只是写法上有略微的差异,其他所有的东西都是相同的,所以说他也没有什么太值得我们去深究的东西。

转载须知

如转载必须标明文章出处文章名称文章作者,格式如下:

转自:【致前端 - https://madaozhijian.com】 前端异步编程  "隐冬"