前言
你應該知道,Javascript語言的執行環境是”單執行緒
“(single thread)。
所謂”單執行緒”,就是指一次只能完成一件任務。如果有多個任務,就必須排隊,前面一個任務完成,再執行後面一個任務,以此類推。
這種模式的好處是實現起來比較簡單,執行環境相對單純;壞處是隻要有一個任務耗時很長,後面的任務都必須排隊等著,會拖延整個程式的執行。常見的瀏覽器無響應(假死),往往就是因為某一段Javascript程式碼長時間執行(比如死迴圈),導致整個頁面卡在這個地方,其他任務無法執行。
為了解決這個問題,Javascript語言將任務的執行模式分成兩種:同步(Synchronous)和非同步(Asynchronous)。
“同步模式”就是上一段的模式,後一個任務等待前一個任務結束,然後再執行,程式的執行順序與任務的排列順序是一致的、同步的;”非同步模式”則完全不同,每一個任務有一個或多個回撥函式(callback),前一個任務結束後,不是執行後一個任務,而是執行回撥函式,後一個任務則是不等前一個任務結束就執行,所以程式的執行順序與任務的排列順序是不一致的、非同步的
非同步模式”非常重要。在瀏覽器端,耗時很長的操作都應該非同步執行,避免瀏覽器失去響應,最好的例子就是Ajax操作。在伺服器端,”非同步模式”甚至是唯一的模式,因為執行環境是單執行緒的,如果允許同步執行所有http請求,伺服器效能會急劇下降,很快就會失去響應。
本文總結了”非同步模式”程式設計的4種方法,理解它們可以讓你寫出結構更合理、效能更出色、維護更方便的Javascript程式。
1、回撥函式
這是非同步程式設計最基本的方法。
假定有兩個函式f1和f2,後者等待前者的執行結果。
f1();
f2();複製程式碼
如果f1是一個很耗時的任務,可以考慮改寫f1,把f2寫成f1的回撥函式。
function f1(callback){
setTimeout(function () {
// f1的任務程式碼
callback();
}, 1000);
}複製程式碼
執行程式碼就變成下面這樣:
f1(f2);複製程式碼
採用這種方式,我們把同步操作變成了非同步操作,f1不會堵塞程式執行,相當於先執行程式的主要邏輯,將耗時的操作推遲執行。
回撥函式的優點是簡單、容易理解和部署,缺點是不利於程式碼的閱讀和維護,各個部分之間高度耦合(Coupling
),流程會很混亂,而回撥函式有一個致命的弱點,就是容易寫出回撥地獄
2、事件監聽
另一種思路是採用事件驅動模式。任務的執行不取決於程式碼的順序,而取決於某個事件是否發生。
還是以f1和f2為例。首先,為f1繫結一個事件(這裡採用的jQuery的寫法)。
f1.on(`done`, f2);複製程式碼
上面這行程式碼的意思是,當f1發生done事件,就執行f2。然後,對f1進行改寫:
function f1(){
setTimeout(function () {
// f1的任務程式碼
f1.trigger(`done`);
}, 1000);
}複製程式碼
f1.trigger(`done`)表示,執行完成後,立即觸發done事件,從而開始執行f2。
這種方法的優點是比較容易理解,可以繫結多個事件,每個事件可以指定多個回撥函式,而且可以”去耦合”(Decoupling),有利於實現模組化。缺點是整個程式都要變成事件驅動型,執行流程會變得很不清晰。
2、釋出/訂閱
上一節的”事件”,完全可以理解成”訊號”。
我們假定,存在一個”訊號中心”,某個任務執行完成,就向訊號中心”釋出”(publish)一個訊號,其他任務可以向訊號中心”訂閱”(subscribe)這個訊號,從而知道什麼時候自己可以開始執行。這就叫做”釋出/訂閱模式”(publish-subscribe pattern),又稱”觀察者模式”(observer pattern)。
這個模式有多種實現,下面採用的是Ben Alman的Tiny Pub/Sub,這是jQuery的一個外掛。
首先,f2向”訊號中心”jQuery訂閱”done”訊號。
jQuery.subscribe("done", f2);複製程式碼
然後,f1進行如下改寫:
function f1(){
setTimeout(function () {
// f1的任務程式碼
jQuery.publish("done");
}, 1000);
}複製程式碼
jQuery.publish(“done”)的意思是,f1執行完成後,向”訊號中心”jQuery釋出”done”訊號,從而引發f2的執行。
此外,f2完成執行後,也可以取消訂閱(unsubscribe)。
jQuery.unsubscribe("done", f2);複製程式碼
這種方法的性質與”事件監聽”類似,但是明顯優於後者。因為我們可以通過檢視”訊息中心”,瞭解存在多少訊號、每個訊號有多少訂閱者,從而監控程式的執行
4、Promises物件
Promises物件是CommonJS工作組提出的一種規範,目的是為非同步程式設計提供統一介面。
Promise物件有以下兩個特點。
(1)物件的狀態不受外界影響。Promise
物件代表一個非同步操作,
有三種狀態:Pending
(進行中)、Resolved
(已完成,又稱Fulfilled)和Rejected
(已失敗)。
只有非同步操作的結果,可以決定當前是哪一種狀態,任何其他操作都無法改變這個狀態。
這也是Promise
這個名字的由來,它的英語意思就是“承諾”,表示其他手段無法改變。
(2)一旦狀態改變,就不會再變,任何時候都可以得到這個結果。
Promise
物件的狀態改變,只有兩種可能:從Pending
變為Resolved
和從Pending
變為Rejected
。
只要這兩種情況發生,狀態就凝固了,不會再變了,會一直保持這個結果。就算改變已經發生了,你再對Promise
物件新增回撥函式,也會立即得到這個結果。
這與事件(Event)完全不同,事件的特點是,如果你錯過了它,再去監聽,是得不到結果的。
有了Promise
物件,就可以將非同步操作以同步操作的流程表達出來,避免了層層巢狀的回撥函式。此外,Promise
物件提供統一的介面,使得控制非同步操作更加容易。
Promise
也有一些缺點。首先,無法取消Promise
,一旦新建它就會立即執行,無法中途取消。其次,如果不設定回撥函式,Promise
內部丟擲的錯誤,不會反應到外部。第三,當處於Pending
狀態時,無法得知目前進展到哪一個階段(剛剛開始還是即將完成)。
基本用法
var promise = new Promise(function(resolve, reject) {
// ... some code
if (/* 非同步操作成功 */){
resolve(value);
} else {
reject(error);
}
});複製程式碼
Promise例項生成以後,可以用then
方法分別指定Resolved
狀態和Reject
狀態的回撥函式。
promise.then(function(value) {
// success
}, function(error) {
// failure
});複製程式碼
Promise.prototype.then()
Promise例項具有then
方法,也就是說,then
方法是定義在原型物件Promise.prototype上的。它的作用是為Promise例項新增狀態改變時的回撥函式。前面說過,then
方法的第一個引數是Resolved狀態的回撥函式,第二個引數(可選)是Rejected狀態的回撥函式。
then
方法返回的是一個新的Promise例項(注意,不是原來那個Promise例項)。因此可以採用鏈式寫法,即then
方法後面再呼叫另一個then
方法。
getJSON("/posts.json").then(function(json) {
return json.post;
}).then(function(post) {
// ...
});複製程式碼
Promise.prototype.catch()
Promise.prototype.catch
方法是.then(null, rejection)
的別名,用於指定發生錯誤時的回撥函式。
getJSON("/posts.json").then(function(posts) {
// ...
}).catch(function(error) {
// 處理 getJSON 和 前一個回撥函式執行時發生的錯誤
console.log(`發生錯誤!`, error);
});複製程式碼
5、Generator 函式
Generator函式是ES6提供的一種非同步程式設計解決方案,語法行為與傳統函式完全不同
Generator函式有多種理解角度。從語法上,首先可以把它理解成,Generator函式是一個狀態機,封裝了多個內部狀態。
執行Generator函式會返回一個遍歷器物件,也就是說,Generator函式除了狀態機,還是一個遍歷器物件生成函式。返回的遍歷器物件,可以依次遍歷Generator函式內部的每一個狀態。
形式上,Generator函式是一個普通函式,但是有兩個特徵。一是,function
關鍵字與函式名之間有一個星號;二是,函式體內部使用yield
語句,定義不同的內部狀態(yield語句在英語裡的意思就是“產出”)。
function* helloWorldGenerator() {
yield `hello`;
yield `world`;
return `ending`;
}
var hw = helloWorldGenerator();複製程式碼
上面程式碼定義了一個Generator函式helloWorldGenerator
,它內部有兩個yield
語句“hello”和“world”,即該函式有三個狀態:hello,world和return語句(結束執行)。
然後,Generator函式的呼叫方法與普通函式一樣,也是在函式名後面加上一對圓括號。不同的是,呼叫Generator函式後,該函式並不執行,返回的也不是函式執行結果,而是一個指向內部狀態的指標物件,也就是上一章介紹的遍歷器物件(Iterator Object)。
下一步,必須呼叫遍歷器物件的next方法,使得指標移向下一個狀態。也就是說,每次呼叫next
方法,內部指標就從函式頭部或上一次停下來的地方開始執行,直到遇到下一個yield
語句(或return
語句)為止。換言之,Generator函式是分段執行的,yield
語句是暫停執行的標記,而next
方法可以恢復執行。
hw.next()
// { value: `hello`, done: false }
hw.next()
// { value: `world`, done: false }
hw.next()
// { value: `ending`, done: true }
hw.next()
// { value: undefined, done: true }複製程式碼
上面程式碼一共呼叫了四次next
方法。
第一次呼叫,Generator函式開始執行,直到遇到第一個yield
語句為止。next
方法返回一個物件,它的value
屬性就是當前yield
語句的值hello,done
屬性的值false,表示遍歷還沒有結束。
第二次呼叫,Generator函式從上次yield
語句停下的地方,一直執行到下一個yield
語句。next
方法返回的物件的value
屬性就是當前yield
語句的值world,done
屬性的值false,表示遍歷還沒有結束。
第三次呼叫,Generator函式從上次yield
語句停下的地方,一直執行到return
語句(如果沒有return語句,就執行到函式結束)。next
方法返回的物件的value
屬性,就是緊跟在return
語句後面的表示式的值(如果沒有return
語句,則value
屬性的值為undefined),done
屬性的值true,表示遍歷已經結束。
第四次呼叫,此時Generator函式已經執行完畢,next
方法返回物件的value
屬性為undefined,done
屬性為true。以後再呼叫next
方法,返回的都是這個值。
總結一下,呼叫Generator函式,返回一個遍歷器物件,代表Generator函式的內部指標。以後,每次呼叫遍歷器物件的next
方法,就會返回一個有著value
和done
兩個屬性的物件。value
屬性表示當前的內部狀態的值,是yield
語句後面那個表示式的值;done
屬性是一個布林值,表示是否遍歷結束
yield語句
由於Generator函式返回的遍歷器物件,只有呼叫next
方法才會遍歷下一個內部狀態,所以其實提供了一種可以暫停執行的函式。yield
語句就是暫停標誌。
遍歷器物件的next
方法的執行邏輯如下。
(1)遇到yield
語句,就暫停執行後面的操作,並將緊跟在yield
後面的那個表示式的值,作為返回的物件的value
屬性值。
(2)下一次呼叫next
方法時,再繼續往下執行,直到遇到下一個yield
語句。
(3)如果沒有再遇到新的yield
語句,就一直執行到函式結束,直到return
語句為止,並將return
語句後面的表示式的值,作為返回的物件的value
屬性值。
(4)如果該函式沒有return
語句,則返回的物件的value
屬性值為undefined
。
需要注意的是,yield
語句後面的表示式,只有當呼叫next
方法、內部指標指向該語句時才會執行,因此等於為JavaScript提供了手動的“惰性求值”(Lazy Evaluation)的語法功能。
function* gen() {
yield 123 + 456;
}複製程式碼
上面程式碼中,yield後面的表示式123 + 456
,不會立即求值,只會在next
方法將指標移到這一句時,才會求值。
yield
語句與return
語句既有相似之處,也有區別。相似之處在於,都能返回緊跟在語句後面的那個表示式的值。區別在於每次遇到yield
,函式暫停執行,下一次再從該位置繼續向後執行,而return
語句不具備位置記憶的功能。一個函式裡面,只能執行一次(或者說一個)return
語句,但是可以執行多次(或者說多個)yield
語句。正常函式只能返回一個值,因為只能執行一次return
;Generator函式可以返回一系列的值,因為可以有任意多個yield
。從另一個角度看,也可以說Generator生成了一系列的值,這也就是它的名稱的來歷(在英語中,generator這個詞是“生成器”的意思)。
6、async與await
ES7提供了async
函式,使得非同步操作變得更加方便。async
函式是什麼?一句話,async
函式就是Generator函式的語法糖。
依次讀取兩個檔案。
var fs = require(`fs`);
var readFile = function (fileName) {
return new Promise(function (resolve, reject) {
fs.readFile(fileName, function(error, data) {
if (error) reject(error);
resolve(data);
});
});
};
var gen = function* (){
var f1 = yield readFile(`/etc/fstab`);
var f2 = yield readFile(`/etc/shells`);
console.log(f1.toString());
console.log(f2.toString());
};複製程式碼
寫成async
函式,就是下面這樣。
var asyncReadFile = async function (){
var f1 = await readFile(`/etc/fstab`);
var f2 = await readFile(`/etc/shells`);
console.log(f1.toString());
console.log(f2.toString());
};複製程式碼
一比較就會發現,async
函式就是將Generator函式的星號(*
)替換成async
,將yield
替換成await
,僅此而已。
async
函式對 Generator 函式的改進,體現在以下四點
(1)內建執行器。Generator函式的執行必須靠執行器,所以才有了co
模組,而async
函式自帶執行器。也就是說,async
函式的執行,與普通函式一模一樣,只要一行。
(2)更好的語義。async
和await
,比起星號和yield
,語義更清楚了。async
表示函式裡有非同步操作,await
表示緊跟在後面的表示式需要等待結果。
(3)更廣的適用性。 co
模組約定,yield
命令後面只能是Thunk函式或Promise物件,而async
函式的await
命令後面,可以是Promise物件和原始型別的值(數值、字串和布林值,但這時等同於同步操作)。
(4)返回值是Promise。async
函式的返回值是Promise物件,這比Generator函式的返回值是Iterator物件方便多了。你可以用then
方法指定下一步的操作。
進一步說,async
函式完全可以看作多個非同步操作,包裝成的一個Promise物件,而await
命令就是內部then
命令的語法糖。