一.前言
在早期 JavaScript 的 ES5 语法中,多层函数的回调嵌套是一件让人很头疼的事儿,行内黑话一般称之为回调地狱 。
可能有些伙计还没遇到过此类业务场景,但是没关系,只要在前端圈里混,苍天会绕过谁呢?所以为了大家,我就举个特别常见的业务场景:
- 有三个接口,分别为
URL-A,URL-B,URL-C(都是get请求),我们需要分别向这三个接口请求获取数据。 - 请求
URL-B时需要带上URL-A返回的数据,同理,请求URL-C时也要带上URL-B返回的数据。
我们来看看用早期的 jquery ajax 会怎么处理:
$.get('/URL-A', function(resA){
// do Something
$.get('/URL-B?query=' + resA,function(resB){
// do Something
$.get('/URL-C?query=' + resB, function(resC){
// do Something
})
})
})
从上面我们可以看出,这一段代码是很不健康的,为什么这么说?有以下几点理由:
- 代码横向发展,而不是纵向变多,就像人不长高反而长胖一般,十分不健康。
- 业务逻辑不够直观,维护困难。
- 业务代码与公用代码难以抽离,函数之间强耦合,一旦报错很难快速定位问题所在。
当然,我们也可以用函数内 callback 的形式来改写上面的这段代码,使之变得更直观些:
// 请求 URL-C
function getURLCData(res){
$.get('/URL-C?query=' + res, function(res){
// do Something
})
}
// 请求 URL-B
function getURLBData(res){
$.get('/URL-B?query=' + res, function(res){
// do Something
getURLCData(res)
})
}
// 请求 URL-A
function getURLAData(){
$.get('/URL-A', function(res){
// do Something
getURLBData(res)
})
}
这样我们就避免了函数的纵向发展,公共代码与业务代码也可以抽离,但是这种方式还不够直观,在复杂业务,超高并发请求下,业务代码依旧晦涩。
所以,在 ES6 中提出了 Promise 用来解决回调嵌套的问题。
以上代码的 Promise 改写我们在下文再讲,我们先讲讲何为 Promise。
二. Promise 的基本用法
对于 Promise 我们可以这么理解,如果一个函数 Promise (数据准备好了)了,那么我们就可以 then 干点事情。
MDN 对其有以下描述:
Promise 对象是一个代理对象(代理一个值),被代理的值在Promise对象创建时可能是未知的。它允许你为异步操作的成功和失败分别绑定相应的处理方法(handlers)。 这让异步方法可以像同步方法那样返回值,但并不是立即返回最终执行结果,而是一个能代表未来出现的结果的promise对象
Promise 有以下四个函数可以调用:
Promise.all(iterable)
这个方法返回一个新的 promise 对象,该 promise 对象在 iterable 参数对象里所有的 promise 对象都成功的时候才会触发成功,一旦有任何一个 iterable 里面的 promise 对象失败则立即触发该 promise 对象的失败。
注意,iterabe 参数为数组,数组里存放 promise 对象。Promise.race(iterable)
当iterable参数里的任意一个子promise被成功或失败后,父promise马上也会用子promise的成功返回值或失败详情作为参数调用父promise绑定的相应句柄,并返回该promise对象。Promise.resolve(value)
返回一个状态由给定value决定的Promise对象。Promise.reject(reason)
返回一个状态为失败的Promise对象,并将给定的失败信息传递给对应的处理方法
我们来看个例子:
实现一个简单的定时 promise:
function delayLogNum(){
return new Promise((resolve, reject) => {
setTimeout(()=> {
console.log('success');
resolve('ok');
}, 3000)
})
}
delayLogNum().then(res => {
console.log(res)
})
结果如下所示:

