论Promise在前端江湖的地位及作用

系列文章:

  1. 先撸清楚:并发/并行、单线程/多线程、同步/异步
  2. 论Promise在前端江湖的地位及作用

前言

上篇文章阐述了并发/并行、单线程/多线程、同步/异步等概念,这篇将会分析Promise的江湖地位。
通过本篇文章,你将了解到:

  1. 为什么需要回调?
  2. 什么是回调地狱?
  3. Promise解决了什么问题?
  4. Promise常用的API
  5. async和await 如影随形
  6. Promise的江湖地位

1. 为什么需要回调?

1.1 同步回调

先看个简单的Demo:

function add(a: number, b: number) {
    return a + b
}

function reprocess(a: number) {
    return a * a
}

function calculate() {
    //加法运算
    let sum = add(4, 5)
    //进行再处理
    let result = reprocess(sum)
    //输出最终结果
    console.log("result:", result)
}

先进行加法运算,再对运算的结果进行处理,最终输出结果。
在reprocess()函数里我们对结果进行了平方,现在想要对它进行除法操作,那么依葫芦画瓢,需要再定义一个函数:

function reprocess2(a: number) {
    return a / 2
}

再后来,还需要继续增加其它功能如减法、乘法、取模等运算,那不是要新增不少函数吗?
假设该模块的主要功能是进行加法,至于对加法结果的再加工它并不关心,外界调用者想怎么玩就怎么玩。于是,回调出现了。
我们重新设计一下代码:

//新增函数作为入参
function add(a: number, b: number, callbackFun: (sum: number) => number) {
    let sum = a + b
    return callbackFun(sum)
}

function calculate() {
    //加法运算
    let result = add(4, 5, (sum) => {
        return sum / sum
    })
    //输出最终结果
    console.log("result:", result)

    let result2 = add(6, 8, (sum) => {
        return sum * sum - sum / 2
    })
    //输出最终结果
    console.log("result2:", result2)
}

add()函数最后一个入参是函数类型的参数,调用者需要实现这个函数,我们称这个函数为回调函数。于是在calculate()函数里,我们可以针对不同的需求调用add()函数,并通过回调函数实现不同的数据加工逻辑。

calculate()函数和回调函数是在同一线程里执行,并且按照代码书写的先后顺序执行,此时的回调函数是同步回调

1.2 异步回调

假若add()函数里对数据的加工需要一定的时间,我们用setTimeout模拟一下耗时操作:

//新增函数作为入参
function add(a: number, b: number, callbackFun: (sum: number) => void) {
    setTimeout(() => {
        let sum = a + b
        callbackFun(sum)
    })
}

function calculate() {
    //加法运算
    add(4, 5, (sum) => {
        let result = sum / sum
        //输出最终结果
        console.log("result:", result)//第1个打印
    })
    console.log("calculate end...")//第2个打印
}

从打印结果看,第2个打印反而比第一个打印先出现,说明第二个打印语句先执行。
calculate()函数执行add()函数的时候,并没有一直等待回调的结果,而是立马执行了第二个打印语句,而当add()函数内部实现执行时,才会执行回调函数,虽然calculate()和回调函数在同一线程执行,但是它们并没有按照代码书写的先后顺序执行,此时的回调函数是异步回调

1.3 为什么需要它?

回调函数的出现使得代码设计更灵活。
你可能会说:异步回调我还可以理解,毕竟或多或少都会涉及到异步调用,但同步回调不是脱裤子放屁吗?
其实不然,同步回调更多的表现在灵活度上,比如我们遍历一个数组:

const score = [60, 70, 80, 90, 100]
score.forEach((value, index, array) => {
    console.log("value:", value, " index:", index)
})

forEach()函数接收的是一个同步回调函数,该函数里可以获取到数组里每一个值,并可以对它进行自定义的逻辑操作。
除了forEach()函数,同步回调还大量地被运用于其它场景。

2. 什么是回调地狱?

先看一段代码:

interface NetCallback {
    //错误返回
    error: (errMsg: string) => void
    //成功返回
    succeed: (data: object) => void
}

