淺談幾個前端非同步解決方案

binbinsilk發表於2018-06-16

      Javascript語言的執行環境是單執行緒。即一次只能完成一個任務。若有多個任務則需排隊逐個執行——前一個任務完成,再執行後一個任務。

      這種執行模式實現簡單,執行環境相對單純。但隨著前端業務日漸複雜,事務和請求等日漸增多,這種單執行緒執行方式在複雜的業務下勢必效率低下,只要有一個任務耗時很長,後面的任務都必須排隊等著,會拖延整個程式的執行。常見的瀏覽器無響應(假死),往往就是因為某一段Javascript程式碼長時間執行(比如死迴圈),導致整個頁面卡在這個地方,其他任務無法執行。

       為避免和解決這種問題,JS語言將任務執行模式分為非同步和同步。同步模式”就是上一段的模式,後一個任務等待前一個任務結束,然後再執行,程式的執行順序與任務的排列順序是一致的、同步的;”非同步模式”則完全不同,每一個任務有一個或多個回撥函式(callback),前一個任務結束後,不是執行後一個任務,而是執行回撥函式,後一個任務則是不等前一個任務結束就執行,所以程式的執行順序與任務的排列順序是不一致的、非同步的。

    “非同步模式”非常重要。在瀏覽器端,耗時很長的操作都應該非同步執行,避免瀏覽器失去響應,最好的例子就是Ajax操作。在伺服器端,”非同步模式”甚至是唯一的模式,因為執行環境是單執行緒的,如果允許同步執行所有http請求,伺服器效能會急劇下降,很快就會失去響應。

      1.回撥函式 

      非同步程式設計最基本方法。

      首先需要宣告,回撥函式只是一種實現,並不是非同步模式特有的實現。回撥函式同樣可以運用到同步(阻塞)的場景下以及其他一些場景。

      回撥函式的英文定義:A callback is a function that is passed as an argument to another function and is executed after its parent function has completed。

      字面上的理解,回撥函式就是一個引數,將這個函式作為引數傳到另一個函式裡面,當那個函式執行完之後,再執行傳進去的這個函式。這個過程就叫做回撥。

      在JavaScript中,回撥函式具體的定義為: 函式A作為引數(函式引用)傳遞到另一個函式B中,並且這個函式B執行函式A。我們就說函式A叫做回撥函式。如果沒有名稱(函式表示式),就叫做匿名回撥函式。

      用一個通俗的生活例子比喻一下就是:約會結束後你送你女朋友回家,離別時,你肯定會說:“到家了給我發條資訊,我很擔心你。” 然後你女朋友回家以後還真給你發了條資訊。其實這就是一個回撥的過程。你留了個引數函式(要求女朋友給你發條資訊)給你女朋友,然後你女朋友回家,回家的動作是主函式。她必須先回到家以後,主函式執行完了,再執行傳進去的函式,然後你就收到一條資訊了。

     假定有兩個函式f1和f2,後者等待前者的執行結果。

     

f1();
f2(); 
複製程式碼

     若f1是一個很耗時的任務,可以考慮改寫f1,把f2寫成f1的回撥函式。

