概述

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

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

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

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

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

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

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

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

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

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

那这就是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他的回调执行的时序问题还比较特殊,这个问题我们在后面会有一个专门的章节来说。

使用案例

接下来我们再来看一个使用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一个基本的应用。

常见误区

通过前面的尝试我们发现从表象上来看,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方法的链式调用的特点,尽量去保证我们异步任务的扁平化。

链式调用

其实相比于传统回调函数的方式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对象去注册了对应的回调。

异常处理

正如前面所说,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对象
})

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