1. 程式人生 > >JavaScript是如何工作的:事件循環和異步編程的崛起 + 5種使用 async/await 更好地編碼方式!

JavaScript是如何工作的:事件循環和異步編程的崛起 + 5種使用 async/await 更好地編碼方式!

www 輸入數據 實例 事件 學術 scrip 界面 永遠 oom

摘要: 深度理解JS事件循環!!!

  • 原文:JavaScript是如何工作的:事件循環和異步編程的崛起+ 5種使用 async/await 更好地編碼方式!
  • 作者:前端小智

Fundebug經授權轉載,版權歸原作者所有。

此篇是 JavaScript是如何工作的第四篇,其它三篇可以看這裏:

  • JavaScript是如何工作的:引擎,運行時和調用堆棧的概述!
  • JavaScript是如何工作的:深入V8引擎&編寫優化代碼的5個技巧!
  • JavaScript如何工作:內存管理+如何處理4個常見的內存泄漏!

通過第一篇文章回顧在單線程環境中編程的缺陷以及如何解決這些缺陷來構建健壯的JavaScript UI。按照慣例,在本文的最後,分享5個如何使用async/ wait編寫更簡潔代碼的技巧。

為什麽單線程是一個限制?

在發布的第一篇文章中,思考了這樣一個問題:當調用堆棧中有函數調用需要花費大量時間來處理時會發生什麽?

例如,假設在瀏覽器中運行一個復雜的圖像轉換算法。

當調用堆棧有函數要執行時,瀏覽器不能做任何其他事情——它被阻塞了。這意味著瀏覽器不能渲染,不能運行任何其他代碼,只是卡住了。那麽你的應用 UI 界面就卡住了,用戶體驗也就不那麽好了。

在某些情況下,這可能不是主要的問題。還有一個更大的問題是一旦你的瀏覽器開始處理調用堆棧中的太多任務,它可能會在很長一段時間內停止響應。這時,很多瀏覽器會拋出一個錯誤,提示是否終止頁面:

技術分享圖片

JavaScript程序的構建塊

你可能在單個.js文件中編寫 JavaScript 應用程序,但可以肯定的是,你的程序由幾個塊組成,其中只有一個正在執行,其余的將在稍後執行。最常見的塊單元是函數。

大多數剛接觸JavaScript的開發人員似乎都有這樣的問題,就是認為所有函數都是同步完成,沒有考慮的異步的情況。如下例子:

技術分享圖片

你可能知道標準 Ajax 請求不是同步完成的,這說明在代碼執行時 Ajax(..) 函數還沒有返回任何值來分配給變量 response

一種等待異步函數返回的結果簡單的方式就是 回調函數:

技術分享圖片

註意:實際上可以設置同步Ajax請求,但永遠不要那樣做。如果設置同步Ajax請求,應用程序的界面將被阻塞——用戶將無法單擊、輸入數據、導航或滾動。這將阻止任何用戶交互,這是一種可怕的做法。

以下是同步 Ajax 地,但是請千萬不要這樣做:

技術分享圖片

這裏使用Ajax請求作為示例,你可以讓任何代碼塊異步執行。

這可以通過 setTimeout(callback,milliseconds) 函數來完成。setTimeout 函數的作用是設置一個回調函數milliseconds後執行,如下:

function first() {
    console.log(‘first‘);
}
function second() {
    console.log(‘second‘);
}
function third() {
    console.log(‘third‘);
}
first();
setTimeout(second, 1000); // Invoke `second` after 1000ms
third();

輸出:

first
third
second

解析事件循環

這裏從一個有點奇怪的聲明開始——盡管允許異步 JavaScript 代碼(就像上例討論的setTimeout),但在ES6之前,JavaScript本身實際上從來沒有任何內置異步的概念,JavaScript引擎在任何給定時刻只執行一個塊。

那麽,是誰告訴JS引擎執行程序的代碼塊呢?實際上,JS引擎並不是單獨運行的——它是在一個宿主環境中運行的,對於大多數開發人員來說,宿主環境就是典型的web瀏覽器或Node.js。實際上,現在JavaScript被嵌入到各種各樣的設備中,從機器人到燈泡,每個設備代表 JS 引擎的不同類型的托管環境。

所有環境中的共同點是一個稱為事件循環的內置機制,它處理程序的多個塊在一段時間內通過調用調用JS引擎的執行。

這意味著JS引擎只是任意JS代碼的按需執行環境,是宿主環境處理事件運行及結果。

