前端 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 異常處理

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 就屬於鏈式調用。

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(原型方法)

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 最佳實踐介紹

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 很多人都會用,但要注意幾個非常重要的點。

比如下面寫法就是不正確的:

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