JS異步編程Promise對象詳解
1、單線程模型
單線程模型指的是,JavaScript 隻在一個線程上運行。也就是說,JavaScript 同時隻能執行一個任務,其他任務都必須在後面排隊等待。註意,JavaScript 隻在一個線程上運行,不代表 JavaScript 引擎隻有一個線程。事實上,JavaScript 引擎有多個線程,單個腳本隻能在一個線程上運行(稱為主線程),其他線程都是在後臺配合。
JavaScript 之所以采用單線程,而不是多線程,跟歷史有關系。JavaScript 從誕生起就是單線程,原因是不想讓瀏覽器變得太復雜,因為多線程需要共享資源、且有可能修改彼此的運行結果,對於一種網頁腳本語言來說,這就太復雜瞭。
如果 JavaScript 同時有兩個線程,一個線程在網頁 DOM 節點上添加內容,另一個線程刪除瞭這個節點,這時瀏覽器應該以哪個線程為準?是不是還要有鎖機制?
所以,為瞭避免復雜性,JavaScript 一開始就是單線程,這已經成瞭這門語言的核心特征,將來也不會改變。
2、同步任務和異步任務
程序裡面所有的任務,可以分成兩類:同步任務(synchronous)和異步任務(asynchronous)。
同步任務是那些沒有被引擎掛起、在主線程上排隊執行的任務。隻有前一個任務執行完畢,才能執行後一個任務。
異步任務是那些被引擎放在一邊,不進入主線程、而進入任務隊列的任務。隻有引擎認為某個異步任務可以執行瞭(比如 Ajax 操作從服務器得到瞭結果),該任務(采用回調函數的形式)才會進入主線程執行。排在異步任務後面的代碼,不用等待異步任務結束會馬上運行,也就是說,異步任務不具有“堵塞”效應。
舉例來說,Ajax 操作可以當作同步任務處理,也可以當作異步任務處理,由開發者決定。如果是同步任務,主線程就等著 Ajax 操作返回結果,再往下執行;如果是異步任務,主線程在發出 Ajax 請求以後,就直接往下執行,等到 Ajax 操作有瞭結果,主線程再執行對應的回調函數。
3、任務隊列和事件循環
JavaScript 運行時,除瞭一個正在運行的主線程,引擎還提供一個任務隊列(task queue),裡面是各種需要當前程序處理的異步任務。(實際上,根據異步任務的類型,存在多個任務隊列。為瞭方便理解,這裡假設隻存在一個隊列。)首先,主線程會去執行所有的同步任務。等到同步任務全部執行完,就會去看任務隊列裡面的異步任務。
如果滿足條件,那麼異步任務就重新進入主線程開始執行,這時它就變成同步任務瞭。等到執行完,下一個異步任務再進入主線程開始執行。一旦任務隊列清空,程序就結束執行。異步任務的寫法通常是回調函數。一旦異步任務重新進入主線程,就會執行對應的回調函數。
如果一個異步任務沒有回調函數,就不會進入任務隊列,也就是說,不會重新進入主線程,因為沒有用回調函數指定下一步的操作。JavaScript 引擎怎麼知道異步任務有沒有結果,能不能進入主線程呢?答案就是引擎在不停地檢查,一遍又一遍,隻要同步任務執行完瞭,引擎就會去檢查那些掛起來的異步任務,是不是可以進入主線程瞭。這種循環檢查的機制,就叫做事件循環(Event Loop)。
維基百科的定義是:“事件循環是一個程序結構,用於等待和發送消息和事件(a programming construct that waits for and dispatches events or messages in a program)”。
4、異步操作的模式
4.1回調函數
把f2
寫成f1
的回調函數。
function f1(callback) { // ... callback(); } function f2() { // ... } f1(f2);
回調函數的優點是簡單、容易理解和實現,缺點是不利於代碼的閱讀和維護,各個部分之間高度耦合(coupling),使得程序結構混亂、流程難以追蹤(尤其是多個回調函數嵌套的情況),而且每個任務隻能指定一個回調函數。
4.2事件監聽
f1.on('done', f2); function f1() { setTimeout(function () { // ... f1.trigger('done'); }, 1000); }
f1.trigger('done')
表示,執行完成後,立即觸發done
事件,從而開始執行f2
這種方法的優點是比較容易理解,可以綁定多個事件,每個事件可以指定多個回調函數,而且可以“去耦合”(decoupling),有利於實現模塊化。缺點是整個程序都要變成事件驅動型,運行流程會變得很不清晰。閱讀代碼的時候,很難看出主流程。
4.3 發佈/訂閱
事件完全可以理解成“信號”,如果存在一個“信號中心”,某個任務執行完成,就向信號中心“發佈”(publish)一個信號,其他任務可以向信號中心“訂閱”(subscribe)這個信號,從而知道什麼時候自己可以開始執行。這就叫做“發佈/訂閱模式”(publish-subscribe pattern),又稱“觀察者模式”(observer pattern)。
f2
向信號中心jQuery
訂閱done
信號。
jQuery.subscribe('done', f2); function f1() { setTimeout(function () { // ... jQuery.publish('done'); }, 1000); }
上面代碼中,jQuery.publish('done')
的意思是,f1
執行完成後,向信號中心jQuery
發佈done
信號,從而引發f2
的執行。
f2
完成執行後,可以取消訂閱(unsubscribe)。
jQuery.unsubscribe('done', f2);
這種方法的性質與“事件監聽”類似,但是明顯優於後者。因為可以通過查看“消息中心”,瞭解存在多少信號、每個信號有多少訂閱者,從而監控程序的運行。
5、Promise 對象的狀態
Promise 對象通過自身的狀態,來控制異步操作。Promise 實例具有三種狀態。
- 異步操作未完成(pending)
- 異步操作成功(fulfilled)
- 異步操作失敗(rejected)
上面三種狀態裡面,fulfilled
和rejected
合在一起稱為resolved
(已定型)。
這三種的狀態的變化途徑隻有兩種。
- 從“未完成”到“成功”
- 從“未完成”到“失敗”
一旦狀態發生變化,就凝固瞭,不會再有新的狀態變化。這也是 Promise 這個名字的由來,它的英語意思是“承諾”,一旦承諾成效,就不得再改變瞭。這也意味著,Promise 實例的狀態變化隻可能發生一次。
因此,Promise 的最終結果隻有兩種。
- 異步操作成功,Promise 實例傳回一個值(value),狀態變為fulfilled。
- 異步操作失敗,Promise 實例拋出一個錯誤(error),狀態變為rejected。
6、Promise 構造函數
JavaScript 提供原生的Promise
構造函數,用來生成 Promise 實例。
var promise = new Promise(function (resolve, reject) { // ... if (/* 異步操作成功 */){ resolve(value); } else { /* 異步操作失敗 */ reject(new Error()); } });
上面代碼中,Promise
構造函數接受一個函數作為參數,該函數的兩個參數分別是resolve
和reject
。它們是兩個函數,由 JavaScript 引擎提供,不用自己實現。
resolve
函數的作用是,將Promise
實例的狀態從“未完成”變為“成功”(即從pending
變為fulfilled
),在異步操作成功時調用,並將異步操作的結果,作為參數傳遞出去。reject
函數的作用是,將Promise
實例的狀態從“未完成”變為“失敗”(即從pending
變為rejected
),在異步操作失敗時調用,並將異步操作報出的錯誤,作為參數傳遞出去。
下面是一個例子。
function timeout(ms) { return new Promise((resolve, reject) => { setTimeout(resolve, ms, 'done'); }); } timeout(100)
上面代碼中,timeout(100)
返回一個 Promise 實例。100毫秒以後,該實例的狀態會變為fulfilled
。
7、then() 用法辨析
Promise 的用法,簡單說就是一句話:使用then
方法添加回調函數。但是,不同的寫法有一些細微的差別,請看下面四種寫法,它們的差別在哪裡?
// 寫法一 f1().then(function () { return f2(); }); // 寫法二 f1().then(function () { f2(); }); // 寫法三 f1().then(f2()); // 寫法四 f1().then(f2);
為瞭便於講解,下面這四種寫法都再用then
方法接一個回調函數f3
。寫法一的f3
回調函數的參數,是f2
函數的運行結果。
f1().then(function () { return f2(); }).then(f3);
寫法二的f3
回調函數的參數是undefined
。
f1().then(function () { f2(); return; }).then(f3);
寫法三的f3
回調函數的參數,是f2
函數返回的函數的運行結果。
f1().then(f2()) .then(f3);
寫法四與寫法一隻有一個差別,那就是f2
會接收到f1()
返回的結果。
f1().then(f2) .then(f3);
8、Promise 優缺點
優點:讓回調函數變成瞭規范的鏈式寫法,程序流程可以看得很清楚。它有一整套接口,可以實現許多強大的功能,比如同時執行多個異步操作,等到它們的狀態都改變以後,再執行一個回調函數;再比如,為多個回調函數中拋出的錯誤,統一指定處理方法等等。
而且,Promise 還有一個傳統寫法沒有的好處:它的狀態一旦改變,無論何時查詢,都能得到這個狀態。這意味著,無論何時為 Promise 實例添加回調函數,該函數都能正確執行。所以,你不用擔心是否錯過瞭某個事件或信號。如果是傳統寫法,通過監聽事件來執行回調函數,一旦錯過瞭事件,再添加回調函數是不會執行的。
缺點:編寫的難度比傳統寫法高,而且閱讀代碼也不是一眼可以看懂。你隻會看到一堆then
,必須自己在then
的回調函數裡面理清邏輯。
9、微任務
Promise 的回調函數屬於異步任務,會在同步任務之後執行。
new Promise(function (resolve, reject) { resolve(1); }).then(console.log); console.log(2); // 2 // 1
上面代碼會先輸出2,再輸出1。因為console.log(2)
是同步任務,而then
的回調函數屬於異步任務,一定晚於同步任務執行。
但是,Promise 的回調函數不是正常的異步任務,而是微任務(microtask)。它們的區別在於,正常任務追加到下一輪事件循環,微任務追加到本輪事件循環。這意味著,微任務的執行時間一定早於正常任務。
setTimeout(function() { console.log(1); }, 0); new Promise(function (resolve, reject) { resolve(2); }).then(console.log); console.log(3); // 3 // 2 // 1
上面代碼的輸出結果是321
。這說明then
的回調函數的執行時間,早於setTimeout(fn, 0)
。因為then
是本輪事件循環執行,setTimeout(fn, 0)
在下一輪事件循環開始時執行。
到此這篇關於Promise異步編程模式的文章就介紹到這瞭。希望對大傢的學習有所幫助,也希望大傢多多支持WalkonNet。
推薦閱讀:
- 前端JavaScript之Promise
- JavaScript中的Promise詳解
- JS中promise特點與信任問題解決
- 徹底搞懂 javascript的Promise
- ES6的Promise用法詳解