JS 非同步程式設計的 5 種解決方案

語言: CN / TW / HK

我們知道 JS 語言的執行環境是"單執行緒",所謂"單執行緒",就是指一次只能完成一件任務,這種模式的好處是實現起來比較簡單,執行環境相對單純;壞處是隻要有一個任務耗時很長,後面的任務都必須排隊等著,會拖延整個程式的執行。為了解決這個問題,JS 語言將任務的執行模式分成兩種:同步 (Synchronous)和非同步 (Asynchronous)

下面就來講一講非同步為什麼很重要?如何使用非同步來有效處理潛在的阻塞操作?

為什麼需要非同步?

通常來說,程式都是順序執行,同一時刻只會發生一件事。如果一個函式依賴於另一個函式的結果,它只能等待那個函式結束才能繼續執行,從使用者的角度來說,整個程式才算執行完畢.

你可能知道,Javascript語言的執行環境是"單執行緒"(single thread)。

所謂"單執行緒",就是指一次只能完成一件任務。如果有多個任務,就必須排隊,前面一個任務完成,再執行後面一個任務,以此類推。

這種模式的好處是實現起來比較簡單,執行環境相對單純;壞處是隻要有一個任務耗時很長,後面的任務都必須排隊等著,會拖延整個程式的執行。常見的瀏覽器無響應(假死),往往就是因為某一段Javascript程式碼長時間執行(比如死迴圈),導致整個頁面卡在這個地方,其他任務無法執行。

比如

Mac 使用者可能會經歷過這種旋轉的彩虹游標(常稱為沙灘球),作業系統通過這個游標告訴使用者:“現在執行的程式正在等待其他的某一件事情完成,才能繼續執行,都這麼長的時間了,你一定在擔心到底發生了什麼事情”。

在這裡插入圖片描述

這是令人沮喪的體驗,沒有充分利用計算機的計算能力 — 尤其是在計算機普遍都有多核CPU的時代,坐在那裡等待毫無意義,你完全可以在另一個處理器核心上幹其他的工作,同時計算機完成耗時任務的時候通知你。這樣你可以同時完成其他工作,這就是非同步程式設計的出發點。你正在使用的程式設計環境(就web開發而言,程式設計環境就是web瀏覽器)負責為你提供非同步執行此類任務的API。

1. 阻塞

非同步技術非常有用,特別是在web程式設計。當瀏覽器裡面的一個web應用進行密集運算還沒有把控制權返回給瀏覽器的時候,整個瀏覽器就像凍僵了一樣,這叫做 阻塞;這時候瀏覽器無法繼續處理使用者的輸入並執行其他任務,直到web應用交回處理器的控制。

我們來看一些 阻塞 的例子。

例子: simple-sync.html

```html

Simple synchronous JavaScript example

```

在按鈕上添加了一個事件監聽器,當按鈕被點選,它就開始執行一個非常耗時的任務(計算1千萬個日期,並在console裡顯示最終的耗時),然後在DOM裡面新增一個段落。

執行這個例子的時候,開啟JavaScript console,然後點選按鈕 — 你會注意到,直到日期的運算結束,最終的耗時在console上顯示出來,段落才會出現在網頁上。

效果如下:

在這裡插入圖片描述

程式碼按照原始碼的順序執行,只有前面的程式碼結束執行,後面的程式碼才會執行。

Note: 這個例子不現實:在實際情況中一般不會發生,沒有誰會計算1千萬次日期,它僅僅提供一個非常直觀的體驗.

2. 同步

要理解什麼是 非同步 JavaScript ,我們應該從確切理解 同步 JavaScript 開始。

我們學的很多知識基本上都是同步的:執行程式碼,然後瀏覽器儘快返回結果。先看一個簡單的例子

```html

Simple synchronous JavaScript example

```

效果如下:

在這裡插入圖片描述

這段程式碼, 一行一行的順序執行:

  1. 先取得一個在DOM裡面的 <button> 引用。

  2. 點選按鈕的時候,新增一個 click 事件監聽器:

    1. alert() 訊息出現。
    2. 一旦alert 結束,建立一個<p> 元素。
    3. 給它的文字內容賦值。
    4. 最後,把這個段落放進網頁。

