前端 ES6 之 Promise 實踐應用與控制反轉
Promise 主要是爲解決程序異步處理而生的,在現在的前端應用中無處不在,已然成爲前端開發中最重要的技能點之一。它不僅解決了以前回調函數地獄嵌套的痛點,更重要的是它提供了更完整、更強大的異步解決方案。
同時 Promise 也是前端面試中必不可少的考察點,考察內容可深可淺,因此熟練掌握它是每個前端開發者的必備能力。
Promise 相對於 callback 模式的優勢,網上的介紹文章已經多如牛毛,本文我將不再重點贅述。本文我主要會在介紹 Promise 的基礎使用上,重點介紹其典型的場景應用,以及一些重難點場景分析,主要目的是提高對 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
Promise 異常處理
- 方式一:通過 then() 的第 2 個參數
const myPromise = new Promise(...);
myPromise.then(successCallback, errorCallback);
這種方式能捕獲到 promise 主體裏面的異常,並執行 errorCallback。但是如果 Promise 主體裏面沒有異常,然後進入到 successCallback 裏面發生了異常,此時將不會進入到 errorCallback。因此我們經常使用下面的方式二來處理異常。
- 方式二:通過 catch() (常用方案)
const myPromise = new Promise(...);
myPromise.then(successCallback).catch(errorCallback);
這樣不管是 Promise 主體,還是 successCallback 裏面的出了異常,都會進入到 errorCallback。這裏需要注意,按這種鏈式寫法才正確,如果按下面的寫法將會和方式一類似,不能按預期捕獲,具體原因在後面的鏈式調用裏面說明。
const myPromise = new Promise(...);
myPromise.then(successCallback);
myPromise.catch(errorCallback);
- 方式三:try...catch
try catch 是傳統的異常捕獲方式,這裏只能捕獲同步代碼的異常,並不能捕獲異步異常,因此無法對 Promise 進行完整的異常捕獲。
鏈式調用
熟悉 JQuery 的同學應該很瞭解鏈式調用,就是在調用了對象的一個方法後,此方法又返回了這個對象,從而可以繼續在後面調用對象的方法。Promise 的鏈式調用,每次調用後,會返回一個新的 Promise 實例對象,從而可以繼續 then() 或者其他 API 調用,如上面的方式二異常處理中的 catch 就屬於鏈式調用。
const myPromise = new Promise((resolve) => {
resolve(1)
}).then((data) => {
return data + 1;
})).then((data) => {
console.log(data)
};
// 輸出 2
這裏需要注意的是,每次 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
我再對一些常用 API 進行一下簡單說明和介紹,Promise API 和大部分類一樣,分爲實例 API 或原型方法(即 new 出來的對象上的方法),和靜態 API 或類方法(即直接通過類名調用,不需要 new)。注意實例 API 都是可以通過鏈式調用的。
實例 API(原型方法)
- then()
Promise 主體任務和在此之前的鏈式調用裏的回調任務都成功的時候(即前面通過 resolve 標註狀態後),進入本次 then() 回調。
- catch()
Promise 主體任務和在此之前的鏈式調用裏的出現了異常,並且在此之前未被捕獲的時候(即前面通過 reject 標註狀態或者出現 JS 原生報錯沒處理的時候),進入本次 catch() 回調。
- finally()
無論前面出現成功還是失敗,最終都會執行這個方法(如果添加過)。比如某個任務無論成功還是失敗,我們都希望能告訴用戶任務已經執行結束了,就可以使用 finally()。
靜態 API(類方法)
- Promise.resolve()
返回一個成功狀態的 Promise 實例,一般常用於構建微任務,比如有個耗時操作,我們不希望阻塞主程序,就把它放到微任務去,如下輸出 1、3、2,即 console.log(2) 將放到最後微任務去執行:
console.log(1);
Promise.resolve().then(() => {
console.log(2); // 作爲微任務輸出 2
})
console.log(3);
- Promise.reject()
這個與 Promise.resolve 使用類似,返回一個失敗狀態的 Promise 實例。
- Promise.all()
此方法接收一個數組爲參數(準確說是可迭代參數),數組裏面每一項都是一個單獨的 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.race()
與 Promise.all() 類似,不過區別是 Promise.race 只要傳入的 Promise 對象,有一個狀態變化了,就會立即結束,而不會等待其他 Promise 對象返回。所以一般用於競速的場景。
接下來,來看看 Promise 具體的使用場景。
Promise 最佳實踐介紹
Promise 的 API 不多,使用也不復雜,簡單場景一看就明白,不過對於一些複雜的代碼模塊,不夠熟悉的同學就會感覺比較繞。比如這些實際應用中的經驗。
異步 Promise 化的兩個關鍵
實際應用中,我們儘量將所有異步操作進行 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
實際使用中,我們一般通過 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 很多人都會用,但要注意幾個非常重要的點。
-
await 同一行後面的內容對應 Promise 主體內容,即同步執行的
-
await 下一行的內容對應 then() 裏面的內容,是異步執行的
-
await 同一行後面應該跟着一個 Promise 對象,如果不是,需要轉換(如果是常量會自動轉換)
-
async 函數的返回值還是一個 Promise 對象
比如下面寫法就是不正確的:
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 封裝的後臺請求,去後臺請求數據。同時初始化緩存類並調用 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 的基本運用後,我們再來深入點理解。Promise 和 callback 還有個本質區別,就是控制權反轉。
callback 模式下,回調函數是由業務層傳遞給封裝層的,封裝層在任務結束時執行了回調函數。
而 Promise 模式下,業務層並沒有把回調函數直接傳遞給封裝層 (Promise 對象內部),封裝層在任務結束時也不知道要做什麼回調,只是通過 resolve 或 reject 來通知到 業務層,從而由業務層自己在 then() 或 reject() 裏面去控制自己的回調執行。
這裏可能理解起來有點繞,換種等效的簡單理解:我們知道函數一般是分定義 + 調用步驟的,先定義,後調用。誰調用了函數,就表示誰在控制這個函數的執行。
那麼我們來看 callback 模式下,業務層將回調函數的定義傳給了封裝層,封裝層在內部完成了回調函數的調用執行,業務層並沒有調用回調函數,甚至業務層都看不到其調用代碼,所以回調函數的執行控制權在封裝層。
而 Promise 模式下,回調函數的調用執行是在 then() 裏面完成的,是由業務層發起的,業務層不僅能看到回調函數的調用代碼,也能修改,因此回調函數的控制權在業務層。
手動實現 Promise 類的思路
現在我們已經熟悉了 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');
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/36he_7HHuYNKyKS53B8nFQ