我們知道,js的執行環境是“單線程”的。所謂”單線程”,就是指一次只能完成一件任務。如果有多個任務,就必須排隊,前面一個任務完成,再執行後面一個任務,以此類推。
js被創造出來用於實現操作瀏覽器html中的DOM樹、CSS樣式樹以及實現界面中用戶與html中DOM的動態交互,那麼問題來了,假如js是多線程執行,那麼很可能出現同一時刻有兩個線程在操作同一個UI DOM元素,並且假如一個線程用於給DOM元素添加樣式,而另一個線程用於刪除DOM元素。這時候,這個DOM就成爲了 臨界資源,瀏覽器就無法去裁決究竟以哪一個線程的操作爲準。雖然,我們也可以實現某些 鎖 來規避這種線程操作互斥的情況,但是這一定會給js開發帶來更大的複雜性。所以js最終採用單線程機制。
這種模式的好處是實現起來比較簡單,執行環境相對單純;壞處是只要有一個任務耗時很長,後面的任務都必須排隊等着,會拖延整個程序的執行。常見的瀏覽器無響應(假死),往往就是因爲某一段Javascript代碼長時間運行(比如死循環),導致整個頁面卡在這個地方,其他任務無法執行。
爲了解決這個問題,Javascript語言將任務的執行模式分成兩種:同步(Synchronous)和異步(Asynchronous)。
“同步模式”就是上面所說的,後一個任務等待前一個任務結束,然後再執行,程序的執行順序與任務的排列順序是一致的、同步的;”異步模式”則完全不同,每一個任務有一個或多個回調函數(callback),前一個任務結束後,不是執行後一個任務,而是執行回調函數,後一個任務則是不等前一個任務結束就執行,所以程序的執行順序與任務的排列順序是不一致的、異步的。
“異步模式”非常重要。在瀏覽器端,耗時很長的操作都應該異步執行,避免瀏覽器失去響應,最好的例子就是Ajax操作。在服務器端,”異步模式”甚至是唯一的模式,因爲執行環境是單線程的,如果允許同步執行所有http請求,服務器性能會急劇下降,很快就會失去響應。
四種異步模式的方法
一、回調函數
假定有兩個函數f1和f2,後者等待前者的執行結果(f2依賴f1)。
f1();
f2();
如果f1是一個很耗時的任務,可以考慮改寫f1,把f2寫成f1的回調函數。
function f1(callback){
setTimeout(function () {
// f1的任務代碼
callback();
}, 1000);
}
f1(f2);
採用這種方式,我們把同步操作變成了異步操作,f1不會堵塞程序運行,相當於先執行程序的主要邏輯,將耗時的操作推遲執行。
回調函數的優點是簡單、容易理解和部署,缺點是不利於代碼的閱讀和維護,各個部分之間高度耦合(Coupling),流程會很混亂,而且每個任務只能指定一個回調函數。
回調函數:就是在不久的將來,也就是一段不確定的時間之後將要執行的事情。例如:setTimeout,Ajax都是異步回調。
回調地獄:
setTimeout(function (name) {
2 var catList = name + ',';
3
4 setTimeout(function (name) {
5 catList += name + ',';
6
7 setTimeout(function (name) {
8 catList += name + ',';
9
10 setTimeout(function (name) {
11 catList += name + ',';
12
13 setTimeout(function (name) {
14 catList += name;
15
16 console.log(catList);
17
18 }, 1, 'Lion');
19
20 }, 1, 'Snow Leopard');
21
22 }, 1, 'Lynx');
23
24 }, 1, 'Jaguar');}, 1, 'Panther');
由於回調函數是異步的,在上面的代碼中每一層的回調函數都需要依賴上一層的回調執行完,所以形成了層層嵌套的關係最終形成類似上面的回調地獄,但代碼以此種形式展現時無疑是不利於我們閱讀與維護的。
二、事件監聽
f1.on('done', f2);
function f1(){
setTimeout(function () {
// f1的任務代碼
f1.trigger('done');
}, 1000);
}
f1.trigger(‘done’)表示,執行完成後,立即觸發done事件,從而開始執行f2。
這種方法的優點是比較容易理解,可以綁定多個事件,每個事件可以指定多個回調函數,而且可以”去耦合”(Decoupling),有利於實現模塊化。缺點是整個程序都要變成事件驅動型,運行流程會變得很不清晰。
三、發佈/訂閱
上一節的”事件”,完全可以理解成”信號”。
我們假定,存在一個”信號中心”,某個任務執行完成,就向信號中心”發佈”(publish)一個信號,其他任務可以向信號中心”訂閱”(subscribe)這個信號,從而知道什麼時候自己可以開始執行。這就叫做”發佈/訂閱模式”(publish-subscribe pattern),又稱”觀察者模式”(observer pattern)。
這個模式有多種實現,下面採用的是Ben Alman的Tiny Pub/Sub,這是jQuery的一個插件。
首先,f2向”信號中心”jQuery訂閱”done”信號。
jQuery.subscribe("done", f2);
function f1(){
setTimeout(function () {
// f1的任務代碼
jQuery.publish("done");
}, 1000);
}
jQuery.unsubscribe("done", f2);
jQuery.publish(“done”)的意思是,f1執行完成後,向”信號中心”jQuery發佈”done”信號,從而引發f2的執行。
此外,f2完成執行後,也可以取消訂閱(unsubscribe)。
這種方法的性質與”事件監聽”類似,但是明顯優於後者。因爲我們可以通過查看”消息中心”,瞭解存在多少信號、每個信號有多少訂閱者,從而監控程序的運行。
四、Promises對象
ES6 原生提供了 Promise 對象。
所謂 Promise,就是一個對象,用來傳遞異步操作的消息。它代表了某個未來纔會知道結果的事件(通常是一個異步操作),並且這個事件提供統一的 API,可供進一步處理。
簡單說,它的思想是,每一個異步任務返回一個Promise對象,該對象有一個then方法,允許指定回調函數。比如,f1的回調函數f2,可以寫成:
f1().then(f2);
function f1(){
var dfd = $.Deferred();
setTimeout(function () {
// f1的任務代碼
dfd.resolve();
}, 500);
return dfd.promise;
}
這樣寫的優點在於,回調函數變成了鏈式寫法,程序的流程可以看得很清楚,而且有一整套的配套方法,可以實現許多強大的功能。
比如,指定多個回調函數:
f1().then(f2).then(f3);
再比如,指定發生錯誤時的回調函數:
f1().then(f2).fail(f3);
這裏再對promise進行一下說明,這是es6中的定義的對象。所謂Promise,簡單說就是一個容器,裏面保存着某個未來纔會結束的事件(通常是一個異步操作)的結果。
Promise對象有以下兩個特點。
(1)對象的狀態不受外界影響。Promise對象代表一個異步操作,有三種狀態:Pending(進行中)、Resolved(已完成,又稱 Fulfilled)和Rejected(已失敗)。只有異步操作的結果,可以決定當前是哪一種狀態,任何其他操作都無法改變這個狀態。這也是Promise這個名字的由來,它的英語意思就是“承諾”,表示其他手段無法改變。
(2)一旦狀態改變,就不會再變,任何時候都可以得到這個結果。Promise對象的狀態改變,只有兩種可能:從Pending變爲Resolved和從Pending變爲Rejected。只要這兩種情況發生,狀態就凝固了,不會再變了,會一直保持這個結果。就算改變已經發生了,你再對Promise對象添加回調函數,也會立即得到這個結果。這與事件(Event)完全不同,事件的特點是,如果你錯過了它,再去監聽,是得不到結果的。
有了Promise對象,就可以將異步操作以同步操作的流程表達出來,避免了層層嵌套的回調函數。此外,Promise對象提供統一的接口,使得控制異步操作更加容易。
var promise = new Promise(function(resolve, reject) {
// ... some code
if (/* 異步操作成功 */){
resolve(value);
} else {
reject(error);
}
});
上面代碼創造了一個Promise實例。
Promise構造函數接受一個函數作爲參數,該函數的兩個參數分別是resolve和reject。它們是兩個函數,由JavaScript引擎提供,不用自己部署。
resolve函數的作用是,將Promise對象的狀態從“未完成”變爲“成功”(即從Pending變爲Resolved),在異步操作成功時調用,並將異步操作的結果,作爲參數傳遞出去;reject函數的作用是,將Promise對象的狀態從“未完成”變爲“失敗”(即從Pending變爲Rejected),在異步操作失敗時調用,並將異步操作報出的錯誤,作爲參數傳遞出去。
Promise實例生成以後,可以用then方法分別指定Resolved狀態和Reject狀態的回調函數。
promise.then(function(value) {
// success
}, function(error) {
// failure
});
then方法可以接受兩個回調函數作爲參數。第一個回調函數是Promise對象的狀態變爲Resolved時調用,第二個回調函數是Promise對象的狀態變爲Reject時調用。其中,第二個函數是可選的,不一定要提供。這兩個函數都接受Promise對象傳出的值作爲參數。
這裏有一個promise異步執行的經典例題:
console.log(0);
setTimeout(function() {
console.log(1)}, 0);
new Promise(function executor(resolve) {
console.log(2);
for( var i=0 ; i<10000 ; i++ ) {
i == 9999 && resolve();
}
console.log(3);
}).then(function() {
console.log(4);}
);
console.log(5);
//0,2,3,5,4,1
/*先輸出0
然後碰到一個 setTimeout,於是會先設置一個定時,在定時結束後將傳遞這個函數放到任務隊列裏面,因此開始肯定不會輸出 1 。·
然後是一個 Promise,裏面的函數是直接執行的,因此應該直接輸出 2 3 。
然後,Promise 的 then 應當會放到當前 tick 的最後,但是還是在當前 tick 中。
因此,應當先輸出 5,然後再輸出 4 。
最後在到下一個 tick,就是 1 。
“2 3 5 4 1”*/
Promise 也有一些缺點。首先,無法取消 Promise,一旦新建它就會立即執行,無法中途取消。其次,如果不設置回調函數,Promise 內部拋出的錯誤,不會反應到外部。第三,當處於 Pending 狀態時,無法得知目前進展到哪一個階段(剛剛開始還是即將完成)。