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

系列文章:

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

前言

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

通过本篇文章,你将了解到:

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

1. 为什么需要回调?

1.1 同步回调

先看个简单的Demo:

ts 复制代码
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()函数里我们对结果进行了平方,现在想要对它进行除法操作,那么依葫芦画瓢,需要再定义一个函数:

ts 复制代码
function reprocess2(a: number) {
    return a / 2
}

再后来,还需要继续增加其它功能如减法、乘法、取模等运算,那不是要新增不少函数吗?

假设该模块的主要功能是进行加法,至于对加法结果的再加工它并不关心,外界调用者想怎么玩就怎么玩。于是,回调出现了。

我们重新设计一下代码:

js 复制代码
//新增函数作为入参
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模拟一下耗时操作:

js 复制代码
//新增函数作为入参
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 为什么需要它?

回调函数的出现使得代码设计更灵活。

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

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

forEach()函数接收的是一个同步回调函数,该函数里可以获取到数组里每一个值,并可以对它进行自定义的逻辑操作。

除了forEach()函数,同步回调还大量地被运用于其它场景。

2. 什么是回调地狱?

先看一段代码:

js 复制代码
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。

很显然这三个接口是逐层(串行)依赖的,我们可以写出如下代码:

js 复制代码
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出现了。

还是以获取学生信息为例:

js 复制代码
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,接着看看如何使用这个返回值。

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

你可能会说,这看起来和使用回调的方式差不多呢,then()函数的闭包就相当于回调嘛。

确实,单看这个例子和回调差不多,接着尝试用Promise改造之前的回调地狱。

js 复制代码
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 是个接口,它有两个函数:

  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. 不论小红作出了什么样的答复,这个承诺就算结束了

用代码表示如下:

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

无论小红resolve()还是reject(),最终小明得要知道结果。

当小明发起帮助请求时,他有两种方式可以拿到小红的回复:

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

我们先看第二种方式:

js 复制代码
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()函数的两个参数都是可选的。

只关注成功状态:

js 复制代码
helpXiaoMing().then(value=>{
    console.log('success:',value)
})

只关注失败状态:

js 复制代码
helpXiaoMing().then(null, reason => {
    console.log('fail:', reason)
})

两者皆关注:

js 复制代码
helpXiaoMing().then(value => {
    console.log('success:', value)
}, reason => {
    console.log('fail:', reason)
})

2. catch()可选参数

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

js 复制代码
helpXiaoMing().then(value => {
    console.log('success:', value)
}).catch(reason => {})

失败状态有两个来源:

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

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

3. finally()始终会执行

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

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

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

交换个位置:

js 复制代码
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的状态由谁更改呢?

js 复制代码
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()的入参。

js 复制代码
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构造函数闭包里传递的类型:

js 复制代码
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类型的对象。

js 复制代码
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: '爱莫能助'})//返回对象
        }
    })
}

当调用:

js 复制代码
helpXiaoMing().then(value => {
    console.log('success:', value)
    return 'success occur'
})

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

resolve success: 我是内部的Promise

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

js 复制代码
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 易混淆的地方

先看第一个易混点:

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

如果第一个then闭包执行成功,那么第二个then闭包的结果是啥?

答案是输出:undefined

因为想要将数据往下传递,then()/catch()函数闭包里必须显式返回数据:

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

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

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

与上面效果一致。

第二个易混点:

js 复制代码
helpXiaoMing().then(value => {
    throw Error
}).catch()

catch()能够捕获到异常吗?

答案是:不能

catch()需要传入参数:

js 复制代码
helpXiaoMing().then(value => {
    throw Error
}).catch(()=>{})

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

第三个易混点:

finally()闭包在then()或catch()闭包之后执行?

答案是:不一定

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

第四个易混点:

Promise需要调用then()才会触发状态变化吗?

答案是:不一定

js 复制代码
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里,小明可以选择一直等小红的回复,也可以先去做别的事等小红的通知,第二种场景上边已经分析过了,这次我们来看看第一种场景。

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

使用await操作符会使得当前调用者一直等待Promise状态变为完成(可能成功、可能失败),如上第二条语句一直等到Promise结束。

如果Promise成功,则拿到具体结果,如果Promise失败则会返回异常,因此需要对await本身进行异常捕获:

js 复制代码
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

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

此时Promise值类型是string。

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

js 复制代码
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的时序

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

js 复制代码
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即可:

js 复制代码
async function testWait() {
    await testWait1()
    await testWait2()
}
testWait()

其打印结果如下:

js 复制代码
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 存在于前端代码的各个方面

至于地位嘛,类比阁老

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

相关推荐
Myli_ing6 分钟前
HTML的自动定义倒计时,这个配色存一下
前端·javascript·html
ifanatic24 分钟前
[面试]-golang基础面试题总结
面试·职场和发展·golang
I_Am_Me_38 分钟前
【JavaEE进阶】 JavaScript
开发语言·javascript·ecmascript
℘团子এ1 小时前
vue3中如何上传文件到腾讯云的桶(cosbrowser)
前端·javascript·腾讯云
学习前端的小z1 小时前
【前端】深入理解 JavaScript 逻辑运算符的优先级与短路求值机制
开发语言·前端·javascript
程序猿进阶1 小时前
堆外内存泄露排查经历
java·jvm·后端·面试·性能优化·oom·内存泄露
前端百草阁1 小时前
【TS简单上手,快速入门教程】————适合零基础
javascript·typescript
彭世瑜1 小时前
ts: TypeScript跳过检查/忽略类型检查
前端·javascript·typescript
Backstroke fish1 小时前
Token刷新机制
前端·javascript·vue.js·typescript·vue
zwjapple1 小时前
typescript里面正则的使用
开发语言·javascript·正则表达式