function fetchNetData(url: string, netCallback: NetCallback) {
    //模拟网络耗时
    setTimeout(() => {
        if (Math.random() > 0.2) {
            //成功
            netCallback.succeed({code: 200, msg: 'success'})
        } else {
            //失败
            netCallback.error(`${url} fetch error`)
        }
    }, 1000)
}

function fetchStuInfo() {
    fetchNetData('/info/stu', {
        error: (errMsg) => {
            console.log(errMsg)
        },
        succeed: (data) => {
            console.log(data)
        }
    })
}

fetchStuInfo()

上述代码是很常规的异步回调过程,看起来很正经没啥问题。
想象一种场景:通过stuId获取stuInfo,stuInfo里存有teacherId,通过teacherId获取teacherInfo,teacherInfo里有schoolId,通过schoolId获取schoolInfo。
很显然这三个接口是逐层(串行)依赖的,我们可以写出如下代码:

function fetchSchoolInfo() {
    //先获取学生信息,成功后带有teacherId
    fetchNetData('/info/stu', {
        error: (errMsg) => {
            console.log(errMsg)
        },
        succeed: (data) => {
            //通过teacherId,再获取教师信息,成功后带有schoolId
            fetchNetData('/info/teacher', {
                error: (errMsg) => {
                    console.log(errMsg)
                },
                succeed: (data) => {
                    //通过schoolId,再获取学校信息
                    fetchNetData('/info/school', {
                        error: (errMsg) => {
                            console.log(errMsg)
                        },
                        succeed: (data) => {
                            console.log(data)
                        }
                    })
                }
            })
        }
    })
}

可以看到fetchSchoolInfo()函数里嵌套地调用了fetchNetData()函数,层层递进,并且伴随着error和succeed分支判断,同时异常的错误很难抛出去。
此种场景下代码并不简洁,分支多容易出错且不易调试,当需要依赖的更多时,我们就陷入了回调地狱

3. Promise解决了什么问题?

3.1 Promise替代回调

怎么解决回调地狱的问题呢?这个时候Promise出现了。
还是以获取学生信息为例:

function fetchNetData(url: string): Promise<any> {
    //模拟网络耗时
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (Math.random() > 0.2) {
                //成功
                resolve({code: 200, msg: 'success'})
            } else {
                //失败
                reject(`${url} fetch error`)
            }
        }, 1000)
    })
}

与之前的对比,fetchNetData()函数只需要传入一个参数,无需回调函数,它返回一个Promise。
当网络请求成功,则调用resolve()函数,当网络请求失败则调用reject()函数。
既然返回了Promise,接着看看如何使用这个返回值。

function fetchStuInfo() {
    fetchNetData('/info/stu').then(data => {
        //成功
        console.log(data)
    }, error => {
        //失败
        console.log(error)
    })
}

你可能会说,这看起来和使用回调的方式差不多呢,then()函数的闭包就相当于回调嘛。
确实,单看这个例子和回调差不多,接着尝试用Promise改造之前的回调地狱。

function fetchSchoolInfo() {
    //先获取学生信息,成功后带有teacherId
    fetchNetData('/info/stu')
        .then(data => fetchNetData('/info/teacher'))
        .then(data => fetchNetData('/info/school'))
        .then(data => console.log(data))
        .catch(err => console.log(err))
}

这么看,使用Promise是不是简洁了许多,回调方式代码一直往右增长,而使用Promise每个接口请求都是平铺,并且它们的逻辑关系是递进的。
三个接口都成功,则打印成功的结果。
其中一个接口失败,剩下的接口都不会再请求,并且错误结果被catch()函数捕获。

3.2 Promise基本使用

Promise 是个接口,它有两个函数:


image.png
  1. then(resolve,reject)函数,入参有两个(都是可选的),返回Promise类型
  2. catch(reject)函数,入参有一个(可选),返回Promise类型
  3. 构造Promise需要传递一个参数,其是函数类型,该函数类型包括两个入参:resolve和reject,当解决了Promise时需要调用resolve()函数,当拒绝了Promise时调用reject()函数

