從 Promise 來看 JavaScript 的異步處理

一.前言

在早期 JavaScriptES5 語法中,多層函數的回調嵌套是一件讓人很頭疼的事兒,行內黑話一般稱之爲回調地獄

可能有些夥計還沒遇到過此類業務場景,但是沒關係,只要在前端圈裏混,蒼天會繞過誰呢?所以爲了大家,我就舉個特別常見的業務場景:

  • 有三個接口,分別爲 URL-A, URL-B, URL-C (都是 get 請求),我們需要分別向這三個接口請求獲取數據。
  • 請求 URL-B 時需要帶上 URL-A 返回的數據,同理,請求 URL-C 時也要帶上 URL-B 返回的數據。

我們來看看用早期的 jquery ajax 會怎麼處理:

$.get('/URL-A', function(resA){
    // do Something
    $.get('/URL-B?query=' + resA,function(resB){
        // do Something
        $.get('/URL-C?query=' + resB, function(resC){
            // do Something
        })
    })
})

從上面我們可以看出,這一段代碼是很不健康的,爲什麼這麼說?有以下幾點理由:

  1. 代碼橫向發展,而不是縱向變多,就像人不長高反而長胖一般,十分不健康。
  2. 業務邏輯不夠直觀,維護困難。
  3. 業務代碼與公用代碼難以抽離,函數之間強耦合,一旦報錯很難快速定位問題所在。

當然,我們也可以用函數內 callback 的形式來改寫上面的這段代碼,使之變得更直觀些:

// 請求 URL-C 
function getURLCData(res){
     $.get('/URL-C?query=' + res, function(res){
          // do Something
    })
}

// 請求 URL-B
function getURLBData(res){
     $.get('/URL-B?query=' + res, function(res){
          // do Something
        getURLCData(res)
    })
}

// 請求 URL-A 
function getURLAData(){
    $.get('/URL-A', function(res){
       // do Something
        getURLBData(res)
    })
}

這樣我們就避免了函數的縱向發展,公共代碼與業務代碼也可以抽離,但是這種方式還不夠直觀,在複雜業務,超高併發請求下,業務代碼依舊晦澀。

所以,在 ES6 中提出了 Promise 用來解決回調嵌套的問題。

以上代碼的 Promise 改寫我們在下文再講,我們先講講何爲 Promise

二. Promise 的基本用法

對於 Promise 我們可以這麼理解,如果一個函數 Promise (數據準備好了)了,那麼我們就可以 then 乾點事情。

MDN 對其有以下描述:

Promise 對象是一個代理對象(代理一個值),被代理的值在Promise對象創建時可能是未知的。它允許你爲異步操作的成功和失敗分別綁定相應的處理方法(handlers)。 這讓異步方法可以像同步方法那樣返回值,但並不是立即返回最終執行結果,而是一個能代表未來出現的結果的promise對象

Promise 有以下四個函數可以調用:

  1. Promise.all(iterable)
    這個方法返回一個新的 promise 對象,該 promise 對象在 iterable 參數對象裏所有的 promise 對象都成功的時候纔會觸發成功,一旦有任何一個 iterable 裏面的 promise 對象失敗則立即觸發該 promise 對象的失敗。
    注意,iterabe 參數爲數組,數組裏存放 promise 對象。
  2. Promise.race(iterable)
    當iterable參數裏的任意一個子promise被成功或失敗後,父promise馬上也會用子promise的成功返回值或失敗詳情作爲參數調用父promise綁定的相應句柄,並返回該promise對象。
  3. Promise.resolve(value)
    返回一個狀態由給定value決定的Promise對象。
  4. Promise.reject(reason)
    返回一個狀態爲失敗的Promise對象,並將給定的失敗信息傳遞給對應的處理方法

我們來看個例子:

實現一個簡單的定時 promise:

function delayLogNum(){
    return new Promise((resolve, reject) => {
        setTimeout(()=> {
            console.log('success');
            resolve('ok');
        }, 3000)
    })
}

delayLogNum().then(res => {
    console.log(res)
})

結果如下所示:
在這裏插入圖片描述

三.Promise 處理串行和並行

JavaScript 中已經有同步,異步,串行,並行這些概念了,大家需分清楚其中的區別:

  • 同步異步是指是在 JavaScript 的主線程中執行(同步)還是丟到任務隊列中執行(異步)。
  • 串行並行是指在異步任務隊列中的函數是按順序一個一個執行(串行)還是所有隊列中的函數一起執行,但是必須在所有函數執行完畢後再接着執行下一步。