例如,當 JavaScript 程序發出 Ajax 請求從服務器獲取一些數據時,在函數(“回調”)中設置“response”代碼,JS引擎告訴宿主環境:"我現在要推遲執行,但當完成那個網絡請求時,會返回一些數據,請回調這個函數並給數據傳給它"。

然後瀏覽器將偵聽來自網絡的響應,當監聽到網絡請求返回內容時,瀏覽器通過將回調函數插入事件循環來調度要執行的回調函數。以下是示意圖:

技術分享圖片

這些Web api是什麽?從本質上說,它們是無法訪問的線程,只能調用它們。它們是瀏覽器的並發部分。如果你是一個Nojs.js開發者,這些就是 c++ 的 Api。

這樣的叠代在事件循環中稱為(tick)標記,每個事件只是一個函數回調。

技術分享圖片

讓我們“執行”這段代碼,看看會發生什麽:

1. 初始化狀態都為空,瀏覽器控制臺是空的的,調用堆棧也是空的

技術分享圖片

2. console.log(‘Hi‘)添加到調用堆棧中

技術分享圖片

3. 執行console.log(‘Hi‘)

技術分享圖片

4. console.log(‘Hi‘)從調用堆棧中移除。

技術分享圖片

5. setTimeout(function cb1() { ... }) 添加到調用堆棧。

技術分享圖片

6. setTimeout(function cb1() { ... }) 執行,瀏覽器創建一個計時器計時,這個作為Web api的一部分。

技術分享圖片

7. setTimeout(function cb1() { ... })本身執行完成,並從調用堆棧中刪除。

技術分享圖片

8. console.log(‘Bye‘) 添加到調用堆棧

技術分享圖片

9. 執行 console.log(‘Bye‘)

技術分享圖片

10. console.log(‘Bye‘) 從調用調用堆棧移除

技術分享圖片

11. 至少在5秒之後,計時器完成並將cb1回調推到回調隊列。

技術分享圖片

12. 事件循環從回調隊列中獲取cb1並將其推入調用堆棧。

技術分享圖片

13. 執行cb1並將console.log(‘cb1‘)添加到調用堆棧。

技術分享圖片

14. 執行 console.log(‘cb1‘)

技術分享圖片

15. console.log(‘cb1‘) 從調用堆棧中移除

技術分享圖片

16. cb1 從調用堆棧中移除

技術分享圖片

快速回顧:

技術分享圖片

值得註意的是,ES6指定了事件循環應該如何工作,這意味著在技術上它屬於JS引擎的職責範圍,不再僅僅扮演宿主環境的角色。這種變化的一個主要原因是ES6中引入了Promises,因為ES6需要對事件循環隊列上的調度操作進行直接、細度的控制。

setTimeout(…) 是怎麽工作的

需要註意的是,setTimeout(…)不會自動將回調放到事件循環隊列中。它設置了一個計時器。當計時器過期時,環境將回調放到事件循環中,以便將來某個標記(tick)將接收並執行它。請看下面的代碼:

setTimeout(myCallback, 1000);

這並不意味著myCallback將在1000毫秒後就立馬執行,而是在1000毫秒後,myCallback被添加到隊列中。但是,如果隊列有其他事件在前面添加回調剛必須等待前後的執行完後在執行myCallback

有不少的文章和教程上開始使用異步JavaScript代碼,建議用setTimeout(回調,0),現在你知道事件循環和setTimeout是如何工作的:調用setTimeout 0毫秒作為第二個參數只是推遲回調將它放到回調隊列中,直到調用堆棧是空的。

請看下面的代碼:

console.log(‘Hi‘);
setTimeout(function() {
    console.log(‘callback‘);
}, 0);
console.log(‘Bye‘);

雖然等待時間被設置為0 ms,但在瀏覽器控制臺的結果如下:

Hi
Bye
callback

ES6的任務隊列是什麽?

ES6中引入了一個名為“任務隊列”的概念。它是事件循環隊列上的一個層。最為常見在Promises 處理的異步方式。

現在只討論這個概念,以便在討論帶有Promises的異步行為時,能夠了解 Promises 是如何調度和處理。

想像一下:任務隊列是一個附加到事件循環隊列中每個標記末尾的隊列。某些異步操作可能發生在事件循環的一個標記期間,不會導致一個全新的事件被添加到事件循環隊列中,而是將一個項目(即任務)添加到當前標記的任務隊列的末尾。

這意味著可以放心添加另一個功能以便稍後執行,它將在其他任何事情之前立即執行。

任務還可能創建更多任務添加到同一隊列的末尾。理論上,任務“循環”(不斷添加其他任務的任等等)可以無限運行,從而使程序無法獲得轉移到下一個事件循環標記的必要資源。從概念上講,這類似於在代碼中表示長時間運行或無限循環(如while (true) ..)。

