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中与异步编程相关的一些内容,主要包括以下几点。

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 beginconsole.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函数来讲,他的调用就已经完成了,所以说代码会继续往下执行。然后又遇到了一个setimeout调用,同理也是先压栈,然后开启另一个倒计时器。然后弹栈。最后又遇到了console.log调用,打印了消息过后对于整体的这个匿名调用就已经完成了。所以说这个调用栈就会被清空掉。

然后这时候Event loop因为调用栈里面已经没有工作了,所以说Event loop就会发挥作用。Event loop其实只做一件事情,就是负责监听调用栈和消息队列,一但调用栈当中所有的任务都结束了,事件循环就会从消息队列中取出第一个回调函数压入到调用栈。只不过此时消息队列当是空的什么都没有,所以说执行就相当于是暂停下来了。

此时回过头看一看这里的两个倒计时器,自从前面开启了这两个倒计时过后代码就再也没有管过他们。而是直接往后执行了。这里timer1函数所对应的倒计时他应该是倒计1.8stimer21s。很明显,timer2所对应的倒计时应该先结束。

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

一但消息队列中发生了变化,事件循环就会监听到然后把消息队列中的第一个也就是timer2函数取出来,压入到调用栈。继续执行timer2。此时对于调用栈来讲的话,相当于开启了新一轮的执行。执行过程与刚刚分析的是一致的。

如果这个过程中又遇到了有异步调用,也是相同的情况,先会把他放入到api环境里面单独去执行,然后在往后就是不断这样重复。直到调用栈和消息队列当中都没有需要继续执行的任务了整体的代码就结束了。

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

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

整个过程都是通过内部的消息队列和事件循环去实现的,因为这里是分开分析的,所以说你会认为这些步骤都会有一定的先后顺序,其实不是这样的。因为他们各自都有各自的time-line例如倒计时器,他开始过后就会自动开始倒计时,根本不会管调用栈或者队列当中是什么情况。

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

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

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

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

更具体一点来说就是通过javaScript调用的某些内部的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中被标准化进了语言规范。

Promise实际上是一个用来表示异步任务结束后是成功还是失败状态的。就像内部对外界做出了一个承诺,一开始这个承诺是一种待定的状态,英文叫做pendding,最终有可能成功,英文叫做Fulfilled,也有可能失败,英文是Rejected

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

承诺结束过后不管这个承诺最终是达成或者是失败,都会有相应的反应,比如说如果达成了可能会很感激,如果说失败了可能会臭骂一顿。这也就是说,在承诺状态最终明确了过后,都会有相对应的任务会被执行。

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

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

6. Promise 基本用法

在代码层面Promise实际上是ES2015提供的全局类型,可以使用他来构造一个Promise实例也就是创建一个承诺,这个类型的构造函数需要接收一个函数作为参数。函数就可以理解为兑现承诺的逻辑,函数会在构造Promise的过程被同步执行。在这个函数内部它能够接收到两个参数,分别是resolvereject,二者都是一个函数。resolve函数的作用就是将对象的状态修改为Fulfilled也就是成功。一般将异步任务的结果会通过resolve的参数传递出去,比如传入100固定值。reject函数的作用是将Promise的状态修改为rejected也就是失败。失败的参数一般传递的是一个错误的对象,用来表示承诺为什么失败,也就是一个理由。可以传入一个全新的错误对象。然后错误的描述描述就是promise rejected

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

前面说过Promise的状态一旦确定过后就不能再被修改,所以说在这个函数中resolvereject二者只能调用其一。先注释掉reject调用,只去调用resolve函数。

Promise实例被创建过后可以用实例的then方法分别指定onFulfilledonRejected回调函数。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)  
})

完成以后运行代码可以看到resolve传递的100被正常打印出来了。回到代码当中,只调用reject函数注释掉resolve的调用。打印的就是reject中传入的错误对象了。

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)  
})

需要注意的是,即便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对象,而不是一个字符串。然后再注册一下xhrload事件,这同样也是html5当中提供的新事件。这个事件是请求完成后也就是readystatus等于4才会执行。

在请求完成事件中应该先判断请求的状态是不是200,如果是的话那意味着请求已经成功了。调用resolve表示成功。resolve传入的是请求得到的响应结果。反之如果请求失败就调用reject函数表示Promise失败,传入错误信息对象是当前的状态文本。

