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

JavaScript是如何工作的:事件迴圈和非同步程式設計的崛起 + 5種使用 async/await 更好地編碼方式!

摘要: 深度理解JS事件迴圈!!!

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

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

通過第一篇文章回顧在單執行緒環境中程式設計的缺陷以及如何解決這些缺陷來構建健壯的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、金山軟體、百姓網等眾多知名使用者的認可。歡迎免費試用!