任務有點像 setTimeout(callback, 0) “hack”,但其實現方式是引入一個定義更明確、更有保證的順序:稍後,但越快越好。

回調

正如你已經知道的,回調是到目前為止JavaScript程序中表達和管理異步最常見的方法。實際上,回調是JavaScript語言中最基本的異步模式。無數的JS程序,甚至是非常復雜的程序,除了一些基本都是在回調異步基礎上編寫的。

然而回調方式還是有一些缺點,許多開發人員都在試圖找到更好的異步模式。但是,如果不了解底層的內容,就不可能有效地使用任何抽象出來的異步模式。

在下一章中,我們將深入探討這些抽象,以說明為什麽更復雜的異步模式(將在後續文章中討論)是必要的,甚至是值得推薦的。

嵌套回調

請看以下代碼:

技術分享圖片

我們有一個由三個函數組成的鏈嵌套在一起,每個函數表示異步系列中的一個步驟。

這種代碼通常被稱為“回調地獄”。但是“回調地獄”實際上與嵌套/縮進幾乎沒有任何關系,這是一個更深層次的問題。

首先,我們等待“單擊”事件,然後等待計時器觸發,然後等待Ajax響應返回,此時可能會再次重復所有操作。

乍一看,這段代碼似乎可以將其異步性自然地對應到以下順序步驟:

listen(‘click‘, function (e) {
    // ..
});

然後:

setTimeout(function(){
    // ..
}, 500);

接著:

ajax(‘https://api.example.com/endpoint‘, function (text){
    // ..
});

最後:

if (text == "hello") {
    doSomething();
}
else if (text == "world") {
    doSomethingElse();
}

因此,這種連續的方式來表示異步代碼似乎更自然,不是嗎?一定有這樣的方法,對吧?

Promises

請看下面的代碼:

var x = 1;
var y = 2;
console.log(x + y);

這非常簡單:它對xy的值進行求和,並將其打印到控制臺。但是,如果xy的值丟失了,仍然需要求值,要怎麽辦?

例如,需要從服務器取回xy的值,然後才能在表達式中使用它們。假設我們有一個函數loadXloadY``,它們分別從服務器加載xy的值。然後,一旦xy都被加載,假設我們有一個函數sum,它對xy`的值進行求和。

它可能看起來像這樣(很醜,不是嗎?)

技術分享圖片

這裏有一些非常重要的事情——在這個代碼片段中,我們將x和y作為異步獲取的的值,並且執行了一個函數sum(…)(從外部),它不關心x或y,也不關心它們是否立即可用。

當然,這種基於回調的粗略方法還有很多不足之處。 這只是一個我們不必判斷對於異步請求的值的處理方式一個小步驟而已。

Promise Value

用Promise來重寫上例:

技術分享圖片

在這個代碼片段中有兩層Promise。

fetchXfetchY 先直接調用,返回一個promise,傳給 sumsum 創建並返回一個Promise,通過調用 then 等待 Promise,完成後,sum 已經準備好了(resolve),將會打印出來。

第二層是 sum(…) 創建的 Promise ( 通過 Promise.all([ ... ]) )然後返回 Promise,通過調用then(…)來等待。當 sum(…) 操作完成時,sum 傳入的兩個 Promise 都執行完後,可以打印出來了。這裏隱藏了在sum(…)中等待xy未來值的邏輯。

註意:在sum(...)內,Promise.all([...])調用創建一個 promise(等待 promiseX 和 promiseY 解析)。 然後鏈式調用 .then(...)方法裏再的創建了另一個 Promise,然後把 返回的 x 和 和(values[0] + values[1]) 進行求和 並返回 。

因此,我們在sum(...)末尾調用then(...)方法 ?—? 實際上是在返回的第二個 Pwwromise 上運行,而不是由Promise.all([ ... ])創建 Promise。 此外,雖然沒有在第二個 Promise 結束時再調用 then方法 ,其時這裏也創建一個 Promise。

Promise.then(…) 實際上可以使用兩個函數,第一個函數用於執行成功的操作,第二個函數用於處理失敗的操作:

如果在獲取xy時出現錯誤,或者在添加過程中出現某種失敗,sum(…) 返回的 Promise將被拒絕,傳遞給 then(…) 的第二個回調錯誤處理程序將從 Promise 接收失敗的信息。

從外部看,由於 Promise 封裝了依賴於時間的狀態(等待底層值的完成或拒絕,Promise 本身是與時間無關的),它可以按照可預測的方式組成,不需要開發者關心時序或底層的結果。一旦 Promise 決議,此刻它就成為了外部不可變的值。

可鏈接調用 Promise 真的很有用:

創建一個延遲2000ms內完成的 Promise ,然後我們從第一個then(...)回調中返回,這會導致第二個then(...)等待 2000ms。

註意:因為Promise 一旦被解析,它在外部是不可變的,所以現在可以安全地將該值傳遞給任何一方,因為它不能被意外地或惡意地修改,這一點在多方遵守承諾的決議時尤其正確。一方不可能影響另一方遵守承諾決議的能力,不變性聽起來像是一個學術話題,但它實際上是承諾設計最基本和最重要的方面之一,不應該被隨意忽略。

使用 Promise 還是不用?

關於 Promise 的一個重要細節是要確定某個值是否是一個實際的Promise 。換句話說,它是否具有像Promise 一樣行為?

我們知道 Promise 是由new Promise(…)語法構造的,你可能認為`p instanceof Promise是一個足夠可以判斷的類型,嗯,不完全是。