每一個操作在執行的時候,其他任何事情都沒有發生 — 網頁的渲染暫停. 因為前篇文章提到過 JavaScript 是單執行緒. 任何時候只能做一件事情, 只有一個主執行緒,其他的事情都阻塞了,直到前面的操作完成。

所以上面的例子,點選了按鈕以後,段落不會建立,直到在alert訊息框中點選ok,段落才會出現,你可以自己試試

Note: 請記住,這個很重要,alert()在演示阻塞效果的時候非常有用,但是在正式程式碼裡面,它就是一個噩夢。

3. 解決

為了解決這個問題,Javascript語言將任務的執行模式分成兩種:同步(Synchronous)和非同步(Asynchronous)

  • "同步模式"就是前面講到的模式,後一個任務等待前一個任務結束,然後再執行,程式的執行順序與任務的排列順序是一致的、同步的;"非同步模式"則完全不同,每一個任務有一個或多個回撥函式(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 function doSomething(msg, callback){ alert(msg); if(typeof callback == "function") callback(); } doSomething("回撥函式", function(){ alert("匿名函式實現回撥!"); }); 我們再來看幾個經典的回撥函式程式碼,我保證你一定用過他們:

◾ 1. 非同步請求的毀掉函授:

javascript $.get("/try/ajax/demo_test.php",function(data,status){ alert("資料: " + data + "\n狀態: " + status); });

◾ 2. 陣列遍歷的回撥函式

javascript array1.forEach(element => console.log(element));

等等

回撥函式的優點是簡單、容易理解和部署,缺點是不利於程式碼的閱讀和維護,各個部分之間高度耦合(Coupling),流程會很混亂,而且每個任務只能指定一個回撥函式。

回撥函式 最致命的缺點,就是容易寫出 回撥地獄(Callback hell)。假設多個請求存在依賴性,你可能就會寫出如下程式碼:

javascript ajax(url, () => { // 處理邏輯 ajax(url1, () => { // 處理邏輯 ajax(url2, () => { // 處理邏輯 }) }) })

2. 事件監聽

另一種思路是採用事件驅動模式。任務的執行不取決於程式碼的順序,而取決於某個事件是否發生。

事件監聽的回撥函式:

javascript element.addEventListener("click", function(){ alert("Hello World!"); }); 上面這行程式碼的意思是,當 element 發生click事件,就執行傳入的 function。

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

3. 釋出/訂閱

釋出-訂閱模式其實是一種物件間一對多的依賴關係,當一個物件的狀態傳送改變時,所有依賴於它的物件都將得到狀態改變的通知。

  • 訂閱者(Subscriber)把自己想訂閱的事件 註冊(Subscribe)到排程中心(Event Channel);
  • 釋出者(Publisher)釋出該事件(Publish Event)到排程中心,也就是該事件觸發時,由 排程中心 統一排程(Fire Event)訂閱者註冊到排程中心的處理程式碼。

◾ 例子

比如我們很喜歡看某個公眾號號的文章,但是我們不知道什麼時候釋出新文章,要不定時的去翻閱;這時候,我們可以關注該公眾號,當有文章推送時,會有訊息及時通知我們文章更新了。

上面一個看似簡單的操作,其實是一個典型的釋出訂閱模式,公眾號屬於釋出者,使用者屬於訂閱者;使用者將訂閱公眾號的事件註冊到排程中心,公眾號作為釋出者,當有新文章釋出時,公眾號釋出該事件到排程中心,排程中心會及時發訊息告知使用者。

◾ 釋出/訂閱模式的優點是物件之間解耦,非同步程式設計中,可以更鬆耦合的程式碼編寫;缺點是建立訂閱者本身要消耗一定的時間和記憶體,雖然可以弱化物件之間的聯絡,多個釋出者和訂閱者巢狀一起的時候,程式難以跟蹤維護。

想要手寫實現釋出/訂閱模式的童鞋可以看我發的這篇文章:從零帶你手寫一個“釋出-訂閱者模式“ ,保姆級教學

4. Promise

Promise 是一種處理非同步程式碼(而不會陷入回撥地獄)的方式。

多年來,promise 已成為語言的一部分(在 ES2015 中進行了標準化和引入),並且最近變得更加整合,在 ES2017 中具有了 async 和 await。

非同步函式 在底層使用了 promise,因此瞭解 promise 的工作方式是瞭解 asyncawait 的基礎。

Promise物件代表一個非同步操作,有三種狀態:pending(進行中)、fulfilled(已成功)和rejected(已失敗)

一個 Promise 必然處於以下幾種狀態之一:

  • 待定 (pending): 初始狀態,既沒有被兌現,也沒有被拒絕。
  • 已成功 (fulfilled): 意味著操作成功完成。
  • 已拒絕 (rejected): 意味著操作失敗。

當 promise 被呼叫後,它會以處理中狀態 (pending) 開始。 這意味著呼叫的函式會繼續執行,而 promise 仍處於處理中直到解決為止,從而為呼叫的函式提供所請求的任何資料。

被建立的 promise 最終會以被解決狀態 (fulfilled)被拒絕狀態 (rejected) 結束,並在完成時呼叫相應的回撥函式(傳給 thencatch)。

● Promise 的鏈式呼叫

Promise 例項具有then方法,也就是說,then方法是定義在原型物件Promise.prototype上的。它的作用是為 Promise 例項新增狀態改變時的回撥函式。前面說過,then方法的第一個引數是resolved狀態的回撥函式,第二個引數(可選)是rejected狀態的回撥函式。

then方法返回的是一個新的Promise例項(注意,不是原來那個Promise例項)。因此可以採用鏈式寫法,即then方法後面再呼叫另一個then方法。

javascript getJSON("/posts.json").then(function(json) { return json.post; }).then(function(post) { // ... }); 上面的程式碼使用then方法,依次指定了兩個回撥函式。第一個回撥函式完成以後,會將返回結果作為引數,傳入第二個回撥函式。

採用鏈式的then,可以指定一組按照次序呼叫的回撥函式。這時,前一個回撥函式,有可能返回的還是一個Promise物件(即有非同步操作),這時後一個回撥函式,就會等待該Promise物件的狀態發生變化,才會被呼叫。

javascript getJSON("/post/1.json").then(function(post) { return getJSON(post.commentURL); }).then(function (comments) { console.log("resolved: ", comments); }, function (err){ console.log("rejected: ", err); }); 上面程式碼中,第一個then方法指定的回撥函式,返回的是另一個Promise物件。這時,第二個then方法指定的回撥函式,就會等待這個新的Promise物件狀態發生變化。如果變為resolved,就呼叫第一個回撥函式,如果狀態變為rejected,就呼叫第二個回撥函式。

如果採用箭頭函式,上面的程式碼可以寫得更簡潔。

javascript getJSON("/post/1.json").then( post => getJSON(post.commentURL) ).then( comments => console.log("resolved: ", comments), err => console.log("rejected: ", err) ); 如果想要更詳細的學習 Promise ,可以參考我發的這幾篇文章: - 通俗易懂的Promise知識點總結,檢驗一下你是否真的完全掌握了promise? - 手把手一行一行程式碼教你“手寫Promise“,完美通過 Promises/A+ 官方872個測試用例 - 看了就會,手寫 Promise 全部 API 教程,包括處於 TC39 第四階段草案的 Promise.any()

5. async/await

asyncawait 關鍵字是最近新增到JavaScript語言裡面的。它們是ECMAScript 2017 的一部分,簡單來說,它們是基於promises的語法糖,使非同步程式碼更易於編寫和閱讀。通過使用它們,非同步程式碼看起來更像是老式同步程式碼,因此它們非常值得學習。

如果想要更詳細的學習 async/await ,可以參考我發的這篇文章: - JS 非同步程式設計終極解決方案 async/await 的使用手冊

更多更全更詳細優質內容猛戳這裡檢視

參考