function f1(callback){setTimeout(function () {// f1的任務程式碼callback();}, 1000);}複製程式碼

     執行程式碼就變成下面這樣:

f1(f2);
複製程式碼

     採用這種方式,我們把同步操作變成了非同步操作,f1不會堵塞程式執行,相當於先執行程式的主要邏輯,將耗時的操作推遲執行。

    另一個例子:

//定義主函式,回撥函式作為引數
function A(callback) {
    callback();  
    console.log('我是主函式');      
}

//定義回撥函式
function B(){
    setTimeout("console.log('我是回撥函式')", 3000);//模仿耗時操作  
}

//呼叫主函式,將函式B傳進去
A(B);

//輸出結果
我是主函式
我是回撥函式

複製程式碼

      上面的程式碼中,我們先定義了主函式和回撥函式,然後再去呼叫主函式,將回撥函式傳進去。

  定義主函式的時候,我們讓程式碼先去執行callback()回撥函式,但輸出結果卻是後輸出回撥函式的內容。這就說明了主函式不用等待回撥函式執行完,可以接著執行自己的程式碼。所以一般回撥函式都用在耗時操作上面。比如ajax請求,比如處理檔案等。

    再來一個更俗的例子:

<strong>問:你有事去隔壁寢室找同學,發現人不在,你怎麼辦呢?</strong><strong>方法1</strong>,每隔幾分鐘再去趟隔壁寢室,看人在不<strong>方法2</strong>,拜託與他同寢室的人,看到他回來時叫一下你 前者是輪詢,後者是回撥。 那你說,我直接在隔壁寢室等到同學回來可以嗎? 可以啊,只不過這樣原本你可以省下時間做其他事,現在必須浪費在等待上了。把原來的非阻塞的非同步呼叫變成了阻塞的同步呼叫。 JavaScript的回撥是在非同步呼叫場景下使用的,使用回撥效能好於輪詢。複製程式碼

   對於回撥函式,一般在同步情境下是最後執行的,而在非同步情境下有可能不執行,因為事件沒有被觸發或者條件不滿足,所以請忽略上上個例子中的小問題,並不是一定回撥函式就要執行。

   同時補充回撥函式應用場合和優缺點:

  • 資源載入:動態載入js檔案後執行回撥,載入iframe後執行回撥,ajax操作回撥,圖片載入完成執行回撥,AJAX等等。
  • DOM事件及Node.js事件基於回撥機制(Node.js回撥可能會出現多層回撥巢狀的問題)。
  • setTimeout的延遲時間為0,這個hack經常被用到,settimeout呼叫的函式其實就是一個callback的體現。
  • 鏈式呼叫:鏈式呼叫的時候,在賦值器(setter)方法中(或者本身沒有返回值的方法中)很容易實現鏈式呼叫,而取值器(getter)相對來說不好實現鏈式呼叫,因為你需要取值器返回你需要的資料而不是this指標,如果要實現鏈式方法,可以用回撥函式來實現。
  • setTimeout、setInterval的函式呼叫得到其返回值。由於兩個函式都是非同步的,即:他們的呼叫時序和程式的主流程是相對獨立的,所以沒有辦法在主體裡面等待它們的返回值,它們被開啟的時候程式也不會停下來等待,否則也就失去了setTimeout及setInterval的意義了,所以用return已經沒有意義,只能使用callback。callback的意義在於將timer執行的結果通知給代理函式進行及時處理。

       回撥函式這種方式的優點是比較容易理解,可以繫結多個事件,每個事件可以指定多個回撥函式,而且可以”去耦合“,有利於實現模組化。缺點是整個程式都要變成事件驅動型,執行流程會變得很不清晰。

    2.Promise物件

      隨著ES6標準的釋出,處理非同步資料流的解決方案又有了新的變化。promise就是這其中的一個。我們都知道,在傳統的ajax請求中,當非同步請求之間的資料存在依賴關係的時候,就可能產生很難看的多層回撥,這樣會使程式碼邏輯很容易造成混亂不便於閱讀和後期維護,俗稱”回撥地獄”(callback hell)。另一方面,往往錯誤處理的程式碼和正常的業務程式碼耦合在一起,造成程式碼會極其難看。為了讓程式設計更美好,我們就需要引入promise來降低非同步程式設計的複雜性。

      所以某種程度上說,promise是對上面說到的回撥函式處理非同步程式設計的一個進階方案。首先Promise是CommandJS提出的一種規範,其目的是為非同步程式設計提供統一介面。

      簡單說,Promise的思想是,每一個非同步任務返回一個Promise物件,該物件有一個then方法,允許指定回撥函式。形如這種形式:

f1().then(f2);
複製程式碼

     對於函式f1,使用Jquery實現以下改寫:

function f1(){var dfd = $.Deferred();setTimeout(function () {// f1的任務程式碼dfd.resolve();}, 500);return dfd.promise;}複製程式碼

      這樣寫的優點在於,回撥函式變成了鏈式寫法,程式的流程可以看得很清楚,而且有一整套的配套方法,可以實現許多強大的功能。這也就是Promise處理非同步程式設計的其中的一個方便之處。

      再舉一個制定多個回撥函式的例子,其形式為:

f1().then(f2).then(f3);
複製程式碼

      當指定發生錯誤時的回撥函式,其形式為:

f1().then(f2).fail(f3);
複製程式碼

      在此補充一點,promise中,如果一個任務已經完成,再新增回撥函式,該回撥函式會立即執行。所以,你不用擔心是否錯過了某個事件或訊號。這種方法的缺點就是編寫和理解,都相對比較難。

      展開談論一下Promise:Promise實際上就是一個特殊的Javascript物件,反映了”非同步操作的最終值”。”Promise”直譯過來有預期的意思,因此,它也代表了某種承諾,即無論你非同步操作成功與否,這個物件最終都會返回一個值給你。

      程式碼示例

const promise = new Promise((resolve, reject) => {
  $.ajax('https://github.com/users', (value) =>  {
    resolve(value);
  }).fail((err) => {
    reject(err);
  });
});
promise.then((value) => {
  console.log(value);
},(err) => {
  console.log(err);
});
//也可以採取下面這種寫法
promise.then(value => console.log(value)).catch(err => console.log(err));
複製程式碼

上面的例子,會在Ajax請求成功後呼叫resolve回撥函式來處理結果,如果請求失敗則呼叫reject回撥函式來處理錯誤。Promise物件內部包含三種狀態,分別為pending,fulfilled和rejected。這三種狀態可以類比於我們平常在ajax資料請求過程的pending,success,error。一開始請求發出後,狀態是Pending,表示正在等待處理完畢,這個狀態是中間狀態而且是單向不可逆的。成功獲得值後狀態就變為fulfilled,然後將成功獲取到的值儲存起來,後續可以通過呼叫then方法傳入的回撥函式來進一步處理。而如果失敗了的話,狀態變為rejected,錯誤可以選擇丟擲(throw)或者呼叫reject方法來處理。

Promise基本語法如下

  • Promise例項必須實現then這個方法

  • then()必須可以接收兩個函式作為引數

  • then()返回的必須是一個Promise例項

    eg
    <script src="https://cdn.bootcss.com/bluebird/3.5.1/bluebird.min.js"></script>//如果低版本瀏覽器不支援Promise,通過cdn這種方式
          <script type="text/javascript">
            function loadImg(src) {
                var promise = new Promise(function (resolve, reject) {
                    var img = document.createElement('img')
                    img.onload = function () {
                        resolve(img)
                    }
                    img.onerror = function () {
                        reject('圖片載入失敗')
                    }
                    img.src = src
                })
                return promise
            }
            var src = 'https://www.imooc.com/static/img/index/logo_new.png'
            var result = loadImg(src)
            result.then(function (img) {
                console.log(1, img.width)
                return img
            }, function () {
                console.log('error 1')
            }).then(function (img) {
                console.log(2, img.height)
            })
         </script>
    作者:浪裡行舟
    連結:https://juejin.im/post/5b1962616fb9a01e7c2783a8
    來源:掘金
    著作權歸作者所有。商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。
    複製程式碼

       Promise還可以做更多的事情,比如,有若干個非同步任務,需要先做任務1,如果成功後再做任務2,任何任務失敗則不再繼續並執行錯誤處理函式。要序列執行這樣的非同步任務,不用Promise需要寫一層一層的巢狀程式碼。

       有了Promise,我們只需要簡單地寫job1.then(job2).then(job3).catch(handleError); 其中job1、job2和job3都是Promise物件。

       比如我們想實現第一個圖片載入完成後,再載入第二個圖片,如果其中有一個執行失敗,就執行錯誤函式:

var src1 = 'https://www.imooc.com/static/img/index/logo_new.png'
        var result1 = loadImg(src1) //result1是Promise物件
        var src2 = 'https://img1.mukewang.com/545862fe00017c2602200220-100-100.jpg'
        var result2 = loadImg(src2) //result2是Promise物件
        result1.then(function (img1) {
            console.log('第一個圖片載入完成', img1.width)
            return result2  // 鏈式操作
        }).then(function (img2) {
            console.log('第二個圖片載入完成', img2.width)
        }).catch(function (ex) {
            console.log(ex)
        })複製程式碼

      這裡需注意的是:then 方法可以被同一個 promise 呼叫多次,then 方法必須返回一個 promise 物件。上例中result1.then如果沒有明文返回Promise例項,就預設為本身Promise例項即result1,result1.then返回了result2例項,後面再執行.then實際上執行的result2.then。

 Promise的常用方法

      除了序列執行若干非同步任務外,Promise還可以並行執行非同步任務

      試想一個頁面聊天系統,我們需要從兩個不同的URL分別獲得使用者的個人資訊和好友列表,這兩個任務是可以並行執行的,用Promise.all()實現如下:

var p1 = new Promise(function (resolve, reject) {
    setTimeout(resolve, 500, 'P1');
});
var p2 = new Promise(function (resolve, reject) {
    setTimeout(resolve, 600, 'P2');
});
// 同時執行p1和p2,並在它們都完成後執行then:
Promise.all([p1, p2]).then(function (results) {
    console.log(results); // 獲得一個Array: ['P1', 'P2']
});複製程式碼

       有些時候,多個非同步任務是為了容錯。比如,同時向兩個URL讀取使用者的個人資訊,只需要獲得先返回的結果即可。這種情況下,用Promise.race()實現:

var p1 = new Promise(function (resolve, reject) {
    setTimeout(resolve, 500, 'P1');
});
var p2 = new Promise(function (resolve, reject) {
    setTimeout(resolve, 600, 'P2');
});
Promise.race([p1, p2]).then(function (result) {
    console.log(result); // 'P1'
});複製程式碼

由於p1執行較快,Promise的then()將獲得結果'P1'。p2仍在繼續執行,但執行結果將被丟棄。

總結:Promise.all接受一個promise物件的陣列,待全部完成之後,統一執行success;

Promise.race接受一個包含多個promise物件的陣列,只要有一個完成,就執行success。

對上面的例子做下修改,加深對這兩者的理解:

var src1 = 'https://www.imooc.com/static/img/index/logo_new.png'
     var result1 = loadImg(src1)
     var src2 = 'https://img1.mukewang.com/545862fe00017c2602200220-100-100.jpg'
     var result2 = loadImg(src2)
     Promise.all([result1, result2]).then(function (datas) {
         console.log('all', datas[0])//<img src="https://www.imooc.com/static/img/index/logo_new.png">
         console.log('all', datas[1])//<img src="https://img1.mukewang.com/545862fe00017c2602200220-100-100.jpg">
     })
     Promise.race([result1, result2]).then(function (data) {
         console.log('race', data)//<img src="https://img1.mukewang.com/545862fe00017c2602200220-100-100.jpg">
     })複製程式碼

如果我們組合使用Promise,就可以把很多非同步任務以並行和序列的方式組合起來執行。

      Promise.reject(reason): 返回一個新的promise物件,用reason值直接將狀態變為rejected

const promise2 = new Promise((resolve, reject) => {
  reject('Failed');
});

const promise2 = Promise.reject('Failed');
複製程式碼

上面兩種寫法是等價的。

        Promise.resolve(value): 返回一個新的promise物件,這個promise物件是被resolved的。與reject類似,下面這兩種寫法也是等價的。

const promise2 = new Promise((resolve, reject) => {
  resolve('Success');
});

const promise2 = Promise.resolve('Success');
複製程式碼

then 利用這個方法訪問值或者錯誤原因。其回撥函式就是用來處理非同步處理返回值的。

catch 利用這個方法捕獲錯誤,並處理。

3.Async/Await簡介與用法

簡介

  • async/await是寫非同步程式碼的新方式,以前的方法有回撥函式Promise
  • async/await是基於Promise實現的,它不能用於普通的回撥函式。
  • async/await與Promise一樣,是非阻塞的。
  • async/await使得非同步程式碼看起來像同步程式碼,這正是它的魔力所在。

語法

用promise示例和asyn/await示例兩段程式碼演示:

promise

const makeRequest = () =>
  getJSON()
    .then(data => {
      console.log(data)
      return "done"
    })

makeRequest()
複製程式碼

async/await

const makeRequest = async () => {
  console.log(await getJSON())
  return "done"
}

makeRequest()
複製程式碼

它們有一些細微不同:

  • 函式前面多了一個aync關鍵字。await關鍵字只能用在aync定義的函式內。async函式會隱式地返回一個promise,該promise的reosolve值就是函式return的值。(示例中reosolve值就是字串”done”)

  • 第1點暗示我們不能在最外層程式碼中使用await,因為不在async函式內。

// 不能在最外層程式碼中使用await
await makeRequest()

// 這是會出事情的 
makeRequest().then((result) => {
  // 程式碼
})
複製程式碼

await getJSON()表示console.log會等到getJSON的promise成功reosolve之後再執行。

相對於promise,async/await的優勢有哪些

1.簡潔

       由示例可知,使用Async/Await明顯節約了不少程式碼。我們不需要寫.then,不需要寫匿名函式處理Promise的resolve值,也不需要定義多餘的data變數,還避免了巢狀程式碼。這些小的優點會迅速累計起來,這在之後的程式碼示例中會更加明顯。

2.錯誤處理

        Async/Await讓try/catch可以同時處理同步和非同步錯誤。在下面的promise示例中,try/catch不能處理JSON.parse的錯誤,因為它在Promise中。我們需要使用.catch,這樣錯誤處理程式碼非常冗餘。並且,在我們的實際生產程式碼會更加複雜。

const makeRequest = () => {
  try {
    getJSON()
      .then(result => {
        // JSON.parse可能會出錯
        const data = JSON.parse(result)
        console.log(data)
      })
      // 取消註釋,處理非同步程式碼的錯誤
      // .catch((err) => {
      //   console.log(err)
      // })
  } catch (err) {
    console.log(err)
  }
}
複製程式碼

使用aync/await的話,catch能處理JSON.parse錯誤

const makeRequest = async () => {
  try {
    // this parse may fail
    const data = JSON.parse(await getJSON())
    console.log(data)
  } catch (err) {
    console.log(err)
  }
}複製程式碼

3.條件語句

下面示例中,需要獲取資料,然後根據返回資料決定是直接返回,還是繼續獲取更多的資料。

const makeRequest = () => {
  return getJSON()
    .then(data => {
      if (data.needsAnotherRequest) {
        return makeAnotherRequest(data)
          .then(moreData => {
            console.log(moreData)
            return moreData
          })
      } else {
        console.log(data)
        return data
      }
    })
}
複製程式碼

這些程式碼看著就頭痛。巢狀(6層),括號,return語句很容易讓人感到迷茫,而它們只是需要將最終結果傳遞到最外層的Promise。

上面的程式碼使用async/await編寫可以大大地提高可讀性:

const makeRequest = async () => {
  const data = await getJSON()
  if (data.needsAnotherRequest) {
    const moreData = await makeAnotherRequest(data);
    console.log(moreData)
    return moreData
  } else {
    console.log(data)
    return data    
  }
}
複製程式碼

4. 中間值

你很可能遇到過這樣的場景,呼叫promise1,使用promise1返回的結果去呼叫promise2,然後使用兩者的結果去呼叫promise3。你的程式碼很可能是這樣的:

const makeRequest = () => {
  return promise1()
    .then(value1 => {
      return promise2(value1)
        .then(value2 => {        
          return promise3(value1, value2)
        })
    })
}
複製程式碼

如果promise3不需要value1,可以很簡單地將promise巢狀鋪平。如果你忍受不了巢狀,你可以將value 1 & 2 放進Promise.all來避免深層巢狀:

const makeRequest = () => {
  return promise1()
    .then(value1 => {
      return Promise.all([value1, promise2(value1)])
    })
    .then(([value1, value2]) => {      
      return promise3(value1, value2)
    })
}
複製程式碼

這種方法為了可讀性犧牲了語義。除了避免巢狀,並沒有其他理由將value1和value2放在一個陣列中。

使用async/await的話,程式碼會變得異常簡單和直觀。

const makeRequest = async () => {
  const value1 = await promise1()
  const value2 = await promise2(value1)
  return promise3(value1, value2)
}
複製程式碼

5. 錯誤棧

下面示例中呼叫了多個Promise,假設Promise鏈中某個地方丟擲了一個錯誤:

const makeRequest = () => {
  return callAPromise()
    .then(() => callAPromise())
    .then(() => callAPromise())
    .then(() => callAPromise())
    .then(() => callAPromise())
    .then(() => {
      throw new Error("oops");
    })
}

makeRequest()
  .catch(err => {
    console.log(err);
    // output
    // Error: oops at callAPromise.then.then.then.then.then (index.js:8:13)
  })
複製程式碼

Promise鏈中返回的錯誤棧沒有給出錯誤發生位置的線索。更糟糕的是,它會誤導我們;錯誤棧中唯一的函式名為callAPromise,然而它和錯誤沒有關係。(檔名和行號還是有用的)。

然而,async/await中的錯誤棧會指向錯誤所在的函式:

const makeRequest = async () => {
  await callAPromise()
  await callAPromise()
  await callAPromise()
  await callAPromise()
  await callAPromise()
  throw new Error("oops");
}

makeRequest()
  .catch(err => {
    console.log(err);
    // output
    // Error: oops at makeRequest (index.js:7:9)
  })
複製程式碼

在開發環境中,這一點優勢並不大。但是,當你分析生產環境的錯誤日誌時,它將非常有用。這時,知道錯誤發生在makeRequest比知道錯誤發生在then鏈中要好。

6. 除錯

最後一點,也是非常重要的一點在於,async/await能夠使得程式碼除錯更簡單。2個理由使得除錯Promise變得非常痛苦:

  • 不能在返回表示式的箭頭函式中設定斷點

const markRequest = () => {
    return callAPromise ()
        .then (() => callAPromise())
        .then (() => callAPromise())
        .then (() => callAPromise())
        .then (() => callAPromise())

}
複製程式碼

  • 如果你在.then程式碼塊中設定斷點,使用Step Over快捷鍵,偵錯程式不會跳到下一個.then,因為它只會跳過非同步程式碼。使用await/async時,你不再需要那麼多箭頭函式,這樣你就可以像除錯同步程式碼一樣跳過await語句。

    const markRequest = async () => {
        await callAPromise()
        await callAPromise()
        await callAPromise()
        await callAPromise()
        await callAPromise()
    }複製程式碼

總結

      對於常用的不同非同步程式設計處理方案,個人觀點是針對不同的業務場景可根據情況選擇合適高效的方案,各有優勢劣勢,沒必要頂一個踩一個,雖然技術不斷髮展優化,但有些技術不至於淘汰如此之快,存在即合理。


相關文章