ES7 中新增加了 asyncawait 關鍵字:

  • async 用於定義一個返回 AsyncFunction 對象的異步函數。異步函數是指通過事件循環異步執行的函數,它會通過一個隱式的 Promise 返回其結果。
  • await 操作符用於等待一個Promise 對象。它只能在異步函數 async function 中使用。

ok,梳理完了前置知識點,我們來看看利用 Promiseasync await 怎麼處理串行。

舉一個例子:遍歷一個 Number 數組並且在每次遍歷時延時 1 秒輸出遍歷的數值。

function delay(){
    return new Promise((resolve, reject) => {
        setTimeout(resolve, 1000)
    })
}

async function eachEveryVal(item){
    await delay();
    console.log(item)
}

async function eachArr(data){
    for(var val of data){
       await eachEveryVal(val)
    }
}

eachArr([1, 2, 3, 4])

我們再舉一個例子,就拿最開始那段代碼來說,就是典型的異步串行操作,我們可以這麼來改寫它:

function getURL(url){
    return new Promise((resolve, reject) => {
        $.get(url, res => {
            resolve(res)
        })
    })
}

async getData(){
    let dataA = await getURL('URL-A');
    let dataB = await getURL('URL-B?query=' + dataA);
    let dataC = await getURL('URL-C?query=' + dataB);
};

getData();

講完了串行,我們再來講講異步並行。假設我們有以下需求:

  • 有三個接口,分別爲 URL-A, URL-B, URL-C (都是 get 請求),我們需要分別向這三個接口請求獲取數據。
  • 在三個請求都結束後,拿到他們的數據進行業務處理。

這就是一個典型的並行的業務需求,我們也可以用 promise 來實現它。

const URI_LIST = ["URL-A", "URL-B", "URL-C"];

function getURL(url){
    return new Promise((resolve, reject) => {
        $.get(url, res => {
            resolve(res)
        })
    })
}

async function getData(){
    const promises = URI_LIST.map(url => getURL(url));
    Promise.all(promises).then(res => console.log(res))  
};

getData();

我大概解釋下這段代碼:

  • getURL() 函數返回一個 promise,並在傳入 url 參數給 $.get() 調用,請求成功後調用 reslove(res) 來返回請求結果。
  • getData() 函數聲明一個 promises 來存放 getURL(url) 返回的 promise 對象,通過 URI_LIST.map() 來得到我們在基礎用法中所講的 iterable 參數對象,並將此對象傳入 Promise.all() 中,最後通過 then() 獲取結果。要注意的是,此結果是三個請求返回的數據組成的數組。

四.Promise 常見特性

有以下5個特性需要大家理解:

  1. Promise 捕獲錯誤與 try catch 等同.
  2. Promise 擁有狀態變化.
  3. Promise 方法中的回調是異步的.
  4. Promise 方法每次都返回一個新的 Promise.
  5. Promise 會存儲返回值.

下面我來一一解釋這 5 點特性:

1.Promise 捕獲錯誤與 try catch 等同

這句話的意識就是說,在 new Promise(()=>{}) 中直接去 throw err,是可以通過 Promise.catch() 方法捕捉的,這也就意味中 Promise 內部也通過 try catch 進行了異常處理。

2.Promise 擁有狀態變化

Promise 有以下三種狀態:

  • pending: 初始狀態,既不是成功,也不是失敗狀態。
  • fulfilled: 意味着操作成功完成。
  • rejected: 意味着操作失敗。

Promise.resolve()Promise.reject() 都會改變 promise 的狀態值,其中 resolve 會將此狀態值修改爲 fulfilled, 而 reject 會將此狀態值修改爲爲 rejected

特別的,一旦 Promise 的狀態值被改變,就會被固定,不再發生變化。也就是說只要你 resolve() 或者 reject() 了一次,在這之後無論你再調用幾次這兩個方法都不起效果。

3.Promise 方法中的回調是異步的

先解釋一下,Promise 方法中的回調是異步的這句話中的方法是指 Promise 中的 catch,then,finally這些方法,而不是指 new Promise() 中的 executor 函數,這個函數你可以把它理解爲一個立即執行函數。

想要真正理解 Promise 方法中的回調是異步的這句話,還沒有這麼簡單,爲什麼這麼說,因爲 setTimeout 也是異步的,如果它們兩同時存在且作用域平級,那麼誰先執行,誰後執行,它們之間的競爭關係怎麼確認?

想要了解這其中的原理,我們就需要了解一個概念:微任務(microtasks)宏任務(tasks)

我們已經知道,JavaScript 是單線程的操作,正是因爲如此,纔有了現在的同步和異步之分。在主線程中,一般是按順序執行同步任務。而其他的異步任務則會掛起,當它們有返回值後會添加到任務隊列中。等到主線程的同步任務執行完畢後,它會去任務隊列中讀取(按先進先出的原則)異步任務執行。以此形成一個反覆的過程被稱爲事件循環。借用一個掘金上的圖片,侵刪:

在這裏插入圖片描述

而在異步任務中,其實又可以細分爲宏任務和微任務。

  • 宏任務可以當成了廣義的異步隊列中的任務,嚴格按照順序壓棧和執行。比如說 整體代碼, setTimeoutsetInterval, MessageChannel(Web Worker中的管道通信)。
  • 微任務是當前宏任務執行完成後立即執行的任務。

而在整個異步流程中,JavaScript 會先進入整體代碼執行宏任務,然後再檢查是否有微任務需要執行,如果有,則需要立即執行;如果沒有則檢查隊列,開始執行下一批宏任務並檢查微任務。借用一個掘金上的圖片,侵刪:

在這裏插入圖片描述

總結一下, Promise 中的 executor 函數是處於主線程同步隊列中執行(立即執行函數),而其他的方法諸如 then, catch 等,則是異步任務隊列中的微任務,諸如 setTimeout,setInterval 等函數必須在微任務執行完畢後再開始執行。

所以,看到這兒,整個 Promise 中的函數內部在整個執行棧的執行順序和競爭關係就已經很清晰了。

4.Promise 方法每次都返回一個新的 Promise

這兒的意思很直白,意味着無論是 then,catch 亦或是 finally 都會返回 一個新的 Promise 對象。

5.Promise 會存儲返回值

一般情況下我們都會這樣來使用 Promise:

function p(flag){
    return new Promise((resolve, reject) => {
        if(flag){
            resolve('success')
        }else{
            reject('error')
        }
    })
};

p(true).then(res => console.log('res', res))
   

可以看到,我們通常會把一些參數或者函數在成功狀態下通過 resolve() 傳遞給 then() 函數來接收並作相應處理;在失敗狀態下通過 reject() 把錯誤信息傳遞給 catch() 函數來處理。

特別的,如果你在 Promise 直接返回某些參數, Pormise 也會捕捉到你返回的參數並把它包裝成 Promise 對象並傳遞給對應的接收函數。

五. Promise 面試題

5.1 請用 Pormise 實現以下流水燈,已知紅黃綠三個函數,要求紅燈3秒執行一次,黃燈2秒執行一次,綠燈1秒執行一次:

function red() {
    console.log('red');
}
function green() {
    console.log('green');
}
function yellow() {
    console.log('yellow');
}

function delay(time){
    return new Promise((resolve, reject) => {
        setTimeout(resolve,time)
    })
}

async function runTask(){
    await delay(3000);
    red();
    await delay(2000);
    green();
    await delay(1000);
    yellow();
    // 遞歸循環播放
    runTask()
}
runTask();

5.2 請用 Pormise 實現 mergePromise 函數,把傳進去的數組按順序先後執行,並且把返回的數據先後放到數組 data 中:

const timeout = ms => new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve();
    }, ms);
});

const ajax1 = () => timeout(2000).then(() => {
    console.log('1');
    return 1;
});

const ajax2 = () => timeout(1000).then(() => {
    console.log('2');
    return 2;
});

const ajax3 = () => timeout(2000).then(() => {
    console.log('3');
    return 3;
});

const mergePromise = ajaxArray => {
    // 在這裏實現你的代碼

};

mergePromise([ajax1, ajax2, ajax3]).then(data => {
    console.log('done');
    console.log(data); // data 爲 [1, 2, 3]
});

// 要求分別輸出
// 1
// 2
// 3
// done
// [1, 2, 3]

我們先分析一下題目,看到這個題目是不是就有一種很熟悉的感覺?像不像我們在上面改寫的異步並行

你的感覺沒錯,實際上這道題考查的就是讓你手寫一個簡單的 Promise.all() 函數。

所以,我們就能知道,上題中 ajaxArray 參數實際上就是一個包含多個 Promise 對象的數組,我們可以用並行遍歷的方式來處理它。

const mergePromise = ajaxArray => {
    let seq = Promise.resolve();
    let data = [];
    ajaxArray.map(func => {
        seq = seq.then(func).then(res => {
            data.push(res);
            return data;
        })
    })
    return seq;
};

六.小結

如果你看到這了這,那麼恭喜你,不管你有沒有吸收其中的內容,你至少你知道了整個 Promise 應該怎麼去學。實際上在工作中 Promise 的應用是很多的,包括我們使用的 babel 中也會有 Promise-polyfill。現在已經是 9102 年了,前端圈已經逐漸穩定下來,這意味着你我的時間已然不多,所以加油吧,夥計們。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章