三.Promise 处理串行和并行
在 JavaScript 中已经有同步,异步,串行,并行这些概念了,大家需分清楚其中的区别:
- 同步与异步是指是在 JavaScript 的主线程中执行(同步)还是丢到任务队列中执行(异步)。
- 串行和并行是指在异步任务队列中的函数是按顺序一个一个执行(串行)还是所有队列中的函数一起执行,但是必须在所有函数执行完毕后再接着执行下一步。
在 ES7 中新增加了 async 和 await 关键字:
async用于定义一个返回 AsyncFunction 对象的异步函数。异步函数是指通过事件循环异步执行的函数,它会通过一个隐式的 Promise 返回其结果。await操作符用于等待一个Promise 对象。它只能在异步函数 async function 中使用。
ok,梳理完了前置知识点,我们来看看利用 Promise 和 async await 怎么处理串行。
举一个例子:遍历一个 Number 数组并且在每次遍历时延时 1 秒输出遍历的数值。
function delay(){
return new Promise((resolve, reject) => {
setTimeout(resolve, 1000)
})
}
async function eachEveryVal(item){
await delay();
console.log(item)
}
async function eachArr(data){
for(var val of data){
await eachEveryVal(val)
}
}
eachArr([1, 2, 3, 4])
我们再举一个例子,就拿最开始那段代码来说,就是典型的异步串行操作,我们可以这么来改写它:
function getURL(url){
return new Promise((resolve, reject) => {
$.get(url, res => {
resolve(res)
})
})
}
async getData(){
let dataA = await getURL('URL-A');
let dataB = await getURL('URL-B?query=' + dataA);
let dataC = await getURL('URL-C?query=' + dataB);
};
getData();
讲完了串行,我们再来讲讲异步并行。假设我们有以下需求:
- 有三个接口,分别为
URL-A,URL-B,URL-C(都是get请求),我们需要分别向这三个接口请求获取数据。 - 在三个请求都结束后,拿到他们的数据进行业务处理。
这就是一个典型的并行的业务需求,我们也可以用 promise 来实现它。
const URI_LIST = ["URL-A", "URL-B", "URL-C"];
function getURL(url){
return new Promise((resolve, reject) => {
$.get(url, res => {
resolve(res)
})
})
}
async function getData(){
const promises = URI_LIST.map(url => getURL(url));
Promise.all(promises).then(res => console.log(res))
};
getData();
我大概解释下这段代码:
getURL()函数返回一个promise,并在传入url参数给$.get()调用,请求成功后调用reslove(res)来返回请求结果。getData()函数声明一个promises来存放getURL(url)返回的promise对象,通过URI_LIST.map()来得到我们在基础用法中所讲的iterable参数对象,并将此对象传入Promise.all()中,最后通过then()获取结果。要注意的是,此结果是三个请求返回的数据组成的数组。
四.Promise 常见特性
有以下5个特性需要大家理解:
- Promise 捕获错误与 try catch 等同.
- Promise 拥有状态变化.
- Promise 方法中的回调是异步的.
- Promise 方法每次都返回一个新的 Promise.
- Promise 会存储返回值.
下面我来一一解释这 5 点特性:
1.Promise 捕获错误与 try catch 等同
这句话的意识就是说,在 new Promise(()=>{}) 中直接去 throw err,是可以通过 Promise.catch() 方法捕捉的,这也就意味中 Promise 内部也通过 try catch 进行了异常处理。
2.Promise 拥有状态变化
Promise 有以下三种状态:
- pending: 初始状态,既不是成功,也不是失败状态。
- fulfilled: 意味着操作成功完成。
- rejected: 意味着操作失败。
而 Promise.resolve() 和 Promise.reject() 都会改变 promise 的状态值,其中 resolve 会将此状态值修改为 fulfilled, 而 reject 会将此状态值修改为为 rejected。
特别的,一旦 Promise 的状态值被改变,就会被固定,不再发生变化。也就是说只要你 resolve() 或者 reject() 了一次,在这之后无论你再调用几次这两个方法都不起效果。
3.Promise 方法中的回调是异步的
先解释一下,Promise 方法中的回调是异步的这句话中的方法是指 Promise 中的 catch,then,finally这些方法,而不是指 new Promise() 中的 executor 函数,这个函数你可以把它理解为一个立即执行函数。
想要真正理解 Promise 方法中的回调是异步的这句话,还没有这么简单,为什么这么说,因为 setTimeout 也是异步的,如果它们两同时存在且作用域平级,那么谁先执行,谁后执行,它们之间的竞争关系怎么确认?
想要了解这其中的原理,我们就需要了解一个概念:微任务(microtasks)和宏任务(tasks)。
我们已经知道,JavaScript 是单线程的操作,正是因为如此,才有了现在的同步和异步之分。在主线程中,一般是按顺序执行同步任务。而其他的异步任务则会挂起,当它们有返回值后会添加到任务队列中。等到主线程的同步任务执行完毕后,它会去任务队列中读取(按先进先出的原则)异步任务执行。以此形成一个反复的过程被称为事件循环。借用一个掘金上的图片,侵删:

而在异步任务中,其实又可以细分为宏任务和微任务。
- 宏任务可以当成了广义的异步队列中的任务,严格按照顺序压栈和执行。比如说 整体代码,
setTimeout,setInterval,MessageChannel(Web Worker中的管道通信)。 - 微任务是当前宏任务执行完成后立即执行的任务。
而在整个异步流程中,JavaScript 会先进入整体代码执行宏任务,然后再检查是否有微任务需要执行,如果有,则需要立即执行;如果没有则检查队列,开始执行下一批宏任务并检查微任务。借用一个掘金上的图片,侵删:

总结一下, Promise 中的 executor 函数是处于主线程同步队列中执行(立即执行函数),而其他的方法诸如 then, catch 等,则是异步任务队列中的微任务,诸如 setTimeout,setInterval 等函数必须在微任务执行完毕后再开始执行。
所以,看到这儿,整个 Promise 中的函数内部在整个执行栈的执行顺序和竞争关系就已经很清晰了。
4.Promise 方法每次都返回一个新的 Promise
这儿的意思很直白,意味着无论是 then,catch 亦或是 finally 都会返回 一个新的 Promise 对象。
5.Promise 会存储返回值
一般情况下我们都会这样来使用 Promise:
function p(flag){
return new Promise((resolve, reject) => {
if(flag){
resolve('success')
}else{
reject('error')
}
})
};
p(true).then(res => console.log('res', res))
可以看到,我们通常会把一些参数或者函数在成功状态下通过 resolve() 传递给 then() 函数来接收并作相应处理;在失败状态下通过 reject() 把错误信息传递给 catch() 函数来处理。
特别的,如果你在 Promise 直接返回某些参数, Pormise 也会捕捉到你返回的参数并把它包装成 Promise 对象并传递给对应的接收函数。
五. Promise 面试题
5.1 请用 Pormise 实现以下流水灯,已知红黄绿三个函数,要求红灯3秒执行一次,黄灯2秒执行一次,绿灯1秒执行一次:
function red() {
console.log('red');
}
function green() {
console.log('green');
}
function yellow() {
console.log('yellow');
}
function delay(time){
return new Promise((resolve, reject) => {
setTimeout(resolve,time)
})
}
async function runTask(){
await delay(3000);
red();
await delay(2000);
green();
await delay(1000);
yellow();
// 递归循环播放
runTask()
}
runTask();
5.2 请用 Pormise 实现 mergePromise 函数,把传进去的数组按顺序先后执行,并且把返回的数据先后放到数组 data 中:
const timeout = ms => new Promise((resolve, reject) => {
setTimeout(() => {
resolve();
}, ms);
});
const ajax1 = () => timeout(2000).then(() => {
console.log('1');
return 1;
});
const ajax2 = () => timeout(1000).then(() => {
console.log('2');
return 2;
});
const ajax3 = () => timeout(2000).then(() => {
console.log('3');
return 3;
});
const mergePromise = ajaxArray => {
// 在这里实现你的代码
};
mergePromise([ajax1, ajax2, ajax3]).then(data => {
console.log('done');
console.log(data); // data 为 [1, 2, 3]
});
// 要求分别输出
// 1
// 2
// 3
// done
// [1, 2, 3]
我们先分析一下题目,看到这个题目是不是就有一种很熟悉的感觉?像不像我们在上面改写的异步并行?
你的感觉没错,实际上这道题考查的就是让你手写一个简单的 Promise.all() 函数。
所以,我们就能知道,上题中 ajaxArray 参数实际上就是一个包含多个 Promise 对象的数组,我们可以用并行遍历的方式来处理它。
const mergePromise = ajaxArray => {
let seq = Promise.resolve();
let data = [];
ajaxArray.map(func => {
seq = seq.then(func).then(res => {
data.push(res);
return data;
})
})
return seq;
};
六.小结
如果你看到这了这,那么恭喜你,不管你有没有吸收其中的内容,你至少你知道了整个 Promise 应该怎么去学。实际上在工作中 Promise 的应用是很多的,包括我们使用的 babel 中也会有 Promise-polyfill。现在已经是 9102 年了,前端圈已经逐渐稳定下来,这意味着你我的时间已然不多,所以加油吧,伙计们。
本文深入讲解Promise的基础用法,探讨其在串行、并行处理中的应用,解析Promise的常见特性,通过实例演示如何解决回调地狱问题,以及如何在实际项目中高效运用Promise。
1277

被折叠的 条评论
为什么被折叠?



