八個Promise高級技巧,讓你在前端開發(fā)中如虎添翼!
在JavaScript項目中,Promise 的使用是必不可少的。然而,我發(fā)現(xiàn)許多中高級前端開發(fā)人員仍然停留在常見的promiseInst.then()、promiseInst.catch()、Promise.all甚至async/await等常規(guī)實踐上,并沒有深入理解它們。
實際上,Promise 有許多巧妙的高級用法,其中一些在ALOVA請求策略庫中廣泛使用。
ALOVA 是一個基于 Promise 的請求策略庫,旨在幫助開發(fā)者更高效地進行 HTTP 請求處理。它通過高級的 Promise 技巧,實現(xiàn)了請求共享、緩存、批量請求等功能,從而簡化了前端開發(fā)中的數(shù)據(jù)請求管理。ALOVA 的目標(biāo)是提供一種簡潔、高效的方式來處理復(fù)雜的請求場景,減少代碼冗余,提高應(yīng)用性能。
現(xiàn)在,我將毫無保留地分享這些高級技巧。讀完這篇文章后,將不再為相關(guān)問題感到困惑。
串行執(zhí)行Promise數(shù)組
例如,如果你有一組需要串行執(zhí)行的接口,首先你可能會想到使用 await。
const requestAry = [() => api.request1(), () => api.request2(), () => api.request3()];
for (const requestItem of requestAry) {
await requestItem();
}
如果使用Promise語法,你可以使用then函數(shù)將多個Promise串起來,從而實現(xiàn)順序執(zhí)行。
const requestAry = [() => api.request1(), () => api.request2(), () => api.request3()];
const finallyPromise = requestAry.reduce(
(currentPromise, nextRequest) => currentPromise.then(() => nextRequest()),
Promise.resolve() // 創(chuàng)建一個初始Promise以串聯(lián)數(shù)組中的Promise。
);
2.在新Promise作用域外改變狀態(tài)
假設(shè)你有一些頁面上的函數(shù)需要在使用前收集用戶信息,那么你會如何實現(xiàn)呢?一種方法是在點擊某個功能前彈出信息收集對話框。
不同層次的前端開發(fā)人員有不同的實現(xiàn)思路:
- 初級前端:我會創(chuàng)建一個模態(tài)框,然后復(fù)制粘貼到其他頁面以提高效率!
- 中級前端:你的方法不利于維護。我們應(yīng)該將這個組件單獨封裝,并在需要的頁面中導(dǎo)入使用!
- 高級前端:封裝該封裝的!寫在一個所有頁面都能調(diào)用的地方,不是更好嗎?
讓我們看看高級前端是如何實現(xiàn)的。以Vue3為例,看下面的示例。
<!-- App.vue -->
<template>
<!-- 以下是模態(tài)組件。 -->
<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;
// 將信息收集函數(shù)傳遞給以下內(nèi)容。
provide('getInfoByModal', () => {
visible.value = true;
return new Promise((resolve, reject) => {
// 將兩個函數(shù)賦值給外部,突破Promise作用域。
resolveFn = resolve;
rejectFn = reject;
});
});
const handleConfirm = () => {
resolveFn && resolveFn(info);
};
const handleCancel = () => {
rejectFn && rejectFn(new Error('用戶已取消。'));
};
</script>
接下來,直接調(diào)用 getInfoByModal 即可使用模態(tài)框,并輕松獲取用戶填寫的數(shù)據(jù)。
<template>
<button @click="handleClick">填寫信息</button>
</template>
<script setup>
import { inject } from 'vue';
const getInfoByModal = inject('getInfoByModal');
const handleClick = async () => {
// 調(diào)用后,會顯示模態(tài)框。當(dāng)用戶點擊“確認”時,Promise會變?yōu)閒ulfilled狀態(tài),從而獲取用戶信息。
const info = await getInfoByModal();
await api.submitInfo(info);
}
</script>
這也是許多UI組件庫中封裝常用組件的方法。
3.async/await的替代用法
許多人只知道在調(diào)用 async function 時使用 await 接收返回值,但他們不知道async函數(shù)實際上是返回Promise的函數(shù)。例如,以下兩個函數(shù)是等價的:
const fn1 = async () => 1;
const fn2 = () => Promise.resolve(1);
fn1(); // 同樣返回一個值為1的Promise對象。
在大多數(shù)情況下,await 后面跟著的是一個Promise對象,并等待其變?yōu)?nbsp;fulfilled 狀態(tài)。因此,下面的函數(shù) fn1 等待也是等價的:
await fn1();
const promiseInst = fn1();
await promiseInst;
然而,await 有一個不為人知的秘密。當(dāng)它后面跟的是非Promise對象的值時,它會使用 Promise 對象包裝該值。因此,await 后的代碼總是異步執(zhí)行的。例如:
Promise.resolve().then(() => {
console.log(1);
});
await 2;
console.log(2);
// 輸出順序:1 2
等價于
Promise.resolve().then(() => {
console.log(1);
});
Promise.resolve().then(() => {
console.log(2);
});
4.使用Promise實現(xiàn)請求共享
當(dāng)一個請求已經(jīng)發(fā)送但尚未響應(yīng)時,再次發(fā)出相同請求會導(dǎo)致浪費請求。此時,我們可以與第二個請求共享第一個請求的響應(yīng)。
request('GET', '/test-api').then(response1 => {
// ...
});
request('GET', '/test-api').then(response2 => {
// ...
});
以上兩個請求只會發(fā)送一次,并同時接收相同的響應(yīng)值。
那么,請求共享有哪些場景呢?我認為有三種:
- 當(dāng)一個頁面同時渲染多個內(nèi)部組件獲取數(shù)據(jù)時;
- 提交按鈕未禁用,用戶連續(xù)多次點擊提交按鈕;
- 在預(yù)加載數(shù)據(jù)的情況下,進入預(yù)加載頁面前完成預(yù)加載;
這也是Alova的高級功能之一。實現(xiàn)請求共享需要使用 Promise 緩存功能。也就是說,一個Promise對象可以通過多次await調(diào)用獲取數(shù)據(jù)。簡單的實現(xiàn)思路如下:
const pendingPromises = {};
function request(type, url, data) {
// 使用請求信息作為唯一請求鍵來緩存正在請求的Promise對象。
// 具有相同鍵的請求將重用該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;
}
5.如果同時調(diào)用resolve和reject會發(fā)生什么?
大家都知道,Promise有三種狀態(tài):pending、fulfilled 和 rejected。但在下面的例子中,Promise 的最終狀態(tài)是什么?
const promise = new Promise((resolve, reject) => {
resolve();
reject();
});
正確答案是 fulfilled 狀態(tài)。我們只需記住,一旦Promise從pending狀態(tài)轉(zhuǎn)變?yōu)槠渌麪顟B(tài),就不能再改變。因此,在這個例子中,調(diào)用 resolve()后,即使調(diào)用 reject(),狀態(tài)也不會再改變。
6.徹底弄清then/catch/finally的返回值
總結(jié)一句話,上述三個函數(shù)都會返回一個新的 Promise包裝對象,包裝的值是執(zhí)行回調(diào)函數(shù)的返回值。如果回調(diào)函數(shù)拋出錯誤,它將包裝一個處于 rejected 狀態(tài)的 Promise。這不太容易理解,我們來看一個例子:
你可以將它們一個一個復(fù)制并在瀏覽器控制臺中運行,以更好地理解。
// then函數(shù)
Promise.resolve().then(() => 1); // return new Promise(resolve => resolve(1))
Promise.resolve().then(() => Promise.resolve(2)); // return new Promise(resolve => resolve(Promise.resolve(2)))
Promise.resolve().then(() => {
throw new Error('abc')
}); // return new Promise(resolve => resolve(Promise.reject(new Error('abc'))))
Promise.reject().then(() => 1, () => 2); // return new Promise(resolve => resolve(2))
// catch函數(shù)
Promise.reject().catch(() => 3); // return new Promise(resolve => resolve(3))
Promise.resolve().catch(() => 4); // 返回值是一個新的Promise,解析為調(diào)用catch的Promise對象。
// 當(dāng)finally函數(shù)的返回值不是Promise時,返回finally函數(shù)之前的Promise對象。
Promise.resolve().finally(() => {}); // return Promise.resolve()
Promise.reject().finally(() => {}); // return Promise.reject()
// 當(dāng)finally函數(shù)的返回值是Promise時,等待返回的Promise解析后再返回finally函數(shù)之前的Promise對象。
Promise.resolve(5).finally(() => new Promise(res => {
setTimeout(res, 1000);
})); // 返回一個處于pending狀態(tài)的Promise,1秒后解析為5。
Promise.reject(6).finally(() => new Promise(res => {
setTimeout(res, 1000);
})); // 返回一個處于pending狀態(tài)的Promise,1秒后拋出數(shù)字6。
7.then函數(shù)的第二個回調(diào)與catch回調(diào)有何不同?
Promise中的 then 函數(shù)的第二個回調(diào)和 catch 函數(shù)在請求失敗時都會被觸發(fā)。乍一看,它們似乎沒有太大區(qū)別,但實際上,前者無法捕捉當(dāng)前 then 函數(shù)第一個回調(diào)函數(shù)中拋出的錯誤,而 catch 函數(shù)可以。
Promise.resolve().then(
() => {
throw new Error('成功回調(diào)中的錯誤');
},
() => {
// 不會執(zhí)行
}
).catch(reason => {
console.log(reason.message); // 打印“成功回調(diào)中的錯誤”
});
其原理如前所述,catch 函數(shù)是在 then 函數(shù)返回的 Promise 處于 rejected 狀態(tài)時調(diào)用的,自然可以捕捉到其錯誤。
8.實現(xiàn)Koa2洋蔥模型中的Promise
Koa2框架引入了洋蔥模型,使你的請求像剝洋蔥一樣逐層處理,按相反順序進入和退出層,從而實現(xiàn)請求的統(tǒng)一前后處理。
圖片
我們看看一個簡單的Koa2洋蔥模型:
const app = new Koa();
app.use(async (ctx, next) => {
console.log('a-start');
await next();
console.log('a-end');
});
app.use(async (ctx, next) => {
console.log('b-start');
await next();
console.log('b-end');
});
app.listen(3000);
上面的輸出是 a-start -> b-start -> b-end -> a-end。這種神奇的輸出順序是如何實現(xiàn)的呢?我用了大約20行代碼實現(xiàn)了這個簡單的實現(xiàn),巧合的是,它與Koa相似。
接下來,讓我們進一步分析。
保存中間件函數(shù),然后在 listen 函數(shù)中接收到請求時調(diào)用洋蔥模型的執(zhí)行。
function action(koaInstance, ctx) {
// ...
}
class Koa {
middlewares = [];
use(mid) {
this.middlewares.push(mid);
}
listen(port) {
// 模擬接收請求的偽代碼
http.on('request', ctx => {
action(this, ctx);
});
}
}
接收到請求后,從第一個中間件開始按順序執(zhí)行前置邏輯,調(diào)用 next。
// 開始中間件調(diào)用。
function action(koaInstance, ctx) {
let nextMiddlewareIndex = 1; // 標(biāo)識下一個執(zhí)行的中間件索引
// 定義next函數(shù)。
function next() {
// 在剝洋蔥之前,調(diào)用next會調(diào)用下一個中間件函數(shù)。
const nextMiddleware = middlewares[nextMiddlewareIndex];
if (nextMiddleware) {
nextMiddlewareIndex++;
nextMiddleware(ctx, next);
}
}
// 從第一個中間件函數(shù)開始執(zhí)行,并傳入ctx和next函數(shù)。
middlewares[0](ctx, next);
}
處理“next”之后的后置邏輯
function action(koaInstance, ctx) {
let nextMiddlewareIndex = 1;
function next() {
const nextMiddleware = middlewares[nextMiddlewareIndex];
if (nextMiddleware) {
nextMiddlewareIndex++;
// 這里還添加了一個return,使中間件函數(shù)的執(zhí)行通過Promise從后向前連接(建議反復(fù)理解這個return)。
return Promise.resolve(nextMiddleware(ctx, next));
} else {
// 在最后一個中間件的前置邏輯執(zhí)行完后,返回的fulfilled Promise將開始執(zhí)行next之后的后置邏輯。
return Promise.resolve();
}
}
middlewares[0](ctx, next);
}