Promise中文意思是承诺,将Promise暴露出去意思就是将承诺放出来。

  1. 就像小明请小红帮个忙
  2. 小红不会立即帮忙,而是给小明一个承诺:我会回复你到底是帮还是不帮
  3. 小红决定帮忙:调用resolve()函数,表示这个忙我帮定了
  4. 小红决定不帮忙,调用reject()函数拒绝,表示爱莫能助
  5. 不论小红作出了什么样的答复,这个承诺就算结束了

用代码表示如下:

function helpXiaoMing(): Promise<string> {
    return new Promise((resolve, reject) => {
        //掷骰子
        if (Math.random() > 0.5) {
            resolve('这个忙我帮定了')
        } else {
            reject('爱莫能助')
        }
    })
}

无论小红resolve()还是reject(),最终小明得要知道结果。
当小明发起帮助请求时,他有两种方式可以拿到小红的回复:

  1. 一直等到小红回复,对应await()函数
  2. 先去做别的事,等小红通知,对应Promise.then()函数

我们先看第二种方式:

helpXiaoMing().then(value => {
    //成功的结果,value就是resolve的参数
    console.log(value)
}, reason => {
    //失败的结果,reason就是reject的参数
    console.log(reason)
})

从上我们也发现了Promise一个特点:无论外部是否有监听Promise结果,Promise都会按照既定逻辑更改它的状态。也就是说无论小明是否关注小红的承诺,她都需要给个准信。

回到最初的问题,Promise解决了什么问题:

  1. Promise本质上也是基于回调,只是把回调封装了
  2. Promise解决嵌套回调地狱的问题
  3. Promise使得异步代码更简洁
  4. Promise支持链式调用,很好地关联了多个异步逻辑

4. Promise常用的API

4.1 Promise 常用的API

上面列举了使用Promise基础三板斧:

  • new Promise((resolve,reject)),构造Promise对象
  • 修改状态resolve()/reject()
  • 监听(接收)Promise状态

1. then()可选参数
then()函数的两个参数都是可选的
只关注成功状态:

helpXiaoMing().then(value=>{
    console.log('success:',value)
})

只关注失败状态:

helpXiaoMing().then(null, reason => {
    console.log('fail:', reason)
})

两者皆关注:

helpXiaoMing().then(value => {
    console.log('success:', value)
}, reason => {
    console.log('fail:', reason)
})

2. catch()可选参数
不想在then里监听失败的状态,也可以单独使用catch()

helpXiaoMing().then(value => {
    console.log('success:', value)
}).catch(reason => {})

失败状态有两个来源:

  1. 显示调用了Promise.reject()函数
  2. 代码抛出了异常throw Error()

失败的状态会先找到最近能够处理该状态的地方。

3. finally()始终会执行
当Promise状态更改后,finally始终会执行,执行的顺序和书写顺序一致。

helpXiaoMing().then(value => {
    console.log('success:', value)
}).catch(reason => {
    console.log('error:', reason)
}).finally(() => {
    console.log('finally called')
})

Promise状态只要变成了成功或失败,那么finally打印将会执行,此时因为finally写在最后,因此最后执行。
交换个位置:

helpXiaoMing().finally(() => {
    console.log('finally called')
}).then(value => {
    console.log('success:', value)
}).catch(reason => {
    console.log('error:', reason)
})

finally打印先执行。

4. then()/catch()/finally() 函数返回值
这三个函数都是返回了Promise,那他们的Promise的状态由谁更改呢?

helpXiaoMing().then(value => {
    console.log('success:', value)
    return 'success occur'
}).then(value => {
    console.log('second then value:', value)
}).catch(() => {
})

第一个then()函数返回了一个Promise,而这个Promise的值就是第一个then()函数闭包里返回的 'success occur'。
当第二个then()执行时,会等待第一个then()函数返回的Promise状态更改,此时return 'success occur'之后就会执行Promise.resolve( 'success occur'),因此第二个then()函数打印:second then value: success occur

同样的,当在catch()函数的闭包里返回值时,该值也作为下一个then()的入参。

helpXiaoMing().then(value => {
    console.log('success:', value)
    return 'success occur'
}).catch(() => {
    return '抓到错误,将信息传递给下一个then'
}).then(value => {
    console.log('second then value:', value)
})

至于finally(),它的闭包里没有参数,返回值也不会传递下去。

then()/catch()函数特性使得Promise可以进行链式调用。

