Promise完全详解

这篇文章是我读《你不知道的js》时做的笔记,如有错误和疑惑请在评论区指出,查看代码高亮优化版原文请点击链接,欢迎watch和star

类比

比如我们在高峰期去麦当劳点餐,告诉服务员要一个汉堡,服务员给你一个订单收据上面印着2204,告诉你先等着取餐。这里订单号即为一个Promise,保证最后我会得到我的汉堡。这时你要拿好你的订单收据,用来拿他和汉堡交换;而且在排队等餐的过程中,你可能会刷刷手机或是和朋友说要一起吃汉堡;你拿到订单号时也不会去想这个汉堡,尽管你很想吃,这是因为你已经把订单号当作了汉堡的占位符;从根本上来说,这个占位符,使这个值不再依赖时间,这是一个未来值。最终屏幕上印着取餐单号2204,你拿着订单收据,把他交给收银员,最后换来了汉堡。也就是说,一旦你需要的值(汉堡)准备好了,你就需要用承诺值(订单收据)来换取这个值本身。

但也有一种情况是,你去收银台拿汉堡时,服务员说,你要的汉堡卖完了。这时未来值还有一个重要特性:他可能成功、也会失败。每次点汉堡时,要么会得到一个汉堡,要么会得到一个售罄消息。

现在值与未来值

var x,y=2
x+y // NaN  x没有被决议(resolved)

+运算符不会等待x,y都准备好,再进行运算。如果有一种方式,来判断两个值的准备状态,如果任何一个没有准备好,就等待二者都准备好,在进行后面的计算。promise为了统一处理现在和将来,所有的操作都成了异步。

Promise值

用promise改写上述代码,使两个值都准备好后,再进行加法操作。

function add(x,y){
    return Promise.all([x,y])
    .then(values=>values[0]+values[1])
}

// fetchX(),fetchY()返回相应的promise,就绪状态未知
add(fetchX(),fetchY())
.then(sum=>console.log(sum))

fetchX和fetchY先直接调用,返回一个promise,传给add。add创建并返回一个Promise,通过调用then等待promise,add加运算完成后,sum已经准备好了(resolve),将会打印出来。

汉堡有可能售罄,程序也可能出错,这时,promise的决议状态为拒绝而不是完成(可能是程序逻辑直接设置的,也有可能是runtime异常隐式得出的值)。

从外部看,由于promise封装了依赖于时间的状态(等待底层值的完成或拒绝,promise本身是与时间无关的),他可以按照可预测的方式组成,不需要开发者关心时序或底层的结果。一旦promise决议,此刻他就成为了外部不可变的值。

完成事件

promise可以说是一种在异步任务中的流程控制机制。我们无需知道他要在什么时候开始,什么时候结束;我们只需要在他完成后发起一个通知,得到这个通知后我们来进行下一个任务。如下代码,foo执行完成后,建立一个listener时间通知处理对象;然后建立两个事件监听器,一个监听"completion",一个监听"failure"。

function foo(){
    // 。。。耗时的工作
    return listener
}
const evt=foo(20)
evt.on('completion',()=>{
    // 进行下一步
})
evt.on('failure',err=>{
    // foo中出错了
})

我们可以把这个事件监听对象(evt)提供给代码中多个独立的部分,他们可以独立的得到通知,执行下一步

const evt=foo(20)
bar(evt) // bar()来监听foo的完成
baz(evt) // baz()也可以来监听foo的完成

foo()无需知道bar()和baz()是否存在,evt对象就是分离的关注点之间的中立的第三方协商机制,也是promise的一个模拟。

function foo(){
    // 。。。耗时的工作
    // 构造并返回promise
    return new Promise((resolve,reject)=>{
        // 这里的函数会立即执行
    })
}
const p=foo()
bar(p)
baz(p)

这里p并不是被传给了bar()和baz(),而是使用p来控制这两个函数何时执行。

then方法

注意,所有的委托或值中,都不能存在自定义的then方法,否则这个值在promise系统中会被误认为是一个thenable,会造成难以追踪的bug!!

promise值得信任吗

以下给出了,在开发时我们会遇到的问题,以及promise的对应处理方式

调用过早

当promise已经决议后,提供给then()的回调总会被异步调用,不需要自己插入setTimeout()

调用过晚

一旦promise决议后,这个promise上所有通过then()注册的回调都会在下一个异步时间点上依次被调用。这些回调中的任何一个都不会影响其他回调的调用,如下:

