一. 前言
Promise是JavaScript中处理异步编程的一种重要机制,它可以避免回调地狱,使得异步操作更加可读、可维护。
在js项目中,promise的使用应该是必不可少的,但我发现在同事和面试者中,很多中级或以上的前端都还停留在promise.then()、promise.catch()、Promise.all等常规用法,连async/await也只是知其然,而不知其所以然。
但其实,promise还有很多巧妙的高级用法,也将一些高级用法在alova请求策略库内部大量运用。本文将探讨这些用法特性,并提供相应的源码示例。
二. Promise基础
代码结构
typescript
const promise = new Promise((resolve, reject) => {
resolve("resolve")
reject("reject")
})
promise.then(res => {
console.log(res)
}).catch(err => {
console.log(err)
})
//在通过new创建Promise对象时,需要传入一个回调函数,称之为executor
//这个回调函数会被立即执行,并且给传另外两个回调函数resolve、reject
//当调用resolve回调函数时,会执行Promise对象的then方法传入的回调函数
//当调用reject回调函数时,会执行Promise对象的catch方法传入的回调函数
状态
一个Promise必然会处于以下三个状态之一:
-
pending
:初始状态,既没有被兑现,也没有被拒绝; -
fullfilled
:意味操作成功完成; -
rejected
:意味操作失败;
常用API
- Promise.resolve(value) : 类方法,该方法返回一个以 value 值解析后的 Promise 对象
- Promise.reject: 类方法,且与 resolve 唯一的不同是,返回的 promise 对象的状态为 rejected
- Promise.race: 类方法,多个 Promise 任务同时执行,返回最先执行结束的 Promise 任务的结果,不管这个 Promise 结果是成功还是失败。
- Promise.all: 类方法,多个 Promise 任务同时执行。如果全部成功执行,则以数组的方式返回所有 Promise 任务的执行结果。 如果有一个 Promise 任务 rejected,则只返回 rejected 任务的结果。
- Promise.prototype.then: 实例方法,为 Promise 注册回调函数,函数形式:fn(vlaue){},value 是上一个任务的返回结果,then 中的函数一定要 return 一个结果或者一个新的 Promise 对象,才可以让之后的then 回调接收。
- Promise.prototype.catch: 实例方法,捕获异常,函数形式:fn(err){}, err 是 catch 注册 之前的回调抛出的异常信息。
- Promise.prototype.finally: 实例方法,ES9中新增特性:表示无论Promise对象无论变成fulfilled还是rejected状态,最终都会被执行的代码。
三.常见问题(FAQ)
Q: then、catch 和 finally 序列能否顺序颠倒?
A: 可以,效果完全一样。但不建议这样做,最好按 then-catch-finally 的顺序编写程序。
Q: 除了 then 块以外,其它两种块能否多次使用?
A: 可以,finally 与 then 一样会按顺序执行,但是 catch 块只会执行第一个,除 非 catch 块里有异常。所以最好只安排一个 catch 和 finally 块。
Q: then 块如何中断?
A: then 块默认会向下顺序执行,return不能中断,可以通过 throw抛出错误来跳转至 catch 实现中断。
Q: 什么时候适合用 Promise 而不是传统回调函数?
A: 当需要多次顺序执行异步操作的时候,例如,如果想通过异步方法先后检测用户名和密码,需要先异步检测用户名,然后再异步检测密码的情况下就很适合 Promise。
Q: 什么时候我们需要再写一个 then 而不是在当前的 then 接着编程?
A: 当你又需要调用一个异步任务的时候。
四. Promise高级用法
1. promise数组串行执行
例如你有一组接口需要串行执行,首先你可能会想到使用await
javascript
const requestAry = [() => api.request1(), () => api.request2(), () => api.request3()];
async function runPromises(requestAry) {
for (const task of requestAry) {
await task()
}
}
runPromises(requestAry)
如果使用promise的写法,那么你可以使用then函数来串联多个promise,从而实现串行执行。
javascript
const requestAry = [() => api.request1(), () => api.request2(), () => api.request3()];
const runPromises = requestAry.reduce(
(promise, curTask) => promise.then(curTask), // 通过循环任务数组,不断在promise后使用.then(nextTask)拼接任务
Promise.resolve(); // 创建一个初始promise,用于链接数组内的promise
);
runPromises.then()
2. 同时调用resolve和reject会怎么样?
大家都知道promise分别有pending/fullfilled/rejected三种状态,但例如下面的示例中,promise最终是什么状态?
scss
const promise = new Promise((resolve, reject) => {
resolve();
reject();
});
正确答案是fullfilled状态,我们只需要记住,promise一旦从pending状态转到另一种状态,就不可再更改了,因此示例中先被转到了fullfilled状态,再调用reject()也就不会再更改为rejected状态了。
3. 在new Promise作用域外更改状态
假设你有多个页面的一些功能需要先收集用户的信息才能允许使用,在点击使用某功能前先弹出信息收集的弹框,你会怎么实现呢?
以下是不同水平的前端同学的实现思路:
初级前端:我写一个模态框,然后复制粘贴到其他页面,效率很杠杠的!
中级前端:你这不便于维护,我们要单独封装一下这个组件,在需要的页面引入使用!
高级前端:封什么装!!!写在所有页面都能调用的地方,一个方法调用岂不更好?
看看高级前端怎么实现的,以vue3为例来看看下面的示例。
xml
<!-- App.vue -->
<template>
<!-- 以下是模态框组件 -->
<div class="modal" v-show="visible">
<div>
用户姓名:<input v-model="info.name" />
</div>
<!-- 其他信息 -->
<button @click="handleCancel">取消</button>
<button @click="handleConfirm">提交</button>
</div>
<!-- 页面组件 -->
</template>
<script setup>
import { provide } from 'vue';
const visible = ref(false);
const info = reactive({
name: ''
});
let resolveFn, rejectFn;
// 将信息收集函数传到下面
provide('getInfoByModal', () => {
visible.value = true;
return new Promise((resolve, reject) => {
// 将两个函数赋值给外部,突破promise作用域
resolveFn = resolve;
rejectFn = reject;
});
})
const handleConfirm = info => {
resolveFn && resolveFn(info);
};
const handleCancel = () => {
rejectFn && rejectFn(new Error('用户已取消'));
};
</script>
接下来直接调用getInfoByModal即可使用模态框,轻松获取用户填写的数据。
xml
<template>
<button @click="handleClick">填写信息</button>
</template>
<script setup>
import { inject } from 'vue';
const getInfoByModal = inject('getInfoByModal');
const handleClick = async () => {
// 调用后将显示模态框,用户点击确认后会将promise改为fullfilled状态,从而拿到用户信息
const info = await getInfoByModal();
await api.submitInfo(info);
}
</script>
这也是很多UI组件库中对常用组件的一种封装方式。
4. async/await的另类用法
很多人只知道在async函数调用时用await接收返回值,但不知道async函数其实就是一个返回promise的函数,例如下面两个函数是等价的:
scss
const fn1 = async () => 1;
const fn2 = () => Promise.resolve(1);
fn1()和fn2()都返回一个值为1的promise对象
而await在大部分情况下在后面接promise对象,并等待它成为fullfilled状态,因此下面的fn1函数等待也是等价的:
ini
const fn1 = async () => 1;
await fn1();
const promiseInst = fn1();
await promiseInst;
然而,await还有一个鲜为人知的秘密,当后面跟的是非promise对象的值时,它会将这个值使用promise对象包装,因此await后的代码一定是异步执行的。如下示例:
javascript
Promise.resolve().then(() => {
console.log(1);
});
await 2;
console.log(2);
// 打印顺序位:1 2
等价于
javascript
Promise.resolve().then(() => {
console.log(1);
});
Promise.resolve().then(() => {
console.log(2);
});
5. promise实现请求共享
当一个请求已发出但还未响应时,又发起了相同请求,就会造成了请求浪费,此时我们就可以将第一个请求的响应共享给第二个请求,让两个请求其实只会真正发出一次,并且同时收到相同的响应值。
那么,请求共享会有哪几个使用场景呢?我认为有以下三个:
1.当一个页面同时渲染多个内部自获取数据的组件时;
2.提交按钮未被禁用,用户连续点击了多次提交按钮;
3.在预加载数据的情况下,还未完成预加载就进入了预加载页面;
这也是alova的高级功能之一,实现请求共享需要用到promise的缓存功能,即一个promise对象可以通过多次await获取到数据,简单的实现思路如下:
ini
const pendingPromises = {};
function request(type, url, data) {
// 使用请求信息作为唯一的请求key,缓存正在请求的promise对象
// 相同key的请求将复用promise
const requestKey = JSON.stringify([type, url, data]);
if (pendingPromises[requestKey]) {
return pendingPromises[requestKey];
}
const fetchPromise = fetch(url, {
method: type,
data: JSON.stringify(data)
})
.then(response => response.json())
.finally(() => {
delete pendingPromises[requestKey];
});
return pendingPromises[requestKey] = fetchPromise;
}
6. then函数的第二个回调和catch回调有什么不同?
then是实例状态发生改变时的回调函数,第一个参数是resolved状态的回调函数,第二个参数是rejected状态的回调函数
promise的then的第二个回调函数和catch在请求出错时都会被触发,咋一看没什么区别,但其实,前者不能捕获当前then第一个回调函数中抛出的错误,但catch可以。
typescript
let p = new Promise((resolve, reject) => {
resolve('成功');
// reject('失败')
})
p.then(
res => {
console.log('then' + res);
throw new Error('来自成功回调的错误');
},
err => {
console.log('then' + err); // 捕获reject抛出的错误
}
).catch(err => console.log('catch' + err))// 将打印出"来自成功回调的错误"
其原理也正如于上一点所言,catch函数是在then函数返回的rejected状态的promise上调用的,自然也就可以捕获到它的错误。
7. then/catch/finally返回值
先总结成一句话,就是以上三个函数都会返回一个新的promise包装对象,被包装的值为被执行的回调函数的返回值,回调函数抛出错误则会包装一个rejected状态的promise,好像不是很好理解,我们来看看例子:
typescript
// then函数
Promise.resolve().then(() => 1); // 返回值为 new Promise(resolve => resolve(1))
Promise.resolve().then(() => Promise.resolve(2)); // 返回 new Promise(resolve => resolve(Promise.resolve(2)))
Promise.resolve().then(() => {
throw new Error('err')
}); // 返回 new Promise(resolve => resolve(Promise.reject(new Error('err'))))
Promise.reject().then(() => 1, () => 2); // 返回值为 new Promise(resolve => resolve(2))
// catch函数
Promise.reject().catch(() => 3); // 返回值为 new Promise(resolve => resolve(3))
Promise.resolve().catch(() => 4); // 返回值为 new Promise(resolve => resolve(调用catch的promise对象))
// finally函数
// 以下返回值均为 new Promise(resolve => resolve(调用finally的promise对象))
Promise.resolve().finally(() => {});
Promise.reject().finally(() => {});
五.Promise相关执行顺序
js事件循环
JavaScript事件循环是一种处理异步事件和回调函数的机制,它是JavaScript实现异步编程的核心。它在浏览器或Node.js环境中运行,用于管理任务队列和调用栈,以及在适当的时候执行回调函数。
javascript
async function async1() {
console.log("async1 start");
await async2();
console.log("async1 end");
}
async function async2() {
console.log("async2");
}
console.log("script start");
setTimeout(function() {
console.log("setTimeout");
}, 0);
async1();
new Promise(resolve => {
console.log("promise1");
resolve();
}).then(function() {
console.log("promise2");
});
console.log('script end')
代码执行过程:
开头定义了async1和async2两个函数,但是并未执行,执行script中的代码,所以打印出script start;
遇到定时器Settimeout,它是一个宏任务,将其加入到宏任务队列;
之后执行函数async1,首先打印出async1 start;
遇到await,执行async2,打印出async2,并阻断后面代码的执行,将后面的代码加入到微任务队列;
然后跳出async1和async2,遇到Promise,打印出promise1;
遇到resolve,将其加入到微任务队列,然后执行后面的script代码,打印出script end;
之后就该执行微任务队列了,首先打印出async1 end,然后打印出promise2;
执行完微任务队列,就开始执行宏任务队列中的定时器,打印出setTimeout。
javascript
const first = () => (new Promise((resolve, reject) => {
console.log(3);
let p = new Promise((resolve, reject) => {
console.log(7);
setTimeout(() => {
console.log(5);
resolve(6);
console.log(p)
}, 0)
resolve(1);
});
resolve(2);
p.then((arg) => {
console.log(arg);
});
}));
first().then((arg) => {
console.log(arg);
});
console.log(4);
执行过程:
首先会进入Promise,打印出3,之后进入下面的Promise,打印出7;
遇到了定时器,将其加入宏任务队列;
执行Promise p中的resolve,状态变为resolved,返回值为1;
执行Promise first中的resolve,状态变为resolved,返回值为2;
遇到p.then,将其加入微任务队列,遇到first().then,将其加入任务队列;
执行外面的代码,打印出4;
这样第一轮宏任务就执行完了,开始执行微任务队列中的任务,先后打印出1和2;
这样微任务就执行完了,开始执行下一轮宏任务,宏任务队列中有一个定时器,执行它,打印出5,由于执行已经变为resolved状态,所以resolve(6)不会再执行;
最后console.log(p)打印出Promise{<resolved>: 1};