5. then()/catch()/finally() 函数闭包返回值
理论上这几个函数的的闭包能够返回任意值,先看Promise构造函数闭包里传递的类型:

function helpXiaoMing(): Promise<any> {
    return new Promise((resolve, reject) => {
        //掷骰子
        if (Math.random() > 0.5) {
            console.log('resolve')
            //resolve('这个忙我帮定了') 返回普通字符串(基本类型)
            resolve({msg: '这个忙我帮定了'})//返回对象
        } else {
            console.log('reject')
            //reject('爱莫能助') 返回普通字符串(基本类型)
            reject({reason: '爱莫能助'})//返回对象
        }
    })
}

由上可知,传递了引用对象类型,那么helpXiaoMing().then()闭包接收的参数也是对象。而对象里比较特殊的是返回Promise类型的对象。

function helpXiaoMing(): Promise<any> {
    //外层Promise对象
    return new Promise((resolve, reject) => {
        //掷骰子
        if (Math.random() > 0.5) {
            console.log('resolve')
            //内层Promise对象
            resolve(new Promise((resolve2, reject2) => {
                setTimeout(() => {
                    resolve2('我是内部的Promise')
                }, 2000)
            }))
        } else {
            console.log('reject')
            //reject('爱莫能助') 返回普通字符串
            reject({reason: '爱莫能助'})//返回对象
        }
    })
}

当调用:

helpXiaoMing().then(value => {
    console.log('success:', value)
    return 'success occur'
})

then监听的是内层Promise对象的变化,因此最终打印的结果是:

resolve
success: 我是内部的Promise

同样的,then()/catch()/finally()闭包里也可以返回Promise对象

helpXiaoMing().then(value => {
    console.log('success:', value)
    return new Promise((resolve2, reject2) => {
        setTimeout(() => {
            resolve2('我是内部的Promise')
        }, 2000)
    })
}).then(value => {
    console.log('second then value:', value)
})

基于这种特性,Promise可作链式调用,就像最开始那会儿用Promise替代回调的写法就涉及到了Promise链式调用。

4.2 Promise 易混淆的地方

先看第一个易混点:

helpXiaoMing().then(value => {
    console.log('success:', value)
}).then(value => {
    //猜猜这里的打印结果是什么
    console.log(value)
})

如果第一个then闭包执行成功,那么第二个then闭包的结果是啥?
答案是输出:undefined
因为想要将数据往下传递,then()/catch()函数闭包里必须显式返回数据:

helpXiaoMing().then(value => {
    console.log('success:', value)
    return value
}).then(value => {
    //猜猜这里的打印结果是什么
    console.log(value)
})

当然如果是简单的表达式,那就可以忽略return:

helpXiaoMing().then(value => value).then(value => {
    //猜猜这里的打印结果是什么
    console.log(value)
})

与上面效果一致。

第二个易混点:

helpXiaoMing().then(value => {
    throw Error
}).catch()

catch()能够捕获到异常吗?
答案是:不能
catch()需要传入参数:

helpXiaoMing().then(value => {
    throw Error
}).catch(()=>{})

一个空的实现,就能捕获异常。

第三个易混点:
finally()闭包在then()或catch()闭包之后执行?
答案是:不一定
这和传统的try{...}catch{...}finally{...}不太一样,传统的先执行try里面的或者是catch里的,最终才执行finally,而此处Promise里的finally是表示该Promise状态变为了"settled",至于在then()闭包还是catch()闭包前执行,决定点在于书写的顺序,具体的Demo在上一节。

第四个易混点:
Promise需要调用then()才会触发状态变化吗?
答案是:不一定

function test() {
    return new Promise((resolve, reject) => {
        console.log('hello')
        resolve('hello')
    })
}
//没有.then,Promise状态也会变化
test()

4.3 Promise其它API

还有一些比较高级的API,如Promise.all()/Promise.allSettled()/Promise.race()/Promise.any()/Promise.reject()/Promise.resolve()等,此处就不再细说。

5. async和await 如影随形

5.1 await 返回值