這主要是因為可以從另一個瀏覽器窗口(例如iframe)接收 Promise 值,而該窗口或框架具有自己的 Promise 值,與當前窗口或框架中的 Promise 值不同,所以該檢查將無法識別 Promise 實例。

此外,庫或框架可以選擇性的封裝自己的 Promise,而不使用原生 ES6 的Promise 來實現。事實上,很可能在老瀏覽器的庫中沒有 Promise。

吞掉錯誤或異常

如果在 Promise 創建中,出現了一個javascript一場錯誤(TypeError 或者 ReferenceError),這個異常會被捕捉,並且使這個 promise 被拒絕。

但是,如果在調用 then(…) 方法中出現了 JS 異常錯誤,那麽會發生什麽情況呢?即使它不會丟失,你可能會發現它們的處理方式有點令人吃驚,直到你挖得更深一點:

技術分享圖片

看起來foo.bar()中的異常確實被吞噬了,不過,它不是。然而,還有一些更深層次的問題,我們沒有註意到。 p.then(…) 調用本身返回另一個 Promise,該 Promise 將被 TypeError 異常拒絕。

處理未捕獲異常

許多人會說,還有其他更好的方法。

一個常見的建議是,Promise 應該添加一個 done(…),這實際上是將 Promise 鏈標記為 “done”。done(…) 不會創建並返回 Promise ,因此傳遞給 done(..) 的回調顯然不會將問題報告給不存在的鏈接 Promise 。

Promise 對象的回調鏈,不管以 then 方法或 catch 方法結尾,要是最後一個方法拋出錯誤,都有可能無法捕捉到(因為 Promise 內部的錯誤不會冒泡到全局)。因此,我們可以提供一個 done 方法,總是處於回調鏈的尾端,保證拋出任何可能出現的錯誤。

技術分享圖片

ES8中改進了什麽 ?Async/await (異步/等待)

JavaScript ES8引入了 async/await,這使得使用 Promise 的工作更容易。這裏將簡要介紹async/await 提供的可能性以及如何利用它們編寫異步代碼。

使用 async 聲明異步函數。這個函數返回一個 AsyncFunction 對象。AsyncFunction 對象表示該函數中包含的代碼的異步函數。

調用使用 async 聲明函數時,它返回一個 Promise。當這個函數返回一個值時,這個值只是一個普通值而已,這個函數內部將自動創建一個承諾,並使用函數返回的值進行解析。當這個函數拋出異常時,Promise 將被拋出的值拒絕。

使用 async 聲明函數時可以包含一個 await 符號,await 暫停這個函數的執行並等待傳遞的 Promise 的解析完成,然後恢復這個函數的執行並返回解析後的值。

async/wait 的目的是簡化使用承諾的行為

讓看看下面的例子:

function getNumber1() {
    return Promise.resolve(‘374‘);
}
// 這個函數與getNumber1相同
async function getNumber2() {
    return 374;
}

類似地,拋出異常的函數等價於返回被拒絕的 Promise 的函數:

function f1() {
    return Promise.reject(‘Some error‘);
}
async function f2() {
    throw ‘Some error‘;
}

await 關鍵字只能在異步函數中使用,並允許同步等待 Promise。如果在 async 函數之外使用 Promise,仍然需要使用 then 回調:

技術分享圖片

還可以使用“異步函數表達式”定義異步函數。異步函數表達式與異步函數語句非常相似,語法也幾乎相同。異步函數表達式和異步函數語句之間的主要區別是函數名,可以在異步函數表達式中省略函數名來創建匿名函數。異步函數表達式可以用作生命(立即調用的函數表達式),一旦定義它就會運行。

