Javascript中常見的非同步程式設計模型
在Javascript非同步程式設計專題的前一篇文章淺談Javascript中的非同步中,我簡明的闡述了“Javascript中的非同步原理”、“Javascript如何在單執行緒上實現非同步呼叫”以及“Javascript中的定時器”等相關問題。
本篇文章我將會談一談Javascript中常用的幾種非同步程式設計模型。
在前端的程式碼編寫中,非同步的場景隨處可見。比如滑鼠點選、鍵盤迴車、網路請求等這些與瀏覽器緊密聯絡的操作,比如一些延遲互動特效等等。
在這些場景中,你必須要使用所謂的“非同步模式”,否則將會嚴重程式的可行性和使用者體驗。我們列舉這些場景中常用的幾種非同步程式設計模型,包括回撥函式、事件監聽、觀察者模式(訊息訂閱/釋出)、promise
模式。除此之外還會稍微介紹一番ES6(ES7)中新增的方案。
下面我們將針對每一種程式設計模型加以說明。
回撥函式
回撥函式可以說是Javascript非同步程式設計最基本的方法。我們試想有這樣一個場景,我們需要在頁面上展示一個持續3秒鐘的loading
視覺樣式,然後在頁面上顯示我們真正想顯示的內容。示例程式碼如下,
// more code
function loading(callback) {
// 持續3秒的loading展示
setTimeout(function () {
callback();
}, 3000);
}
function show() {
// 展示真實資料給使用者
}
loading(show);
// more code
程式碼中的loading(show)
就是將函式show()
作為函式loading()
的引數。在loading()
完成3秒的loading
之後,再去執行回撥函式(示例使用了setTimeout
來模擬)。通過這種方法,show()
就變成了非同步呼叫,它的執行時機被推遲到loading()
即將完成之前。
回撥函式的缺陷
回撥函式往往就是呼叫使用者提供的函式,該函式往往是以引數的形式提供的。回撥函式並不一定是非同步執行的。回撥函式的特點就是使用簡單、容易理解。缺點就是邏輯間存在一定耦合。最噁心的地方在於會造成所謂的callback hell
。比如下面這樣的一個例子,
A(function () {
B(function () {
C(function() {
D(function() {
// ...
})
})
})
})
例子中A、B、C、D四個任務存在依賴關係,通過函式回撥的方式,寫出來的程式碼就會變成上面的這個樣子。維護性和可讀性都非常糟糕。
除了回撥巢狀的問題之外,還可能會帶來另一個問題,就是流程控制不方便。比如我們要傳送3個請求,當3個請求都返回時,我們再執行相關邏輯,那麼程式碼可能就是,
var count = 0
for (var i = 0; i < 3; i++) {
request('source_' + i, function () {
count++;
if (count === 3) {
// do my logic
}
});
}
上面的示例程式碼中,我通過request
對三個url
傳送了請求,但是我不知道這三個請求的返回情況。無奈之下我新增了一個計數器count
,在每個請求的回撥中都進行計數器判斷,當計數器為3時即表示三個請求都已經成功返回了,此時再去執行相關任務。顯而易見,這種情況下的流程控制就顯得比較醜陋。
最後,有時候我們為了程式的健壯性,可能會需要一個try...catch
語法。比如,
// demo1
try {
setTimeout(function () {
throw new Error('error occured');
})
} catch(e) {
console.log(e);
}
// demo2
setTimeout(function () {
try {
// your logic
} catch(e) {
}
});
上面的示例程式碼中,如果我們像demo1那樣將try...catch
加在非同步邏輯的外面,即使非同步呼叫發生了異常我們也是捕獲不到的,因為try...catch
不能捕獲未來的異常。無奈,我們只能像demo2那樣將try...catch
語句塊放在具體的非同步邏輯內。這樣一旦非同步呼叫多起來,那麼就會多出來很多try...catch
。這樣肯定是不好的。
除了上面這些問題之外,我覺得回撥函式真正的核心問題在於,巢狀的回到函式往往會破壞整個程式的呼叫堆疊,並且像return
,throw
等這些用於程式碼流程控制的關鍵詞都不能正常使用(因為前一個回撥函式往往會影響到它後面所有的回撥函式)。
事件監聽
事件監聽在UI程式設計中隨處可見。比如我給一個按鈕繫結一個點選事件,給一個輸入框繫結一個鍵盤敲擊事件等等。比如下面的程式碼,
$('#button').on('click', function () {
console.log('我被點了');
});
上面使用了JQuery的語法,給一個按鈕繫結了一個事件。當事件觸發時,會執行繫結的邏輯。這比較容易理解。
除了介面事件之外,通常我們還有各種網路請求事件,比如ajax,websocket
等等。這些網路請求在不同階段也會觸發各種事件,如果程式中有繫結相關處理邏輯,那麼當事件觸發時就會去執行相關邏輯。
除此之外,我們還可以自定義事件。比如,
$('#div').on('data-loaded', function () {
console.log('data loaded');
});
$('#div').trigger('data-loaded');
上面採用JQuery的語法,我們自定義了一個事件,叫做”data-loaded
”,並在此事件上定義了一個觸發邏輯。當我們通過trigger
觸發這個事件時,之前繫結的邏輯就會執行了。
觀察者模式
之前在事件監聽中提到了自定義事件,其實自定義事件是觀察者模式的一種具體表現。觀察者模式,又稱為訊息訂閱/釋出模式。它的含義是,我們先假設有一個“訊號中心”,當某個任務執行完畢就向訊號中心發出一個訊號(事件),然後訊號中心收到這個訊號之後將會進行廣播。如果有其他任務訂閱了該訊號,那麼這些任務就會收到一個通知,然後執行任務相關的邏輯。
下面是觀察者模式的一個簡單實現(可參閱用AngularJS實現觀察者模式),
var ob = {
channels: [],
subscribe: function(topic, callback) {
if (!_.isArray(this.channels[topic])) {
channels[topic] = [];
}
var handlers = channels[topic];
handlers.push(callback);
},
unsubscribe: function(topic, callback) {
if (!_.isArray(this.channels[topic])) {
return;
}
var handlers = this.channels[topic];
var index = _.indexOf(handlers, callback);
if (index >= 0) {
handlers.splice(index, 1);
}
},
publish: function(topic, data) {
var self = this;
var handlers = this.channels[topic] || [];
_.each(handlers, function(handler) {
try {
handler.apply(self, [data]);
} catch (ex) {
console.log(ex);
}
});
}
};
其用法如下,
ob.subscribe('done', function () {
console.log('done');
});
setTimeout(function () {
ob.publish('done')
}, 1000);
觀察者模式的實現方式有很多,不過基本核心都差不多,都會有訊息訂閱和釋出。從本質上說,前面所說的事件監聽也是一種觀察者模式。
觀察者模式用好了自然好處多多,能夠把解耦做的相當好。但是複雜的系統如果要用觀察者模式來做邏輯,必須要做好事件訂閱和釋出的設計,否則會導致程式的執行流程混亂。
Promise
模式
Promise
嚴格來說不是一種新技術,它只是一種語法糖,一種機制,一種程式碼結構和流程,用於管理非同步回撥。
jQuery中的Promise
實現源自Promises/A規範。使用promise
來管理回撥,可以將回撥邏輯扁平化,可以避免之前提到的回撥地獄。示例程式碼如下,
function fn1() {
var dfd = $.Deferred();
setTimeout(function () {
console.log('fn1');
dfd.resolve();
}, 1000);
return dfd.promise();
}
function fn2() {
console.log('fn2');
}
fn1().then(fn2);
針對之前提到的回撥地獄和異常難以捕獲的問題,使用promise
都可以輕鬆的解決。
A().then(B).then(C).then(D).catch(ERROR);
看,一行就搞定了。不過使用promise
處理非同步呼叫,有一點需要注意,就是所有的非同步函式都要promise
化。所謂promise
化的意思就是需要對非同步函式進行封裝,讓其返回一個promise
物件。比如,
function A() {
var promise = new Promise(function (resolve, reject) {
// your logic
});
return promise;
}
ES6中的方案
ES6於今年6月份左右已經正式釋出了。其中新增了不少內容。其中有兩項內容可能用來解決非同步回撥的內容。
ES6中的Promise
最新發布的ECMAScript2015
中已經涵蓋了promise
的相關內容,不過ES6中的Promise
規範其實是Promise/A+
規範,可以說它是Promise/A規範的增強版。
現代瀏覽器Chrome,Firefox等已經對Promise提供了原生支援。詳細的文件可以參閱MDN。
簡單來說,ES6中promise
的內容具體如下,
- promise有三種狀態:pending(等待)、fulfilled(成功)、rejected(失敗)。其中pending為初始狀態。
- promise的狀態轉換隻能是:
pending
->fulfilled
或者pending
->rejected
。轉換方向不能顛倒,且fulfilled
和rejected
狀態不能相互轉換。每一種狀態轉換都會觸發相關呼叫。 pending
->fulfilled
時,promise
會帶有一個value
(成功狀態的值);pending
->rejected
時,promise
會帶有一個reason
(失敗狀態的原因)promise
擁有then方法。then方法必須返回一個promise。then可以多次鏈式呼叫,且回撥的順序跟then的宣告順序一致。then
方法接受兩個引數,分別是“pending->fulfilled”的呼叫和“pending->rejected”的呼叫。then
還可以接受一個promise例項,也可以接受一個thenable
(類then物件或者方法)例項。
總得來說promise
的內容比較簡單,涉及到三種狀態和兩種狀態轉換。其實promise
的核心就是then
方法的實現。
下面是來自MDN上Promise
的程式碼示例(稍作改動),
var p1 = new Promise(function (resolve, reject) {
console.log('p1 start');
setTimeout(function() {
resolve('p1 resolved');
}, 2000);
});
p1.then(function (value) {
console.log(value);
}, function(reason) {
console.log(reason);
});
上述程式碼的執行結果是,先列印”p1 start”然後經過2秒左右再次列印”p1 resolved”。
當然我們還可以新增多個回撥。我們可以通過在前一個then
方法中呼叫return
將promise
往後傳遞。比如,
p1.then(function(v) {
console.log('1: ', v);
return v + ' 2';
}).then(function(v) {
console.log('2: ', v);
});
不過在使用Promise的時候,有一些需要注意的地方,這篇文章We have a problem with promises(翻譯文)中總結得很好,有興趣的可自行參閱。
不管是ES6中的promise
還是jQuery中的promise/deferred
,的確可以避免非同步程式碼的巢狀問題,使整體程式碼結構變得清晰,不用再受callback hell
折磨。但是也僅僅止步於此,因為它並沒有觸碰js非同步回撥真正核心的內容。
現在業界有許多關於PromiseA+
規範的實現,不過博主個人覺得bluebird是個不錯的庫,可以值得一用,如果你有選擇困難症,不妨試一試???
ES6中Generator
ES6中引入的Generator可以理解為一種協程的實現機制,它允許函式在執行過程中將Javascript執行權交給其他函式(程式碼),並在需要的時候返回繼續執行。
我們可以使用Generator
配合ES6中Promise
,進一步將非同步呼叫扁平化(轉化成同步風格)。
下面我們來看一個例子,
function* gen() {
var ret = yield new Promise(function(resolve, reject) {
console.log('async task start');
setTimeout(function() {
resolve('async task end');
}, 2000);
});
console.log(ret);
}
上述Node.js程式碼中,我們定義了一個Generator
函式,且建立了一個promise,promise內使用setTimeout模擬了一個非同步任務。
接下來我們來執行這個Generator
函式,因為yield
返回的是一個promise
,所以我們需要使用then
方法,
var g = gen();
var result = g.next();
result.value.then(function(str){
console.log(str);
// 對resolve的資料重新包裝,然後傳遞給下一個promise
return {
msg: str
};
}).then(function(data){
g.next(data);
});
最終的結果如下,
async task start
// 經過2秒左右
async task end
{msg: 'async task end'}
其實關於Generator
還有很多的內容可以說,這裡由於篇幅的關係就不展開了。業界已經有了基於Generator
處理非同步呼叫的功能庫,比如co、task.js
。
ES7中的async
和await
在單執行緒的Javascript上做非同步任務(甚至併發任務)的確是一個讓人頭疼的問題,總會越到各種各樣的問題。從最早的函式回撥,到Promise,再到Generator,湧現的各種解決方案,雖然都有所改進,但是仍然讓人覺得並沒有徹底的解決這個問題。
舉個例子來說,我現在就是想讀取一個檔案,這麼簡單的一件事,何必要考慮那麼多呢?又是回撥,又是promise
的,煩不煩吶。我就想像下面這麼簡單的寫程式碼,難道不行麼?
function task() {
var file1Content = readFile('file1path');
var file2Content = readFile(fileContent);
console.log(file2Content);
}
想要做的事情很簡單,讀取第一個檔案,它的內容是要讀取的第二個檔案的檔名。
值得慶幸的是,ES7中的async
和await
可以幫你做到這件事。不過要稍微改動一下,
async function task() {
var file1Content = await readFile('file1path');
var file2Content = await readFile(fileContent);
console.log(file2Content);
}
看,改動的地方很簡單,只要在task
前面加上關鍵詞async
,在函式內的非同步任務前新增await
宣告即可。如果忽略這些額外的關鍵字,簡直就是完完全全的同步寫法嘛。
其實,這種方式就是前端提到的Generator和Promise方案的封裝。ECMAScript組織也認為這是目前解決Javascript非同步回撥的最佳方案,所以可能會在ES7中將其納入到規範中來。需要注意的是,這項特性是ES7的提案,依賴Generator,所以慎用(目前來說基本用不了)!
fibjs
除了上述的幾種方案之外,其實還有另外一種方案。就是使用協程的方案來解決單執行緒上的非同步呼叫問題。
之前我們也提到過,Generator
的yield
可以暫停函式執行,將執行權臨時轉交給其他任務,待其他任務完畢之後,再交還回執行權。這其實就是協程的基本模型。
業界有一款基於V8引擎的服務端開發框架fibjs,它的實現機制跟Node.js是不一樣的。fibjs採用fiber解決v8引擎的多路複用,並通過大量c++元件,將重負荷運算委託給後臺執行緒,釋放v8執行緒,爭取更大的併發時間。
一句話,fibjs從底層,使用的纖程模型解決了非同步呼叫的問題。關於fibjs,有興趣的話可以查閱相關資料。不過我個人對它是持謹慎態度的。原因是如下兩點,
- 生態原因。
- 使用了js,但是又摒棄了js的非同步。
不過還是可以作為興趣去研究一下的。
相關文章
- JavaScript 中常見設計模式整理JavaScript設計模式
- 好程式設計師web前端分享JavaScript中常見的反模式程式設計師Web前端JavaScript模式
- 一文徹底搞定(阻塞/非阻塞/同步/非同步)網路IO、併發程式設計模型、非同步程式設計模型的愛恨情仇非同步程式設計模型
- socket程式設計中常見的概念問題!程式設計
- Javascript 非同步程式設計JavaScript非同步程式設計
- 你好,JavaScript非同步程式設計—- 理解JavaScript非同步的美妙JavaScript非同步程式設計
- 你好,JavaScript非同步程式設計---- 理解JavaScript非同步的美妙JavaScript非同步程式設計
- 非同步程式設計模型的思考非同步程式設計模型
- Javascript中的非同步程式設計JavaScript非同步程式設計
- 非同步程式設計:.NET 4.5 基於任務的非同步程式設計模型(TAP)非同步程式設計模型
- Java開發人員在程式設計中常見的雷!Java程式設計
- JavaScript 非同步程式設計入門JavaScript非同步程式設計
- 前端- JavaScript非同步程式設計Promise前端JavaScript非同步程式設計Promise
- Javascript非同步程式設計總結JavaScript非同步程式設計
- JavaScript非同步程式設計筆記JavaScript非同步程式設計筆記
- [譯] 非同步程式設計:阻塞與非阻塞非同步程式設計
- JavaScript非同步程式設計的6種方法JavaScript非同步程式設計
- javascript 非同步程式設計的5種方式JavaScript非同步程式設計
- 說一說javascript的非同步程式設計JavaScript非同步程式設計
- 好程式設計師Python培訓分享Python程式設計中常見的異常處理程式設計師Python
- JavaScript非同步程式設計:Generator與AsyncJavaScript非同步程式設計
- JavaScript非同步程式設計-基礎篇JavaScript非同步程式設計
- 好程式設計師分享JavaScript學習筆記ES5中常見的陣列方法程式設計師JavaScript筆記陣列
- 為什麼 JavaScript 需要非同步程式設計JavaScript非同步程式設計
- 前端-JavaScript非同步程式設計async函式前端JavaScript非同步程式設計函式
- Model設計中常見的技巧和注意事項
- 一些Java開發人員在程式設計中常見的雷!Java程式設計
- JavaScript 單執行緒之非同步程式設計JavaScript執行緒非同步程式設計
- 深入解析JavaScript非同步程式設計:Generator與AsyncJavaScript非同步程式設計
- JavaScript非同步程式設計–Generator函式、async、awaitJavaScript非同步程式設計函式AI
- 談談JavaScript中常見的資料型別JavaScript資料型別
- JavaScript中常見的錯誤,你犯了幾個?JavaScript
- 幫你整理 Java 中常見設計模式整理Java設計模式
- 【進階之路】併發程式設計(三)-非阻塞同步機制程式設計
- JavaScript 常見設計模式JavaScript設計模式
- JavaScript深入淺出非同步程式設計二、promise原理JavaScript非同步程式設計Promise
- JavaScript深入淺出非同步程式設計三、async、awaitJavaScript非同步程式設計AI
- 非同步程式設計:基於事件的非同步程式設計模式(EAP)非同步程式設計事件設計模式