Promise 主要是为解决程序异步处理而生的,在现在的前端应用中无处不在,已然成为前端开发中最重要的技能点之一。它不仅解决了以前回调函数地狱嵌套的痛点,更重要的是它提供了更完整、更强大的异步解决方案。
同时 Promise 也是前端面试中必不可少的考察点,考察内容可深可浅,因此熟练掌握它是每个前端开发者的必备能力。
Promise 相对于 callback 模式的优势,网上的介绍文章已经多如牛毛,本文我将不再重点赘述。本文我主要会在介绍 Promise 的基础使用上,重点介绍其典型的场景应用,以及一些重难点场景分析,主要目的是提高对 Promise 的理解及对其灵活的运用能力。
首先 Promise 也是一个类或构造函数,是 JS 原生提供的,和我们自定义的类一样,通过对它进行实例化后,来完成预期的异步任务处理。
Promise 接受异步任务并立即执行,然后在任务完成后,将状态标注成最终结果(成功或失败)。
Promise 有三种状态:初始化时,刚开始执行主体任务,这时它的初始状态时 pending(进行中) ;等到任务执行完成,这时根据成功或失败,分别对应状态 fulfilled(成功)和 rejected(失败) ,这时的状态就固定不能被改变了,即 Promise 状态是不可逆的。
Promise 就是一个类,所以使用时,我们照常 new 一个实例即可。
const myPromise = new Promise((resolve, reject) => {
// 这里是 Promise 主体,执行异步任务
ajax('xxx', () => {
resolve('成功了'); // 或 reject('失败了')
})
})
上面创建好 Promise 实例后,里面的主体会立即执行,比如,如果是发送请求,则会立即把请求发出去,如果是定时器,则会立即启动计时。至于请求什么时候返回,我们就在返回成功的地方,通过 resolve() 将状态标注为成功即可,同时 resolve(data) 可以附带着返回数据。 然后在 then() 里面进行回调处理。
const myPromise = new Promise((resolve, reject) => {
// 这里是 Promise 主体,执行异步任务
ajax('xxx', () => {
resolve('成功了');
})
})
myPromise.then((data) => {
// 处理 data 数据
})
这里需要注意的是当初始化 Promise 实例时,主体代码是同步就开始执行了的,只有 then() 里面的回调处理才是异步的,因为它需要等待主体任务执行结束。技能考察时常常会通过分析执行顺序考察此处。 如下面的代码将输出 1、3、2。
const myPromise = new Promise((resolve, reject) => {
// 这里是 Promise 主体,执行异步任务
console.log(1);
ajax('xxx', () => {
resolve('成功了');
})
}).then(() => {
console.log(2);
})
console.log(3);// 最终输出 1、3、2
如果我们在调用 then() 之前,Promise 主体里的异步任务已经执行完了,即 Promise 的状态已经标注为成功了。那么我们调用 then 的时候,并不会错过,还是会执行。但需要记着,即使主体的异步任务早就执行完了,then() 里面的回调永远是放到微任务里面异步执行的,而不是立马执行。
比如我们在主体里面仅执行一块同步代码,从而不需要等待,下面代码 then() 将依然最后输出。因此我们也常常利用这种方式构建微任务(相对应的利用 setTimeout 构建宏任务):
const myPromise = new Promise((resolve, reject) => {
// 主体只有同步代码,则 Promise 状态会立马标注为成功
console.log(1);
resolve();
}).then(() => {
console.log(2);
})
console.log(3);
// 最终输出为 1、3、2
const myPromise = new Promise(...);
myPromise.then(successCallback, errorCallback);
这种方式能捕获到 promise 主体里面的异常,并执行 errorCallback。但是如果 Promise 主体里面没有异常,然后进入到 successCallback 里面发生了异常,此时将不会进入到 errorCallback。因此我们经常使用下面的方式二来处理异常。
const myPromise = new Promise(...);
myPromise.then(successCallback).catch(errorCallback);
这样不管是 Promise 主体,还是 successCallback 里面的出了异常,都会进入到 errorCallback。这里需要注意,按这种链式写法才正确,如果按下面的写法将会和方式一类似,不能按预期捕获,具体原因在后面的链式调用里面说明。
const myPromise = new Promise(...);
myPromise.then(successCallback);
myPromise.catch(errorCallback);
try catch 是传统的异常捕获方式,这里只能捕获同步代码的异常,并不能捕获异步异常,因此无法对 Promise 进行完整的异常捕获。
熟悉 JQuery 的同学应该很了解链式调用,就是在调用了对象的一个方法后,此方法又返回了这个对象,从而可以继续在后面调用对象的方法。Promise 的链式调用,每次调用后,会返回一个新的 Promise 实例对象,从而可以继续 then()或者其他 API 调用,如上面的方式二异常处理中的 catch 就属于链式调用。
V
这里需要注意的是,每次 then() 或者 catch() 后,返回的是一个新的 Promise,和上一次的 Promise 实例对象已经不是同一个引用了。而这个新的 Promise 实例对象包含了上一次 then 里面的结果,这也是为什么链式调用的 catch 才能捕获到上一次 then 里面的异常的原因。
下面的代码非链式调用,每次 then 都是针对最初的 Promise 实例最后输出为 1。
const myPromise = new Promise((resolve) => {
resolve(1)
})
myPromise.then((data) => {
return data + 1;
})
romise.then((data) => {
console.log(data);
})
// 输出 1
我再对一些常用 API 进行一下简单说明和介绍,Promise API 和大部分类一样,分为实例 API 或原型方法(即 new 出来的对象上的方法),和静态 API 或类方法(即直接通过类名调用,不需要 new)。注意实例 API 都是可以通过链式调用的。
实例 API(原型方法)
Promise 主体任务和在此之前的链式调用里的回调任务都成功的时候(即前面通过 resolve 标注状态后),进入本次 then() 回调。
Promise 主体任务和在此之前的链式调用里的出现了异常,并且在此之前未被捕获的时候(即前面通过 reject 标注状态或者出现 JS 原生报错没处理的时候),进入本次 catch()回调。
无论前面出现成功还是失败,最终都会执行这个方法(如果添加过)。比如某个任务无论成功还是失败,我们都希望能告诉用户任务已经执行结束了,就可以使用 finally()。
静态 API(类方法)
返回一个成功状态的 Promise 实例,一般常用于构建微任务,比如有个耗时操作,我们不希望阻塞主程序,就把它放到微任务去,如下输出 1、3、2,即 console.log(2) 将放到最后微任务去执行:
console.log(1);
Promise.resolve().then(() => {
console.log(2); // 作为微任务输出 2
})
console.log(3);
这个与 Promise.resolve 使用类似,返回一个失败状态的 Promise 实例。
此方法接收一个数组为参数(准确说是可迭代参数),数组里面每一项都是一个单独的 Promise 实例,此方法返回一个 Promise 对象。这个返回的对象含义是数组中所有 Promise 都返回了(可失败可成功),返回 Promise 对象就算完成了。适用于需要并发执行任务时,比如同时发送多个请求。
const p1 = new Promise(...);
const p2 = new Promise(...);
const p3 = new Promise(...);
const pAll = Promise.all([p1, p2, p3]);
pAll.then((list) => {
// p1,p2,p3 都成功了即都 resolve 了,会进入这里;
// list 按顺序为 p1,p2,p3 的 resolve 携带的返回值
}).catch(() => {
// p1,p2,p3 有至少一个失败,其他成功,就会进入这里;
})
注意 Promise.all 是所有传入的值都返回状态了,才会最终进入 then 或 catch 回调。
Promise 的参数也可以如下常量,它会转换成立即完成的 Promise 对象:
Promise.all([1, 2, 3]);
// 等同于
const p1 = new Promise(resolve => resolve(1));
const p2 = new Promise(resolve => resolve(2));
const p3 = new Promise(resolve => resolve(3));
Promise.all([p1, p2, p3]);
与 Promise.all() 类似,不过区别是 Promise.race 只要传入的 Promise 对象,有一个状态变化了,就会立即结束,而不会等待其他 Promise 对象返回。所以一般用于竞速的场景。
接下来,来看看 Promise 具体的使用场景。
Promise 的 API 不多,使用也不复杂,简单场景一看就明白,不过对于一些复杂的代码模块,不够熟悉的同学就会感觉比较绕。比如这些实际应用中的经验。
实际应用中,我们尽量将所有异步操作进行 Promise 的封装,方便其他地方调用。放弃以前的 callback 写法,比如我们封装了一个类 classA,里面需要有一些准备工作才能被外界使用,以前我们可能会提供 ready(callback) 方法,那么现在就可以这样 ready().then()。
另外,一般开发中,尽量将 new Promise 的操作封装在内部,而不是在业务层去实例化。
如下面代码:
// 封装
function getData(){
const promise = new Promise((resolve,reject)=>{
ajax(xxx, (d) => {
resolve(d);
})
});
return promise
}
// 使用
getData().then((data)=>{
console.log(data)
})
其实处理和封装异步任务关键就是两件事
当通过 new Promise 初始化实例的时候,就定义了异步任务的执行内容,即 Promise 主体。然后 Promise 给我们两个函数 resolve 和 reject 来让我们明确指出任务结束的时机,也就是告诉 Promise 执行的内容和结束的时机就行了,不用像 callback 那样,需要把处理过程也嵌套写在里面,而是在原来 callback 的地方调用一下 resolve(成功)或 reject(失败)来标识任务结束了。
在实际开发中,不管业务模块或者老代码多么复杂,只需要抓住上述两点去进行改造,就能正确地将所有异步代码进行 Promise 化。 所有异步甚至同步逻辑都可以 Promise 化,只要抓住 任务内容和 任务结束时机这两点就可很清晰的来完成封装。
现在很多类库已经支持返回 Promise 实例了,尽量避免在外面重复包装,所以在使用时仔细看官方说明,有的库既支持 callback 形式,也支持 Promise 形式。
下面代码为冗余封装:
function getData() {
return new Promise((resolve) => {
axios.get(url).then((data) => {
resolve(data)
})
})
}
另一个案例就是,有时我们会需要构建微任务或者将同步执行的结果数据,以 Promise 的形式返回给业务,会容易写成下面的冗余写法:
function getData() {
return new Promise((resolve) => {
const a = 1;
const b = 2;
const c = a + b;
resolve(c);
})
}
优化写法应该如下,即用 Promise.resolve 快速构建一个 Promise 对象:
function getData() {
const a = 1;
const b = 2;
const c = a + b;
return Promise.resolve(c);
}
前面 API 的介绍中已经有说明,尽量通过 catch() 去捕获 Promise 异常,需要说明的是,一旦被 catch 捕获过的异常,将不会再往外部传递,除非在 catch 中又触发了新的异常。
如下面代码,第一个异常被捕获后,就返回了一个新的 Promise,这个 Promise 对象没有异常,将会进入后面的 then() 逻辑:
const p = new Promise((resolve, reject) => {
reject('异常啦'); // 或者通过 throw new Error() 跑出异常
}).catch((err) => {
console.log('捕获异常啦'); // 进入
}).catch(() => {
console.log('还有异常吗'); // 不进入
}).then(() => {
console.log('成功'); // 进入
})
如果 catch 里面在处理异常时,又发生了新的异常,将会继续往外冒,这个时候我们不可能无止尽的在后面添加 catch 来捕获,所以 Promise 有一个小的缺点就是最后一个 catch 的异常没办法捕获(当然实际出现异常的可能性很低,基本不造成什么影响)。
实际使用中,我们一般通过 async await 来配合 Promise 使用,这样可以让代码可读性更强,彻底没有"回调"的痕迹了。
async function getData() {
const data = await axios.get(url);
return data;
}
// 等效于
function getData() {
return axios.get(url).then((data) => {
return data
});
}
对 async await 很多人都会用,但要注意几个非常重要的点。
比如下面写法就是不正确的:
async function getData() {
// await 不认识后面的 setTimeout,不知道何时返回
const data = await setTimeout(() => {
return;
}, 3000)
console.log('3 秒到了')
}
正确写法是:
async function getData() {
const data = await new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 3000)
})
console.log('3 秒到了')
}
有这样一个场景:页面的数据量较大,通过缓存类将数据缓存在了本地,下一次可以直接使用缓存,在一定数据规模时,本地的缓存初始化和读取策略也会比较耗时。这个时候我们可以继续等待缓存类初始完成并读取本地数据,也可以不等待缓存类,而是直接提前去后台请求数据。两种方法最终谁先返回的时间不确定。那么为了让我们的数据第一时间准备好,让用户尽可能早地看到页面,我们可以通过 Promise 来做加载优化。
策略是页面加载后,立马调用 Promise 封装的后台请求,去后台请求数据。同时初始化缓存类并调用 Promise 封装的本地读取数据。最后在显示数据的时候,看谁先返回用谁的。
实际应用中,还有这样一种场景:我们正在发送多个请求用于请求数据,等待完成后将数据插入到不同的 dom 元素中,而如果在中途 dom 元素被销毁了(比如 react 在 useEffect 中请求的数据时,组件销毁),这时就可能会报错。因此我们需要提前中断正在请求的 Promise,不让其进入到 then 中执行回调。
useEffect(() => {
let dataPromise = new Promise(...);
let data = await dataPromise();
// TODO 接下来处理 data,此时本组件可能已经销毁了,dom 也不存在了,所以需要在下面对 Promise 进行中断
return (() => {
// TODO 组件销毁时,对 dataPromise 进行中断或取消
})
});
我们可以对生成的 Promise 对象进行再一次包装,返回一个新的 Promise 对象,而新的对象上被我们增加了 cancel 方法,用于取消。这里的原理就是在 cancel 方法里面去阻止 Promise 对象执行 then()方法。
下面构造了一个 cancelPromise 用于和原始 Promise 竞速,最终返回合并后的 Promise,外层如果调用了 cancel 方法,cancelPromise 将提前结束,整个 Promise 结束。
function getPromiseWithCancel(originPromise) {
let cancel = (v) => {};
let isCancel = false;
const cancelPromise = new Promise(function (resolve, reject) {
cancel = e => {
isCancel = true;
reject(e);
};
});
const groupPromise = Promise.race([originPromise, cancelPromise])
.catch(e => {
if (isCancel) {
// 主动取消时,不触发外层的 catch
return new Promise(() => {});
} else {
return Promise.reject(e);
}
});
return Object.assign(groupPromise, { cancel });
}
// 使用如下
const originPromise = axios.get(url);
const promiseWithCancel = getPromiseWithCancel(originPromise);
promiseWithCancel.then((data) => {
console.log('渲染数据', data);
});
promiseWithCancel.cancel(); // 取消 Promise,将不会再进入 then() 渲染数据
熟悉了 Promise 的基本运用后,我们再来深入点理解。Promise 和 callback 还有个本质区别,就是控制权反转。
callback 模式下,回调函数是由业务层传递给封装层的,封装层在任务结束时执行了回调函数。
而 Promise 模式下,业务层并没有把回调函数直接传递给封装层( Promise 对象内部),封装层在任务结束时也不知道要做什么回调,只是通过 resolve 或 reject 来通知到 业务层,从而由业务层自己在 then() 或 reject() 里面去控制自己的回调执行。
这里可能理解起来有点绕,换种等效的简单理解:我们知道函数一般是分定义 + 调用步骤的,先定义,后调用。谁调用了函数,就表示谁在控制这个函数的执行。
那么我们来看 callback 模式下,业务层将回调函数的定义传给了封装层,封装层在内部完成了回调函数的调用执行,业务层并没有调用回调函数,甚至业务层都看不到其调用代码,所以回调函数的执行控制权在封装层。
而 Promise 模式下,回调函数的调用执行是在 then() 里面完成的,是由业务层发起的,业务层不仅能看到回调函数的调用代码,也能修改,因此回调函数的控制权在业务层。
现在我们已经熟悉了 Promise 的详细使用方式,假设让你回到 Promise 类出现之前,那时的 ES6 还没出现,你为了淘汰 callback 的回调写法,准备自己写一个 Promise 类,你会怎么做?
其实这就是常见面试手写 Promise 题目。我们只要抓住 Promise 的一些特点和关键点就能比较顺利实现。
首先 Promise 是一个类,构造函数接收参数是一个函数,而这个函数的参数是 resolve 和 reject 两个内部函数,也就是我们需要构建 resolve 和 reject 传给它,同时让它立即执行。另外咱这个类是有三种状态及 then 和 catch 等方法。根据这些就能快速先把类框架创建好。
class MyPromise () {
constructor (fun) {
this.status = 'pending'; // pending、fulfilled、rejected
fun(this.resolve, this.reject); // 立即执行主体函数,参数函数可能需要 bind(this)
}
resolve() {} // 定义 resolve,内容待定
reject() {} // 定义 reject,内容待定
then() {}
catch() {}
}
有了雏形之后,再根据对 Promise 的理解逐步完善即可,如 resolve 和 reject 里面我们肯定是要去修改 status 状态的; 而 then() 里面我们需要接收并保存传进来的回调等等。 完整案例可在网上搜索,重点是理解它的实现思路。
今天我们对 Promise 进行了基本 API 介绍,然后重点对其实际应用进行了介绍和解析。相信通过本文的学习,可以提升你对 Promise 的理解和运用能力。
同时文中的一些实际场景举例是非常典型的应用场景,比如 async await 和手写 Promise 是很容易被考察的点。并且考察方式变化很多,万变不离其宗,抓住文中重点内容,做到举一反三不是问题。
最后可以看一个有点难度的 Promise 执行顺序分析题目:
function promise2() {
return new Promise((resolve) => {
console.log('promise2 start');
resolve();
})
}
function promise3() {
return new Promise((resolve) => {
console.log('promise3 start');
resolve();
})
}
function promise4() {
return new Promise((resolve) => {
console.log('promise4 start');
resolve();
}).then(() => {
console.log('promise4 end');
})
}
async function asyncFun() {
console.log('async1 start');
await promise2();
console.log('async1 inner');
await promise3();
console.log('async1 end');
}
setTimeout(() => {
console.log('setTimeout start');
promise1();
console.log('setTimeout end');
}, 0);
asyncFun();
promise4();
console.log('script end');
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8