系列文章:
前言
上篇文章阐述了并发/并行、单线程/多线程、同步/异步等概念,这篇将会分析Promise的江湖地位。
通过本篇文章,你将了解到:
- 为什么需要回调?
- 什么是回调地狱?
- Promise解决了什么问题?
- Promise常用的API
- async和await 如影随形
- 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 是个接口,它有两个函数:
- then(resolve,reject)函数,入参有两个(都是可选的),返回Promise类型
- catch(reject)函数,入参有一个(可选),返回Promise类型
- 构造Promise需要传递一个参数,其是函数类型,该函数类型包括两个入参:resolve和reject,当解决了Promise时需要调用resolve()函数,当拒绝了Promise时调用reject()函数
Promise中文意思是承诺,将Promise暴露出去意思就是将承诺放出来。
- 就像小明请小红帮个忙
- 小红不会立即帮忙,而是给小明一个承诺:我会回复你到底是帮还是不帮
- 小红决定帮忙:调用resolve()函数,表示这个忙我帮定了
- 小红决定不帮忙,调用reject()函数拒绝,表示爱莫能助
- 不论小红作出了什么样的答复,这个承诺就算结束了
用代码表示如下:
js
function helpXiaoMing(): Promise<string> {
return new Promise((resolve, reject) => {
//掷骰子
if (Math.random() > 0.5) {
resolve('这个忙我帮定了')
} else {
reject('爱莫能助')
}
})
}
无论小红resolve()还是reject(),最终小明得要知道结果。
当小明发起帮助请求时,他有两种方式可以拿到小红的回复:
- 一直等到小红回复,对应await()函数
- 先去做别的事,等小红通知,对应Promise.then()函数
我们先看第二种方式:
js
helpXiaoMing().then(value => {
//成功的结果,value就是resolve的参数
console.log(value)
}, reason => {
//失败的结果,reason就是reject的参数
console.log(reason)
})
从上我们也发现了Promise一个特点:无论外部是否有监听Promise结果,Promise都会按照既定逻辑更改它的状态。也就是说无论小明是否关注小红的承诺,她都需要给个准信。
回到最初的问题,Promise解决了什么问题:
- Promise本质上也是基于回调,只是把回调封装了
- Promise解决嵌套回调地狱的问题
- Promise使得异步代码更简洁
- 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 => {})
失败状态有两个来源:
- 显示调用了Promise.reject()函数
- 代码抛出了异常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)
}
}
- await 作用是挂起当前线程,而不是让线程停止执行(sleep等),挂起的意思是线程执行到await 这地方就暂时不往下执行了,但它不会休息,而是先去执行其它任务
- 等到await 的Promise返回,线程继续执行await之后的代码
- 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()结束后才能执行?
而实际的效果却是:
- 线程执行到testWait1()里的await后挂起,并退出testWait1(),进而继续执行testWait2()
- 在执行testWait2()的await后也会挂起
- 此时testWait1()和testWait2()都执行到await了,等待各自的Promise返回结果
- 由于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,是时候总结一下了。
- Promise 是前端实现异步任务的基石
- Promise 存在于前端代码的各个方面
至于地位嘛,类比阁老
本篇介绍了Promise的基本用法以及坑点,下篇将重点分析异步任务的时序(宏任务、微任务),相信你看完再也不用担心时序问题了,敬请期待~