var loadData = async function() {
    // `rp` is a request-promise function.
    var promise1 = rp(‘https://api.example.com/endpoint1‘);
    var promise2 = rp(‘https://api.example.com/endpoint2‘);
   
    // Currently, both requests are fired, concurrently and
    // now we‘ll have to wait for them to finish
    var response1 = await promise1;
    var response2 = await promise2;
    return response1 + ‘ ‘ + response2;
}

更重要的是,在所有主流的瀏覽器都支持 async/await:

技術分享圖片

最後,重要的是不要盲目選擇編寫異步代碼的“最新”方法。理解異步 JavaScript 的內部結構非常重要,了解為什麽異步JavaScript如此關鍵,並深入理解所選擇的方法的內部結構。與編程中的其他方法一樣,每種方法都有優點和缺點。

編寫高度可維護性、非易碎異步代碼的5個技巧

1. 簡化代碼:

使用 async/await 可以編寫更少的代碼。 每次使用 async/await時,都會跳過一些不必要的步驟:使用.then,創建一個匿名函數來處理響應,例如:**

// rp是一個請求 Promise 函數。
rp(‘https://api.example.com/endpoint1‘).then(function(data) {
    // …
});

和:

// `rp` is a request-promise function.
var response = await rp(‘https://api.example.com/endpoint1‘);

2. 錯誤處理

Async/wait 可以使用相同的代碼結構(眾所周知的try/catch語句)處理同步和異步錯誤。看看它是如何與 Promise 結合的:**

function loadData() {
    try { // Catches synchronous errors.
        getJSON().then(function(response) {
            var parsed = JSON.parse(response);
            console.log(parsed);
        }).catch(function(e) { // Catches asynchronous errors
            console.log(e); 
        });
    } catch(e) {
        console.log(e);
    }
}

async function loadData() {
    try {
        var data = JSON.parse(await getJSON());
        console.log(data);
    } catch(e) {
        console.log(e);
    }
}

3. 條件

用async/ wait編寫條件代碼要簡單得多:

function loadData() {
  return getJSON()
    .then(function(response) {
      if (response.needsAnotherRequest) {
        return makeAnotherRequest(response)
          .then(function(anotherResponse) {
            console.log(anotherResponse)
            return anotherResponse
          })
      } else {
        console.log(response)
        return response
      }
    })
}

async function loadData() {
  var response = await getJSON();
  if (response.needsAnotherRequest) {
    var anotherResponse = await makeAnotherRequest(response);
    console.log(anotherResponse)
    return anotherResponse
  } else {
    console.log(response);
    return response;    
  }
}

4. 錯誤堆棧

與 async/await不同,從 Promise 鏈返回的錯誤堆棧不提供錯誤發生在哪裏。看看下面這些

function loadData() {
  return callAPromise()
    .then(callback1)
    .then(callback2)
    .then(callback3)
    .then(() => {
      throw new Error("boom");
    })
}
loadData()
  .catch(function(e) {
    console.log(err);
// Error: boom at callAPromise.then.then.then.then (index.js:8:13)
});

與:

async function loadData() {
  await callAPromise1()
  await callAPromise2()
  await callAPromise3()
  await callAPromise4()
  await callAPromise5()
  throw new Error("boom");
}
loadData()
  .catch(function(e) {
    console.log(err);
    // output
    // Error: boom at loadData (index.js:7:9)
});

5. 調試

如果你使用過 Promise ,那麽你知道調試它們是一場噩夢。例如,如果在一個程序中設置了一個斷點,然後阻塞並使用調試快捷方式(如“停止”),調試器將不會移動到下面,因為它只“逐步”執行同步代碼。使用async/wait,您可以逐步完成wait調用,就像它們是正常的同步函數一樣。

編輯中可能存在的bug沒法實時知道,事後為了解決這些bug,花了大量的時間進行log 調試,這邊順便給大家推薦一個好用的BUG監控工具Fundebug。

原文:https://blog.sessionstack.com...

你的點贊是我持續分享好東西的動力,歡迎點贊!

一個笨笨的碼農,我的世界只能終身學習!

**更多內容請關註公眾號《大遷世界》!

關於Fundebug

Fundebug專註於JavaScript、微信小程序、微信小遊戲、支付寶小程序、React Native、Node.js和Java實時BUG監控。 自從2016年雙十一正式上線,Fundebug累計處理了9億+錯誤事件,得到了Google、360、金山軟件、百姓網等眾多知名用戶的認可。歡迎免費試用!

技術分享圖片

JavaScript是如何工作的:事件循環和異步編程的崛起 + 5種使用 async/await 更好地編碼方式!