p.then(()=>{
    p.then(()=>{
        console.log('C') //c无法打断b
    })
    console.log('A')
})
p.then(()=>{console.log('B')})
// A B C

回调未调用

没有任何东西可以阻止promise通知他的决议;如果你对一个promise注册了完成回调和拒绝回调,那么在promise决议时总是会调用其中一个

那么,如果promise本身永远不会决议呢?我们可以创建一个用于超时的promise工具,设置超时竞态回调,这将在后面讨论promise api时给出答案。

调用次数过少或过多

正常的调用次数为1,过少为0,即为未被调用;调用过多,如代码中出现多个resolve()reject(),那么这个promise将会只接受第一次决议,并忽略后续任何调用!!

未能传递参数/环境值

如果你没有用任何值在promise中显式决议(即没有调用resolve或reject),这个值为undefined;它会被传给所有的注册回调。

或者你要传递多个值,那么就把它们放在数组中进行处理。

吞掉错误或异常

如果在promise创建中,出现了一个javascript一场错误,这个异常会被捕捉,并且使这个promise被拒绝。如下:

const p=new Promise((resove,reject)=>{
    foo.bar() // foo没有被定义,这里抛出错误
    resolve(20)
})
p.then((num)=>{
    // 不会被输出
    console.log(num)
},(err)=>{
    // err会是一个typeerror异常对象
})

传入的值是否为一个可信的promise

在上一节提到,不要传入含有then()的值!那么,在我们无法确定时该怎样处理呢?

先举个反例:

const p={
    then(cb,errcb){
        cb(20)
        errcb('this is err')
    }
}

p.then(
(val)=>{console.log(val)}, // 20
// 这里不应该被运行啊!
(err)=>{console.log(err)} // this is err
)

就像一个普通的函数一样运行了,并不是promise的运行机制。但我们可以用Promise.resove()封装下,就会得到期望的结果。

Promise.resolve(p)
    .then(
        (val)=>{console.log(val)},
        // 永远不会到达这里!
        (err)=>{console.log(err)} 
        )

链式流

我们可以把多个promise连接到一起表示一系列异步步骤,这是基于promise的两个固有行为特性:

每次对promise调用then,都会创建并返回一个新的promise,我们可以将其链接起来
不管从then()调用的完成回调(第一个参数)返回的值是什么,都会被自动设置为被链接的promise的完成。

如下,我们很容易把promise连接到一起:

const p=Promise.resolve(10)
    p.then((val)=>{return val*2})
    p.then((val)=>{console.log(val)}) // 20

并且,不论我们想要多少个异步步骤,每一步都能根据需要等待下一步;当然,如果不显式返回一个值,就会隐式返回undefined。

const p=Promise.resolve(10)
    p.then((val)=>{
        return new Promise((resolve,reject)=>{
            setTimeout(()=>{
                resolve(val*2)
            },500)
        })
    }).then((val)=>{console.log(val)}) // 20

在链式调用中,如果在某个步骤上出现了错误,则会在最近的reject上处理,如果没有,则会抛出错误。

const p=Promise.resolve(10)
    p.then((val)=>{ //第一个then
        return new Promise((resolve,reject)=>{
            resolve(val*2)
            foo.bar() // 这里不会执行,因此并不抛出错误,最后打印60
        })
    })
    .then((val)=>{ // 第二个then
        return val*3
    },
    ()=>{
        console.log('oops!!')
    })
    .then(val=>{ // 第三个then
        console.log(val)
    },
    ()=>{
        console.log('oops!!2')
    })

如果将resolve(val*2)foo.bar()换个位置,foo.bar没有声明,此时promise决议为拒绝状态,因为没有设置reject值,所以向下传递undefined值;在向下遇到的首个reject(第二个then中的reject)被捕捉,输出oops!;由于这个then节点没有出现错误,也没有返回值,则向下传递,直到被第三个then中的resolve捕捉,输出undefined

如果将foo.bar()放到第二个then中,则会打印'oops!!2';将foo.bar()放到第三个then中,其后面的链没有reject,则会抛出错误。

错误处理

我们比较熟悉的try..catch只能是同步的,无法用于异步代码模式。

但从上一节我们也知道promise是可以捕捉到异步错误,但必须是在出错的地方的下一链中有reject才会进行处理,否则只能抛出错误。对此,许多开发者常使用如下的实践,来解决上述问题:

