眾所周知(這也忒誇張了吧?),Javascript通過事件驅動機制,在單線程模型下,以異步的形式來實現非阻塞的IO操作。這種模式使得JavaScript在處理事務時非常高效,但這帶來了很多問題,比如異常處理困難、函數嵌套過深。下面介紹幾種目前已知的實現異步操作的解決方案。 [TOC](操蛋,不支持TOC)
一、回調函數
這是最古老的一種異步解決方案:通過參數傳入回調,未來調用回調時讓函數的調用者判斷發生了什么。 直接偷懶上阮大神的例子: 假定有兩個函數f1和f2,后者等待前者的執行結果。 如果f1是一個很耗時的任務,可以考慮改寫f1,把f2寫成f1的回調函數。
function f1(callback){
setTimeout(function () {
// f1的任務代碼
callback();
}, 1000);
}
執行代碼就變成下面這樣: f1(f2);
采用這種方式,我們把同步操作變成了異步操作,f1不會堵塞程序運行,相當於先執行程序的主要邏輯,將耗時的操作推遲執行。 回調函數的優點是簡單、容易理解和部署,缺點是不利於代碼的閱讀和維護,各個部分之間高度耦合,流程會很混亂.也許你覺得上面的流程還算清晰。那是因為我等初級菜鳥還沒見過世面,試想在前端領域打怪升級的過程中,遇到了下面的代碼:
doA(function(){
doB();
doC(function(){
doD();
})
doE();
});
doF();
要想理清上述代碼中函數的執行順序,還真得停下來分析很久,正確的執行順序是doA->doF->doB->doC->doE->doD. 回調函數的優點是簡單、容易理解和部署,缺點是不利於代碼的閱讀和維護,程序的流程會很混亂,而且每個任務只能指定一個回調函數。
二、事件發布/訂閱模式(觀察者模式)
事件監聽模式是一種廣泛應用於異步編程的模式,是回調函數的事件化,任務的執行不取決於代碼的順序,而取決於某個事件是否發生。這種設計模式常被成為發布/訂閱模式或者觀察者模式。 瀏覽器原生支持事件,如Ajax請求獲取響應、與DOM的交互等,這些事件天生就是異步執行的。在后端的Node環境中也自帶了events模塊,Node中事件發布/訂閱的模式及其簡單,使用事件發射器即可,示例代碼如下:
//訂閱
emitter.on("event1",function(message){
console.log(message);
});
//發布
emitter.emit('event1',"I am message!");
我們也可以自己實現一個事件發射器,代碼實現參考了《JavaScript設計模式與開發實踐》
var event={
clientList:[],
listen:function (key,fn) {
if (!this.clientList[key]) {
this.clientList[key]=[];
}
this.clientList[key].push(fn);//訂閱的消息添加進緩存列表
},
trigger:function(){
var key=Array.prototype.shift.call(arguments),//提取第一個參數為事件名稱
fns=this.clientList[key];
if (!fns || fns.length===0) {//如果沒有綁定對應的消息
return false;
}
for (var i = 0,fn;fn=fns[i++];) {
fn.apply(this,arguments);//帶上剩余的參數
}
},
remove:function(key,fn){
var fns=this.clientList[key];
if (!fns) {//如果key對應的消息沒人訂閱,則直接返回
return false;
}
if (!fn) {//如果沒有傳入具體的回調函數,表示需要取消key對應消息的所有訂閱
fns&&(fns.length=0);
}else{
for (var i = fns.length - 1; i >= 0; i--) {//反向遍歷訂閱的回調函數列表
var _fn=fns[i];
if (_fn===fn) {
fns.splice(i,1);//刪除訂閱者的回調函數
}
}
}
}
};
只有這個事件訂閱發布對象沒有多大作用,我們要做的是給任意的對象都能添加上發布-訂閱的功能: 在ES6中可以使用Object.assign(target,source)
方法合並對象功能。如果不支持ES6可以自行設計一個拷貝函數如下:
var installEvent=function(obj){
for(var i in event){
if(event.hasOwnProperty(i))
obj[i]=event[i];
}
};
上述的函數就能給任意對象添加上事件發布-訂閱功能。下面我們測試一下,假如你家里養了一只喵星人,現在它餓了。
var Cat={};
//Object.assign(Cat,event);
installEvent(Cat);
Cat.listen('hungry',function(){
console.log("鏟屎的,快把朕的小魚干拿來!")
});
Cat.trigger('hungry');//鏟屎的,快把朕的小魚干拿來!
自定義發布-訂閱模式介紹完了。 這種方法的優點是比較容易理解,可以綁定多個事件,每個事件可以指定多個回調函數。缺點是整個程序都要變成事件驅動型,運行流程會變得很不清晰。
三、使用Promise對象
ES6標准中實現的Promise是異步編程的一種解決方案,比傳統的解決方案——回調函數和事件——更合理和更強大。 所謂Promise
,就是一個對象,用來傳遞異步操作的消息。它代表了某個未來才會知道結果的事件,並且這個事件提供統一的API,各種異步操作都可以用同樣的方法進行處理。
Promise
對象有以下兩個特點。 (1)對象的狀態不受外界影響。Promise
對象代表一個異步操作,有三種狀態:Pending
(進行中)、Resolved
(已完成,又稱Fulfilled)和Rejected
(已失敗)。只有異步操作的結果,可以決定當前是哪一種狀態,任何其他操作都無法改變這個狀態 (2)一旦狀態改變,就不會再變,任何時候都可以得到這個結果。Promise
對象的狀態改變,只有兩種可能:從Pending
變為Resolved
和從Pending
變為Rejected
。只要這兩種情況發生,狀態就凝固了,不會再變了,會一直保持這個結果。就算改變已經發生了,你再對Promise
對象添加回調函數,也會立即得到這個結果。這與事件(Event)完全不同,事件的特點是,如果你錯過了它,再去監聽,是得不到結果的。 有了Promise
對象,就可以將異步操作以同步操作的流程表達出來,避免了層層嵌套的回調函數。 下面以一個Ajax請求為例,Cnode社區的API中有這樣一個流程,首先根據accesstoken獲取用戶名,然后可以根據用戶名獲取用戶收藏的主題,如果我們想得到某個用戶收藏的主題數量就要進行兩次請求。如果不使用Promise對象,以Jquery的ajax請求為例:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Promise</title>
</head>
<body>
</body>
<script type="text/javascript" src="http://apps.bdimg.com/libs/jquery/1.7.2/jquery.min.js"></script>
<script type="text/javascript">
$.post("https://cnodejs.org/api/v1/accesstoken",{
accesstoken:"XXXXXXXXXXXXXXXXXXXXXXXXXXX"
},function (res1) {
$.get("https://cnodejs.org/api/v1/topic_collect/"+res1.loginname,function(res2){
alert(res2.data.length);
});
});
</script>
</html>
從上述代碼中可以看出,兩次請求相互嵌套,如果改成用Promise對象實現:
function post(url,para){
return new Promise(function(resolve,reject){
$.post(url,para,resolve);
});
}
function get(url,para){
return new Promise(function(resolve,reject){
$.get(url,para,resolve);
});
}
var p1=post("https://cnodejs.org/api/v1/accesstoken",{
accesstoken:"XXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
});
var p2=p1.then(function(res){
return get("https://cnodejs.org/api/v1/topic_collect/"+res.loginname,{});
});
p2.then(function(res){
alert(res.data.length);
});
可以看到前面代碼中的嵌套被解開了,(也許有人會說,這代碼還變長了,坑爹嗎這是,請不要在意這些細節,這里僅舉例說明)。關於Promise對象的具體用法還有很多知識點,建議查找相關資料深入閱讀,這里僅介紹它作為異步編程的一種解決方案。
四、使用Generator函數
關於Generator函數的概念可以參考阮大神的ES6標准入門,Generator可以理解為可在運行中轉移控制權給其他代碼,並在需要的時候返回繼續執行的函數,看下面一個簡單的例子:
function* helloWorldGenerator(){
yield 'hello';
yield 'world';
yield 'ending';
}
var hw=helloWorldGenerator();
console.log(hw.next());
console.log(hw.next());
console.log(hw.next());
console.log(hw.next());
// { value: 'hello', done: false }
// { value: 'world', done: false }
// { value: 'ending', done: false }
// { value: undefined, done: true }
Generator函數的調用方法與普通函數一樣,也是在函數名后面加上一對圓括號。不同的是,調用Generator函數后,該函數並不執行,返回的也不是函數運行結果,而是一個遍歷器對象(Iterator Object)。 下一步,必須調用遍歷器對象的next方法,使得指針移向下一個狀態。也就是說,每次調用next
方法,內部指針就從函數頭部或上一次停下來的地方開始執行,直到遇到下一個yield
語句(或return
語句)為止。換言之,Generator函數是分段執行的,yield
語句是暫停執行的標記,而next
方法可以恢復執行。 Generator函數的暫停執行的效果,意味着可以把異步操作寫在yield語句里面,等到調用next方法時再往后執行。這實際上等同於不需要寫回調函數了,因為異步操作的后續操作可以放在yield語句下面,反正要等到調用next方法時再執行。所以,Generator函數的一個重要實際意義就是用來處理異步操作,改寫回調函數。 如果有一個多步操作非常耗時,采用回調函數,可能會寫成下面這樣。
step1(function (value1) {
step2(value1, function(value2) {
step3(value2, function(value3) {
step4(value3, function(value4) {
// Do something with value4
});
});
});
});
采用Promise改寫上面的代碼。(下面的代碼使用了Promise的函數庫Q)
Q.fcall(step1)
.then(step2)
.then(step3)
.then(step4)
.then(function (value4) {
// Do something with value4
}, function (error) {
// Handle any error from step1 through step4
})
.done();
上面代碼已經把回調函數,改成了直線執行的形式,但是加入了大量Promise的語法。Generator函數可以進一步改善代碼運行流程。
function* longRunningTask() {
try {
var value1 = yield step1();
var value2 = yield step2(value1);
var value3 = yield step3(value2);
var value4 = yield step4(value3);
// Do something with value4
} catch (e) {
// Handle any error from step1 through step4
}
}
如果只有Generator函數,任務並不會自動執行,因此需要再編寫一個函數,按次序自動執行所有步驟。
scheduler(longRunningTask());
function scheduler(task) {
setTimeout(function() {
var taskObj = task.next(task.value);
// 如果Generator函數未結束,就繼續調用
if (!taskObj.done) {
task.value = taskObj.value
scheduler(task);
}
}, 0);
}
五、使用async函數
在ES7(還未正式標准化)中引入了Async函數的概念,async函數的實現就是將Generator函數和自動執行器包裝在一個函數中。如果把上面Generator實現異步的操作改成async函數,代碼如下:
async function longRunningTask() {
try {
var value1 = await step1();
var value2 = await step2(value1);
var value3 = await step3(value2);
var value4 = await step4(value3);
// Do something with value4
} catch (e) {
// Handle any error from step1 through step4
}
}
正如阮一峰在博客中所述,異步編程的語法目標,就是怎樣讓它更像同步編程,使用async/await的方法,使得異步編程與同步編程看起來相差無幾了。
六、借助流程控制庫
隨着Node開發的流行,NPM社區中出現了很多流程控制庫可以供開發者直接使用,其中很流行的就是async庫,該庫提供了一些流程控制方法,注意這里所說的async並不是標題五中所述的async函數。而是第三方封裝好的庫。其官方文檔見http://caolan.github.io/async/docs.html async為流程控制主要提供了waterfall(瀑布式)、series(串行)、parallel(並行)
- 如果需要執行的任務緊密結合。下一個任務需要上一個任務的結果做輸入,應該使用瀑布式
- 如果多個任務必須依次執行,而且之間沒有數據交換,應該使用串行執行
- 如果多個任務之間沒有任何依賴,而且執行順序沒有要求,應該使用並行執行 關於async控制流程的基本用法可以參考官方文檔或者Async詳解之一:流程控制 下面我舉一個例子說明:假設我們有個需求,返回100加1再減2再乘3最后除以4的結果,而且每個任務需要分解執行。 1.使用回調函數
function add(fn) {
var num=100;
var result=num+1;
fn(result)
}
function minus(num,fn){
var result=num-2;
fn(result);
}
function multiply(num,fn){
var result=num*3;
fn(result);
}
function divide(num,fn){
var result=num/4;
fn(result);
}
add(function (value1) {
minus(value1, function(value2) {
multiply(value2, function(value3) {
divide(value3, function(value4) {
console.log(value4);
});
});
});
});
從上面的結果可以看到回調嵌套很深。 2.使用async庫的流程控制 由於后面的任務依賴前面的任務執行的結果,所以這里要使用watefall方式。
var async=require("async");
function add(callback) {
var num=100;
var result=num+1;
callback(null, result);
}
function minus(num,callback){
var result=num-2;
callback(null, result);
}
function multiply(num,callback){
var result=num*3;
callback(null, result);
}
function divide(num,callback){
var result=num/4;
callback(null, result);
}
async.waterfall([
add,
minus,
multiply,
divide
], function (err, result) {
console.log(result);
});
可以看到使用流程控制避免了嵌套。
七、使用Web Workers
Web Worker是HTML5新標准中新添加的一個功能,Web Worker的基本原理就是在當前javascript的主線程中,使用Worker類加載一個javascript文件來開辟一個新的線程,起到互不阻塞執行的效果,並且提供主線程和新線程之間數據交換的接口:postMessage,onmessage。其數據交互過程也類似於事件發布/監聽模式,異能實現異步操作。下面的示例來自於紅寶書,實現了一個數組排序功能。 頁面代碼:
<!DOCTYPE html>
<html>
<head>
<title>Web Worker Example</title>
</head>
<body>
<script>
(function(){
var data = [23,4,7,9,2,14,6,651,87,41,7798,24],
worker = new Worker("WebWorkerExample01.js");
worker.onmessage = function(event){
alert(event.data);
};
worker.postMessage(data);
})();
</script>
</body>
</html>
Web Worker內部代碼
self.onmessage = function(event){
var data = event.data;
data.sort(function(a, b){
return a - b;
});
self.postMessage(data);
};
把比較消耗時間的操作,轉交給Worker操作就不會阻塞用戶界面了,遺憾的是Web Worker不能進行DOM操作。
參考文獻 Javascript異步編程的4種方法-阮一峰 《You Don't Know JS:Async&Performance》 《JavaScript設計模式與開發實踐》-曾探 《深入淺出NodeJS》-朴靈 《ES6標准入門-第二版》-阮一峰 《JavaScript Web 應用開發》-Nicolas Bevacqua 《JavaScript高級程序設計第3版》