完成以后调用xhrsend方法,开始执行这个异步请求。这样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)
})

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最常见的误区,正确的做法实际上是借助于Promisethen链式调用的特点尽量保证异步任务的扁平化。

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的链条,也就是承诺结束了过后再去返回一个新的承诺。每一个承诺都可以负责一个异步任务相互之间又没有什么影响。意味着如果这里不断的链式调用then方法,每一个then方法实际上都是在为上一个then方法返回的Promise对象去添加状态明确过后的回调。Promise会依次执行添加的回调函数,自然也会依次执行。

而且可以在then的回调中手动返回Promise对象,例如在第一个ajaxthen中返回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执行。

new Error()

onRejected回调实际上是为Promise异常做一些处理,在Promise失败了或者出现异常时候会被执行。其实关于onRejected回调的注册还有一个更常见的用法,就是使用Promise实例的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('/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类型当中还有几个静态方法也经常会用到,依次来看一下。

1. Promise.resolve()

这个方法的作用是快速的把一个值转换为一个Promise对象,例如通过Promise.resolve传入foo字符串会直接回返回一个状态为FulfilledPromise对象,也就是成功的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方法,也就是说在这个方法中可以接收到onFulfilledonRejected两个回调,调用onFulfilled传入一个值。那这样一个对象也可以作为一个Promise对象被执行,在后面的then方法中,也能拿到这里所对应传入的值。

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

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

2. Promise.reject()

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

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

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

3. Promise.all()

前面介绍的操作都是通过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.allPromise数组组合成一个新的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数组当中所有的地址。

4. Promise.race()

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

Promise.all有所不同的是Promise.all是等待所有的任务结束后才会结束,而Promise.race是跟着所有任务中第一个完成的任务一起结束。也就是说只要有任何一个任务完成了所返回的新的Promise对象也会完成。

例如这里先去调用ajax函数去发送一个请求,这样就可以得到一个Promise对象,然后再去单独的创建一个独立的Promise对象,这个Promise对象的内部使用setTimeout500毫秒之后以失败的方式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为准。

Promise.all会等待所组合的所有Promise都结束而且是成功结束才会完成。而Promise.race只会等待第一个结束的任务。

12. 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回调就会作为宏任务再次到回调队列当中排队,也可以像我刚刚的选择一样,直接作为当前这个任务的微任务,就是直接在我当前这个任务结束过后就立即执行而不是到整个队伍的末尾再重新排队。

这就是宏任务和微任务之间的一个差异,Promise的回调就是作为微任务执行的,所以说他会在本轮任务结束的末尾自动执行。这也就是为什么先打印的Promise然后再打印的setTimeout,因为setTimeout是以宏任务的形式进入到回调队列的末尾。

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

在编程的过程中接触到的大部分异步调用的API都会作为宏任务进入到回调队列,而Promise对象、MutationObserever对象,以及Node中的process.nextTick他们都会作为微任务直接在本轮调用的末尾就执行。

13. Generator

使用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'))

那理解了生成器函数的特点以及他的执行过程呢,再来看看如何使用Generator去管理异步流程。其实完全可以借助于yield可以暂停生成器函数执行这样的特点,来使用生成器函数去实现更优的异步编程体验。

这里先定义一个叫做main的生成器函数,在这个生成器的内部使用yield返回一个ajax函数的调用,然后接收一下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对象按照相同的方式去处理他。

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。在这个函数的内部首先应该去判断一下resultdone属性是否为true,也就是这个生成器是否已经结束了,如果结束了handleResult就没必要继续往下执行了,直接return掉。如果没有结束,resultvalue应该是一个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失败的处理逻辑,可以在Promisethen方法中去添加一个失败的回调,在失败的回调中直接调用生成器对象的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

也可以自己尝试去使用一下,co的异步方案实际上在15年之前是特别流行的,但是后来因为在语言本身有了async/await,这种方案就相对来讲没有那么普及了。不过使用Generator方案最明显的一个变化就是让异步调用再次回归到扁平化,这也是javaScript异步编程发展过程当中很重要的一步。

14. 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函数完全一样,效果也是完全一致的。相比于Generatorasync函数最大的好处就是不需要配合类似于co这样的执行器,其次async函数可以返回Promise对象,这样更加利于对整体进行控制。除此之外关于async函数还有一个需要注意的点,await关键词只能出现在async函数内部,不能直接在外部也就是最顶层作用域使用。

不过关于在最外层直接使用await的功能现在已经在开发了,不久以后有可能会出现在标准当中,到时候使用async函数就会更加方便一些。以上就是对async函数基本的了解,如果了解刚刚所说的生成器的异步方案,那async函数只是写法上有略微的差异,其他所有的东西都是相同的,所以也没有什么太值得深究的东西。

转载须知

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

转自:【致前端 - zhiqianduan.com】 前端异步编程  "隐冬"
请输入评论...