为什么需要Promise - 传统回调函数方式的问题
Javascript中,我们处理异步的方式通常是传递一个回调函数(callback) 这个函数将在异步任务执行完成之后被调用。但是这种方式存在一些问题。
回调地狱
我们通常听到最多的关于回调函数的问题就是回调地狱,当我们需要连续执行多个异步任务,并且这些异步任务之间有先后依赖关系时,我们就需要嵌套回调函数。
如我们需要使用 fetch(url,callback) 函数从不同的四个地址 url1 -> url4 请求回四个结果并且拼接,其中后面的请求需要在前面的请求返回结果后发送,就写出了如下代码:
fetch('url1',(result1)=>{
fetch('url2',(result2)=>{
fetch('url3',(result3)=>{
fetch('url4',result4=>{
console.log(result1+result2+result3+result4)
})
})
})
})
这段代码看上去很混乱,每个请求需要放到上一个请求的回调函数中,导致层层嵌套。当然四个请求看上去似乎还算清晰,但是当业务复杂,请求数量更多时,代码的可阅读性就变得很差。回调函数的方式与我们大脑线性的思维模式是有冲突的,所以我们需要使用其他方式尽可能的将嵌套的形式转变成线性的模式,如:
const result1 = fetch('url1')
const result2 = fetch('url2')
const result3 = fetch('url3')
const result4 = fetch('url4')
console.log(result1+result2+result3+result4)
当然这是一段伪代码,熟悉js的小伙伴能看出来result的值不会被同步获得,最后的输出结果是 undefined+undefined+undefined+undefined = NaN 后面我会介绍如何将这种异步的代码通过同步的方式写出来,并且能成功执行。
信任问题
除了回调地狱带来的阅读困难,回调更致命的是信任问题,我们看以下几个例子
Zalgo
“Zalgo” (一种都市传说中的魔鬼),比喻在一个函数中,其回调函数可能被同步执行,也可能被异步执行,这样的代码会带来很多的不确定性, 编写这种函数的行为, 被称作是"release Zalgo" (将Zalgo释放了出来)
我们看一个例子:
function ZalgoFunc(callback) {
// 随机生成 0-9的整数
if (Math.floor(Math.random() * 10) <= 4) {
callback('返回同步数据!')
} else {
setTimeout(() => {
callback('返回异步数据')
});
}
}
let dataFromCallback = ''
ZalgoFunc((data) => {
dataFromCallback = data
})
console.log('返回的数据为', dataFromCallback)
ZalgoFunc这个函数需要传递进一个回调函数,并且根据随机值产生的范围,返回不同的数据。当随机数生成在 0-4的区间时,会同步调用回调函数并且返回 "返回同步数据" 这个字符串,当随机数生成在 5-9这个区间时,会异步调用回调函数并且返回 "返回异步数据" 这个字符串。可以看到ZalgoFunc这个函数有50%的概率同步调用回调函数,由50%的概率会异步调用回调函数,这就给我们调用该函数造成了很多的不确定性。
当ZalgoFunc被同步调用时,我们可以通过回调函数直接获得返回的结果,但是当异步调用时,我们无法通过同步代码直接获得返回值,所以下面的console由50%的概率输出 undefined
疑问?
或许有的小伙伴会问,我自己写的函数,我还不能确定它的行为吗,我不写Zalgo函数,或者对传递进去的callback做一些“防范” 不就可以了吗。
当然,小伙伴们不必纠结zalgoFunc这个函数的意义,我这里只是举个例子,在真实的开发中,ZalgoFunc大概率是我们调用的第三方库,因此我们可能碰到各种不确定性,比如一个函数 10%的概率同步调用回调函数 90%的概率异步调用回调函数,更可能根据我们传递进去的参数不同,由不同的行为。
ZalgoFunc给我们的调试带来了很多困难,更给我们的代码带来了很多不确定性,很多时候我们面对的ZalgoFunc是个黑盒,我们无法确定它的行为。
看到一张很有趣的图片:
调用次数过多或者不被调用的问题
大部分情况下,接受我们的回调函数的函数可能由第三方提供,对我们来说有很多不确定性,我们期待传入的回调函数会在我们期待的时机,调用我们期待的次数,但是很多时候并不会这样。
《你不知道的javascript(中卷)》给过一个 例子很合适,拿来和小伙伴们分享一下。
作为一个公司的员工, 你需要开发一个网上商城, payWithYourMoney是你在确认购买后执行的扣费的函数, 由于公司需要对购买的数据做追踪分析, 这里需要用到一个做数据分析的第三方公司提供的analytics对象中的purchase函数。 代码看起来像这样
analytics.purchase( purchaseData, payWithYourMoney);
在这情况下,可能我们会忽略的一个事实是: 我们已经把payWithYourMoney 的控制权完全交给了analytics.purchase函数了,这让我们的回调“任人宰割”
然后有一天,你的一个客户在购买之后被扣费了5次,客户很生气的对你们公司进行了投诉。你一层一层的寻找问题,最终发现是第三方公司的一个bug,把你传入的payWhithYouerMoney函数执行了5次!
所以你意识到,payWithYourMoney的控制完全不在自己的手里 !!!!!这是一件很危险的事情。
后来, 为了保证只支付一次, 代码改成了这样:
var analysisFlag = true // 判断是否已经分析(支付)过一次了
analytics.purchase( purchaseData, function(){
if (!analysisFlag) {
payWithYourMoney ()
analysisFlag = false
}
} );
虽然这样解决了多次调用的问题,但是这种方式无异于 “打补丁” 因为本质上,你的回调函数控制权力还是在第三方模块的手里,你不知道后续还会不会出现其他问题,你需要在每次出现问题之后定位,打补丁,定位,打补丁!但是这些本不是你的问题!
吞掉异常
这个应该很好理解,当你在你的回调函数中throw一个异常或者第三方代码中出现问题,如果第三方代码中使用如下方式调用你的回调函数,那么你在foo函数外面将无法接收到你callback抛出的错误信息。
function foo(callback){
try{
callback()
}catch(e){
/*
什么都不做,或者在内部处理完之后,没有把异常再抛出来
那么在第三方模块外部,无法接收到这个错误
*/
}
}
什么是 Promise?
Promise可以解释为一个许诺,你可以类比成,当你去麦当劳买汉堡,付款之后收到一个取餐码,在你的汉堡做好之后可以凭借这个号码来获得。在配餐的过程中,你不必一直等待,你可以做点别的事情,刷刷手机,当配餐结束后,你再通过号码取餐。 描写成promise就是
new Promise((resolve,reject)=>{
// 汉堡制作
const hanburger = makeHamburger()
resolve(hamburger)
}).then(hamburger=>{
console.log('eat',hamburger)
})
此时你拿到了你的汉堡,可以通过then方法来进行接下来的操作,比如 吃...
当然,你的汉堡也可能会因为如缺少材料等等各种因素无法按时交付,此时Promise这个许诺就是reject状态。此时,你可以通过then中第二个参数传入回调方法来解决,比如 更换成炸鸡腿...
new Promise((resolve, reject) => {
// 汉堡制作
try {
const hanburger = makeHamburger()
resolve(hamburger)
} catch (e) {
reject('不好意思,汉堡无法交付,因为:', e)
}
}).then(hamburger=>{
console.log('eat',hamburger)
},reason=>{
console.log('由于',reason,'我需要更换成炸鸡腿...')
}))
ECMA在ES6中正式引入Promise,在此之前,有很懂民间社区实现的类Promise,如Jquery中的ajax,但是本质上都大同小异。
[Promises/A+] 规范了Promise的行为
我们可以看到,Promise是一个包含then方法的函数或者方法,也就是说,任何包含有 then 方法的function/object都可以被称为是Promise,我们可以用如下的方式来判断。
function isPromise(p) {
if (p &&
(typeof p === 'function' || typeof p === 'object') &&
(typeof p.then === 'function')) {
return true
}
return false
}
这种类型判断方式,称为“鸭子类型(duck type)” 即: 如果看起来像鸭子,叫起来像鸭子,那它就一定是只鸭子!
我们也称能通过Promise鸭子类型检测的对象/函数是 thenable(注意,ES6官方的Promise也是thenable的) 的 也就是可以调用then的函数/对象
为什么需要鸭子类型或者thenable?
上面提到,在ECMA在ES6中引入Promise之前,有很多的民间或者第三方写的库,模块,都使用了类Promise的方式,为了做到更好的兼容性,保证这些类Promise的方式,可以和官方的Promise兼容,所以我们使用这种相对"宽松"的方式来判断Promise。
通过Promise.resolve() 后面会说 可以将thenable的类Promise对象/方法 转换成官方的promise。
Promise用法(es6)
我们可以简单归纳一下Promise的用法:
1. promise是一个对象,其中包含有一个属性state,其有三种状态
- pending: 表示promise状态还没确定,可以类比为 汉堡还没做好,能不能做成还不确定, 也是promise的默认状态
- fulfilled: 表示promise已经成功,类比为汉堡已经成功交付
- rejected: 表示promise拒绝,类比汉堡缺少食材,无法完成交付
状态一旦确定将无法在改变,当我们在executor中第二次调用resolve或reject时,将不会生效
2. promise对象包含一个value值,其默认值为undefined,当promise的状态变成fulfilled或者rejected时,value值会记录fulfilled的结果或者rejected的错误原因reason
3. promise对象需要通过Promise构造方法,通过new的方式创建,调用Promise的时候,我们需要传入一个执行器函数 executor,我们需要在这个执行器函数中接受两个参数,resolve,reject,其中resolve方法表示promise成功,其参数可选,表示成功值,reject表示promise失败,其参数可选,表示失败原因。Promise构造方法在创建promise对象时,会调用executor,并且传入resolve和reject方法,当我们在executor中调用resolve方法时,会改变promise.state为fulfilled,并且用promise.value记录成功结果,调用reject同理会改变state为rejected,并且用value记录失败原因。
new Promise((resolve, reject) => {
// executor
// 汉堡制作
try {
const hanburger = makeHamburger()
// 成功 调用resolve
resolve(hamburger)
} catch (e) {
// 失败 调用reject
reject('不好意思,汉堡无法交付,因为:', e)
}
})
4. promise对象包含then方法,其then方法需要传入两个函数 onFulfilled 和 onRejected 也就是promise成功/失败 的处理函数,这两个函数可选。在调用then的时候,此时的promise可能有三种状态
- 如果此时的promise是fulfilled或者rejected的状态,会将传入的onFulfilled方法或onRejected方法放入微队列,在同步代码执行之后,执行onFulfilled或onRejected的内容。
- 如果此时的promise是pending的状态,此时传入的onFulfilled或onRejected方法发会被promise对象暂存,当promise的状态确定之后,会将相应的方法放入微队列中异步执行。
5. promise对象的then方法可以"链式调用",可以用多个then的方式对结果多步处理,其实现的原理是then函数会返回promise对象,具体分成几个情况
- 如果then函数不传入onFulfilled或onRejected,那么默认返回一个状态为fulfilled并且value为当前promise值的promise,这个性质也是then方法的穿透性
new Promise((resolve, reject) => {
resolve(100)
})
.then() // 返回 Promise{ value: 100, state: 'fulfilled'} 被穿透
.then() // 返回 Promise{ value: 100, state: 'fulfilled'} 被穿透
.then((value)=>{ // value为200
console.log(value+100) //200
})
- 如果在onFulfilled或者onReject中显式返回一个promise对象,则会跟踪这个promise对象的结果,返回一个与之一致(value,state相同)的promise
new Promise((resolve, reject) => {
resolve(100)
}).then((value)=>{
return new Promise((resolve,reject)=>{
resolve('then1',value+100)
})
}).then((value)=>{ //返回 Promise{value: "then1 200",state:fulfilled}
console.log(value) // 'then1 200'
})
[注意,这里的非promise指的是非thenable 也就是用duck type判断的promise,不是Promise实例也可以,如下图所示,第一个then中的onFulfilled函数返回一个thenable,则then函数会返回一个与thenable一致的promise]
new Promise((resolve, reject) => {
resolve(100)
}).then((value)=>{
return {
// thenable
then:function(r,j){
r('thenable')
}
} //返回 Promise{value: 'thenable',state:fulfilled}
}).then((value)=>{
console.log(value) // 'thenable'
})
- 如果返回一个非promise对象的值,则会用一个fulfilled的promise包裹该值,并且返回(注意 一定是成功状态 fulfilled的)
new Promise((resolve, reject) => {
resolve(100)
}).then((value)=>{
return value+100
}).then((value)=>{ // Promise{value: 200,state:'fulfilled'}
console.log(value) // 200
})
5. 静态方法 Promise.resolve和Promise.reject
区分这两个方法个executor中的resolve和reject, Promise.resolve和Promise.reject是放在Promise构造函数上的
这两个函数的作用是,返回一个成功/失败状态的promise对象,可以接受一个参数,可以用来快速创建成功/失败状态的promise对象。
其中Promise.reject 的参数无论是什么 promise还是非promise 都直接将其包装为一个rejected状态的promise。
但是,Promise.resolve方法就有点意思,需要分情况,类似于上面的then
- 如果不传递任何值,相当于传入undefiend,会返回一个value为undefined的成功状态promise
- 如果传递一个非promise(非thenable)的值,则会将其包装到一个成功的promise返回
- 如果传递一个 thenable(包含Promise)的函数/对象,则会跟随其then方法,返回一个状态,value一致的ES6官方Promise。这个也就是上面说的,为了解决各种社区的类Promise的兼容性问题,可以把类Promise的thenable对象/方法传入官方的Promise.resolve方法,从而得到一个行为一致的官方Promise。
6. 同一个promise对象,可以多次调用then,并且都会生效,如果调用then的时候promise还是pending的状态,则promise对象会暂存这些回调,并且在状态确定后加入微队列,其底层实现原理是保存了一个callback数组,在调用resolve/reject方法后会遍历这个数组并且加入到微队列,这个在Promise手动实现的文章中再细说,先挖个坑!
7. 这点是es6 Promise不符合Promise A+ 规范的地方,es6 promise包含catch和finally方法,可以在链式调用最后来处理错误信息并且做收尾工作,这个是为了方便Promise使用加入的
fetchData('url1')
.then(val => fetchData('url2', val))
.then(val => fetchData('url3', val))
.then(val => fetchData('url4', val))
.catch(e => console.log(e))
.finally(()=>console.log('fetch end!'))
8. Promise构造器的静态方法 all和race 其接受一个promise对象数组,
Promise.all会等所有promise对象都决议之后,返回一个value为所有promise value的list的成功状态promise对象,如果有一个promise对象失败,则整个promise.all返回失败的promise,reason为失败promise对象的value。 注意Promise.all 返回value的list遵循promise对象成功的顺序,不保证和传入的promise顺序一致。
Promise.race则比较简单,会返回其列表中最先返回的promise。 (在解决回调函数不被调用时需要用到)
Promise如何解决回调问题?
解决回调地狱
我们可以通过Promise的链式调用来解决嵌套回调函数的问题。
以上面fetch方法的案例做修改,首先我们需要对fetch方法进行Promise封装
function fetchData(url, beforeVal = '') {
return new Promise((resolve, reject) => {
try {
fetch(url, (res) => {
resolve(beforeVal + res)
})
} catch (err) {
reject(err)
}
})
}
Promise的好处是,不论executor中的代码是同步还是异步的,都将其统一处理为"未来值" 也就是返回一个promise凭据,可以用这个promise代表将来要得到的值
我们可以将嵌套的回调函数优化为:
fetchData('url1')
.then(val => fetchData('url2', val))
.then(val => fetchData('url3', val))
.then(val => fetchData('url4', val))
.catch(e => console.log(e))
.finally(()=>console.log('fetch end!'))
相比于嵌套的回调函数,更直观,线性,符合直觉。
同时,借助生成器(语法糖async await) 我们可以去掉then中的回调函数,用同步的方式写异步代码,更直观的使用Promise。 详细可以见我的文章: 迭代器和生成器总结https://blog.csdn.net/weixin_40710412/article/details/135381302?spm=1001.2014.3001.5502
解决可信
解决Zalgo和顺序问题
Zalgo和回调函数调用顺序出现的问题本质在于,我们无法掌握回调函数的调用时机,其无法在我们期望的时机被调用,导致我们无法获得任务的结果值,而Promise使用then的方式来处理回调结果,并且then中的任务都是被放在微任务队列中,在同步任务执行之后被执行。
ZalgoFunc可以用Promise改写成如下方式:
function PromiseFunc() {
return new Promise((resolve, reject) => {
// 随机生成 0-9的整数
if (Math.floor(Math.random() * 10) <= 4) {
resolve('返回同步数据!')
} else {
setTimeout(() => {
resolve('返回异步数据')
});
}
})
}
PromiseFunc.then(dataFromPromise => console.log('返回的数据为', dataFromPromise))
这种机制就保证了,我们获得的返回结果,一定是最新的,不论PromiseFunc这个第三方函数内部调用resolve是同步的还是异步的,我们都将其当成异步的,并且用微任务的形式来处理返回结果,从而解决了zalgo等一系列调用顺序导致的问题!
解决调用次数过多
Promise状态改变是单向的,一旦从未决议的pending转变成了fulfilled或reject就无法再改变,所以可以利用这个性质,对支付案例做如下修改:
analytics.purchase( purchaseData, payWithYourMoney);
// 改写成
function purchase(purchaseData){
return new Promise((resolve,reject)=>{
//处理 purchaseData
if(success){
resolve(value)
}else{
reject(reason)
}
})
}
purchase.then(value=> payWithYourMoney(value))
由于purchase函数返回一个promise,而promise从pending转换到fulfilled或rejected的过程只有一次,所以我们只需要调用一次then函数,并且传入扣费方法,即可保证扣费方法只调用一次。
通过上面两个案例我们可以看出,当我们把callback放到promise的then方法中后,回调的调用时机,调用次数,都取决于我们调用then的时机,从而实现了控制反转,让调用回调的时机,次数由我们自己决定,而不是由第三方函数决定。
如果你看了我的生成器的文章,你会发现,使用生成器+callback处理异步的方式和Promise+生成器处理异步的方式,利用的就是控制反转!
解决回调函数不被调用
这个就利用到了上面介绍的 Promise.race方法
我们假设foo是一个返回Promise的第三方函数,这个函数由于一些内部问题,bug等永远不会决议
function delayTimer(delay){
return new Promise((resolve,reject)=>{
setTimeout(() => {
reject('运行超时!')
}, delay);
})
}
Promise.race([
foo(),
// 设置3000ms
delayTimer(3000)
]).then((value)=>{
console.log('foo返回的结果是:',value)
},(reason)=>{
console.log('失败,原因是:',reason) //失败,原因是: 运行超时!
})
使用Promise.race() 方法 配合一个超时的计时器,即可解决foo函数长时间不决议的问题,从而解决了回调函数不被调用的问题!
解决吞掉异常的问题
Promise的executor中,如果调用了reject或者出现异常,那么会直接将promise对象的状态置为rejected,并且通过value返回错误原因,可以通过then的第二个处理函数或者最后的catch来处理
new Promise((resolve,reject)=>{
throw new Error('err throw')
}).catch(e=>{console.log(e)})
thenable&Promise.resolve 如何解决兼容性?
最后写一下我对于 thenable和resolve的疑问思考和理解吧,也算是记录一下!
你有没有发现,我们创建一个promise对象时,需要给Promise构造函数传一个executor,这个executor的格式为:
function executor(resolve,reject){
// 我们自己的逻辑代码
resolve(value) / reject(reason)
}
而在then方法中,其需要传递两个函数
promiseObj.then(onFulfilled,onRejected)
有没有发现这两个地方有很奇妙的对应关系,在executor中,我们负责调用Promise构造方法提供给我们的两个内置的函数来决定promise的状态
在then方法中,我们需要传递给promise对象两个方法来让promise对象根据状态来为我们执行
为什么Promise A+要把promise设计成包含then方法的对象/函数,这个then方法是做什么用的呢?
我的理解是 then方法是在promise状态确定后,调用对应的回调方法。
为什么Promise.resolve方法可以把传入的类Promise的thenable对象转换成Promise对象,其原理就是,在检测传入的对象为thenable之后,会直接调用其then函数,并且传入创建新对象的resolve和reject方法,不用管这个类promise对象其执行器是如何运作的,也不用关心这个类promise如何,何时决议,只需要调用其then方法,在其决议之后就会自动调用我们传入的resolve或reject方法,这样就创建了新的ES6 官方的Promise对象。
class MyPromise {
static _isThenable(value){
if(value&&
(typeof value === 'function' || typeof value === 'object')&&
typeof value.then === 'function'){
return true
}
return false
}
static resolve(value){
return new MyPromise((resolve,reject)=>{
if(value instanceof MyPromise){
value.then(resolve,reject)
}else if(MyPromise._isThenable(value)){
value.then(resolve,reject)
}
else{
resolve(value)
}
})
}
....
}
用这种方式,使得ES6 标准下的Promise对象可以兼容之前的很多类Promise!