Promise确实比较好用,你可能已经发现了监听Promise的状态变化是个异步的过程,then()函数里的闭包其实就是传一个回调函数进去。
有些时候我们需要等待异步任务的结果回来后再进行下一步操作,这个时候该怎么做呢?
之前提到过的Demo里,小明可以选择一直等小红的回复,也可以先去做别的事等小红的通知,第二种场景上边已经分析过了,这次我们来看看第一种场景。

async function testWait() {
    console.log('before get result')
    const result = await helpXiaoMing()
    console.log('after result:', result)
}
testWait()

使用await操作符会使得当前调用者一直等待Promise状态变为完成(可能成功、可能失败),如上第二条语句一直等到Promise结束。
如果Promise成功,则拿到具体结果,如果Promise失败则会返回异常,因此需要对await本身进行异常捕获:

async function testWait() {
    console.log('before get result')
    try {
        const result = await helpXiaoMing()
        console.log('after result:', result)
    } catch (e) {
        console.log(e)
    }
}
  1. await 作用是挂起当前线程,而不是让线程停止执行(sleep等),挂起的意思是线程执行到await 这地方就暂时不往下执行了,但它不会休息,而是先去执行其它任务
  2. 等到await 的Promise返回,线程继续执行await之后的代码
  3. await 只能在async 修饰的函数里调用

5.2 async 修饰的函数返回值

async 修饰的函数最终会返回Promise


image.png

如上图,经过async修饰的函数,它的返回值被包装为Promise对象,而该Promise对象的值来源于async 函数的return 语句,此处我们没有return,因此值类型是void。


image.png

此时Promise值类型是string。

await helpXiaoMing()发生了异常,await之后的代码不会再执行。同时async返回的Promise会调用reject()函数将异常传递出去。

async function testWait() {
    console.log('before get result')
    const result = await helpXiaoMing()
    console.log('after result:', result)
    return '完成了'
}

testWait().then(value => {
    //成功,走这
    console.log('value=>', value)
}, error => {
    //失败走这
    console.log('error=>', error)
})

5.3 理解async和await的时序

看以下例子,猜猜打印结果是什么?

function waitPromise2() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve('waitPromise2返回')
        }, 1000)
    })
}

async function testWait1() {
    console.log('before1 get result')
    const result = await waitPromise1()
    console.log('after1 result:', result)
    return '完成了testWait1'
}

async function testWait2() {
    console.log('before2 get result')
    const result = await waitPromise2()
    console.log('after2 result:', result)
    return '完成了testWait2'
}

testWait1()
testWait2()

答案是:

before1 get result
before2 get result
after2 result: waitPromise2返回
after1 result: waitPromise1返回

刚接触async/await 的小伙伴可能会认为:

testWait1()里不是有await 阻塞了吗?此时线程一直阻塞在await处,testWait2()没机会执行,必须等到testWait1()结束后才能执行?

而实际的效果却是:

  1. 线程执行到testWait1()里的await后挂起,并退出testWait1(),进而继续执行testWait2()
  2. 在执行testWait2()的await后也会挂起
  3. 此时testWait1()和testWait2()都执行到await了,等待各自的Promise返回结果
  4. 由于testWait2()里的await时间较短,它先完成了所以先打印了"after2 result: waitPromise2返回",紧接着testWait1()的await 也返回了

当然,如果想要testWait1()和testWait2()按顺序执行怎么办呢?
我们知道testWait1()和testWait2()都会返回Promise,我们只需要await Promise即可:

async function testWait() {
    await testWait1()
    await testWait2()
}
testWait()

其打印结果如下:

before1 get result
after1 result: waitPromise1返回
before2 get result
after2 result: waitPromise2返回

5.4 async和await 作用

Promise代表的是异步编程,而通过async和await的亲密配合,我们可以使用同步的方式编写异步的代码。
其它语言也有类似的操作,比如Koltin的协程里的withcontext()函数。

6. Promise的江湖地位

好了说了一大篇Promise,是时候总结一下了。

  1. Promise 是前端实现异步任务的基石
  2. Promise 存在于前端代码的各个方面

至于地位嘛,类比阁老


image.png

本篇介绍了Promise的基本用法以及坑点,下篇将重点分析异步任务的时序(宏任务、微任务),相信你看完再也不用担心时序问题了,敬请期待~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容