const handleErrors=(err)=>{console.log(err)}
const p=Promise.resolve(10)
p.then((val)=>{
    foo.bar()
    return val*2
})
.catch(handleErrors)

但这个处理方法并不全面,如果handleErrors中有错,promise中的错误也不能被成功捕获。

还有一种解决方法是,设置一个定时器,在拒绝的时候启动,如果promise被拒绝,而在定时器出发之前都没有出错处理函数被注册,呢么他就不会注册处理韩式,进而就是未被捕获错误。再多种库中这个方法运行良好,但设置定时时间太随意了,如果处理某些请求真的pending了很长时间,这个方法显得并不那么可靠。

promise模式

1.Promise.all([..])

多个任务完成后再继续执行更多操作。在promise链中,任意时刻都只能有一个异步任务正在执行,想要同时执行两个或更多步骤(并行执行),必须使用来创建。

门要等待两个或更多并行/并发的任务都完成才能继续。完成顺序并不重要,但必须都得完成,门才能打开并让流程控制继续。

Promise.all([p1,p2])
.then(msgs=>{
    // 这里的msg也是一个数组,分别为p1、p2的完成消息
})

需要注意的是,p1、p2中,如果有任何一个被拒绝的,主Promise.all()就会立即被拒绝,并丢弃来自其他所有promise的全部后果。

2.Promise.race([])

这个api指的是竞态,传统模式是称为门闩,即只响应第一个执行完成的promise。

与Promise.all()相似,一旦有任何一个promise决议为完成,promise.race()就会完成;一旦有任何一个promise决议为拒绝,他就会拒绝。如果传入了空数组,则永远不会被决议。

我们可以用它来检测一个请求是否超时:

Promise.race([
request(),
timeoutPromise()
])
.then(()=>{
    // request按时完成
},
err=>{
    // request()被拒绝或超时!!
}
)

并发迭代

有时候需要再一列promise中迭代,并对所有promise都执行某个任务。其实就像是同步数组迭代,但改成了异步。使用map迭代promise(或其他任何值),再每个值上运行一个函数作为参数。map本身返回一个promise,其完成值是一个数组,该数组保持映射顺序,保存任务执行之后的异步完成值:

Promise.map((vals,cb)=>{
    return Promise.all(
        vals.map(val=>
            new Promise(resolve=>cb(val,resolve))
        )
    )
})

在以上的map实现中,不能发送异步拒绝拒绝信号,但如果在映射的回调cb中,出现同步的异常或错误,主Promise.map()返回的promise就会拒绝。

Promise的局限性

promise看似神奇,解决了很多异步回调的问题,使代码清晰可靠。但万事没有百分百完美的,promise也有自身的局限性。

1.顺序错误处理

由于promise的链接方式,promise中的错误很容易被无意中默默忽略掉。如果构建了一个没有错误处理的promise链,链中任何地方都会在链中一直传递下去,直到被查看(通过在某个步骤注册拒绝处理函数)。

2.单一值

promise只能有一个完成值或一个拒绝理由。在简单的应用中,这不是什么问题;但是在复杂的场景中,你就会发现这是一种局限。
如果需要处理多个值,直接将多个值封装成promise,并把它们放到数组中,使用Promise.all()来进行处理。

3.单决议

promise只能被决议一次,如果将某个事件绑定,放到promise中,如下例。如果按钮响应只点击一次,这种方式才能运作。点击第二次,promise已经决议,所以第二次调用resolve()就会被忽略。如下例,只会再第一次点击时打印50,之后的点击resolve被忽略。

function click(ele,event){
    document.getElementById(ele).onclick=event
}

const request=()=>new Promise((resolve,rej)=>{
    resolve(50) 
})

const p=new Promise((resolve,reject)=>{
    click('test',resolve)
})

p.then(evt=>request())
.then(text=>{console.log(text)})

4.无法取消的promise

如果建立了一个promise并为其注册了完成或拒绝处理函数,如果出现某种情况使这个任务悬而未决的话,也没有办法从外部停止他的进程。

考虑前面的超时场景:使用Promise.race()设置一个定时器,到时则抛出错误。

5.promise的性能问题

promise做的工作比自身建立的回调方案,要慢一些。但promise值得信任,损失微小的的性能但能让整个系统可信任性和组合性更高;代码条理也更加清晰。

你可能感兴趣的:(Promise完全详解)