Javascript中常見(jiàn)的異步編程模型

2018-06-09 18:24 更新

Javascript異步編程專(zhuān)題,目前包含以下幾篇文章,


本篇文章是本專(zhuān)題的第二篇文章。

正文開(kāi)始。

在Javascript異步編程專(zhuān)題的前一篇文章淺談Javascript中的異步中,我簡(jiǎn)明的闡述了“Javascript中的異步原理”、“Javascript如何在單線程上實(shí)現(xiàn)異步調(diào)用”以及“Javascript中的定時(shí)器”等相關(guān)問(wèn)題。

本篇文章我將會(huì)談一談Javascript中常用的幾種異步編程模型。

在前端的代碼編寫(xiě)中,異步的場(chǎng)景隨處可見(jiàn)。比如鼠標(biāo)點(diǎn)擊、鍵盤(pán)回車(chē)、網(wǎng)絡(luò)請(qǐng)求等這些與瀏覽器緊密聯(lián)系的操作,比如一些延遲交互特效等等。

在這些場(chǎng)景中,你必須要使用所謂的“異步模式”,否則將會(huì)嚴(yán)重程序的可行性和用戶(hù)體驗(yàn)。我們列舉這些場(chǎng)景中常用的幾種異步編程模型,包括回調(diào)函數(shù)、事件監(jiān)聽(tīng)、觀察者模式(消息訂閱/發(fā)布)、promise模式。除此之外還會(huì)稍微介紹一番ES6(ES7)中新增的方案。

下面我們將針對(duì)每一種編程模型加以說(shuō)明。

回調(diào)函數(shù)

回調(diào)函數(shù)可以說(shuō)是Javascript異步編程最基本的方法。我們?cè)囅胗羞@樣一個(gè)場(chǎng)景,我們需要在頁(yè)面上展示一個(gè)持續(xù)3秒鐘的loading視覺(jué)樣式,然后在頁(yè)面上顯示我們真正想顯示的內(nèi)容。示例代碼如下,


// more code
function loading(callback) {
    // 持續(xù)3秒的loading展示
    setTimeout(function () {
        callback();
    }, 3000);
}
function show() {
    // 展示真實(shí)數(shù)據(jù)給用戶(hù)
}
loading(show);
// more code

代碼中的loading(show)就是將函數(shù)show()作為函數(shù)loading()的參數(shù)。在loading()完成3秒的loading之后,再去執(zhí)行回調(diào)函數(shù)(示例使用了setTimeout來(lái)模擬)。通過(guò)這種方法,show()就變成了異步調(diào)用,它的執(zhí)行時(shí)機(jī)被推遲到loading()即將完成之前。

回調(diào)函數(shù)的缺陷

回調(diào)函數(shù)往往就是調(diào)用用戶(hù)提供的函數(shù),該函數(shù)往往是以參數(shù)的形式提供的?;卣{(diào)函數(shù)并不一定是異步執(zhí)行的?;卣{(diào)函數(shù)的特點(diǎn)就是使用簡(jiǎn)單、容易理解。缺點(diǎn)就是邏輯間存在一定耦合。最?lèi)盒牡牡胤皆谟跁?huì)造成所謂的callback hell。比如下面這樣的一個(gè)例子,


A(function () {
    B(function () {
        C(function() {
            D(function() {
                // ...
            })
        })
    })
})

例子中A、B、C、D四個(gè)任務(wù)存在依賴(lài)關(guān)系,通過(guò)函數(shù)回調(diào)的方式,寫(xiě)出來(lái)的代碼就會(huì)變成上面的這個(gè)樣子。維護(hù)性和可讀性都非常糟糕。

除了回調(diào)嵌套的問(wèn)題之外,還可能會(huì)帶來(lái)另一個(gè)問(wèn)題,就是流程控制不方便。比如我們要發(fā)送3個(gè)請(qǐng)求,當(dāng)3個(gè)請(qǐng)求都返回時(shí),我們?cè)賵?zhí)行相關(guān)邏輯,那么代碼可能就是,


var count = 0
for (var i = 0; i < 3; i++) {
    request('source_' + i, function () {
        count++;
        if (count === 3) {
            // do my logic
        }
    });
}

上面的示例代碼中,我通過(guò)request對(duì)三個(gè)url發(fā)送了請(qǐng)求,但是我不知道這三個(gè)請(qǐng)求的返回情況。無(wú)奈之下我添加了一個(gè)計(jì)數(shù)器count,在每個(gè)請(qǐng)求的回調(diào)中都進(jìn)行計(jì)數(shù)器判斷,當(dāng)計(jì)數(shù)器為3時(shí)即表示三個(gè)請(qǐng)求都已經(jīng)成功返回了,此時(shí)再去執(zhí)行相關(guān)任務(wù)。顯而易見(jiàn),這種情況下的流程控制就顯得比較丑陋。

最后,有時(shí)候我們?yōu)榱顺绦虻慕研?,可能?huì)需要一個(gè)try...catch語(yǔ)法。比如,


// 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加在異步邏輯的外面,即使異步調(diào)用發(fā)生了異常我們也是捕獲不到的,因?yàn)?code>try...catch不能捕獲未來(lái)的異常。無(wú)奈,我們只能像demo2那樣將try...catch語(yǔ)句塊放在具體的異步邏輯內(nèi)。這樣一旦異步調(diào)用多起來(lái),那么就會(huì)多出來(lái)很多try...catch。這樣肯定是不好的。

除了上面這些問(wèn)題之外,我覺(jué)得回調(diào)函數(shù)真正的核心問(wèn)題在于,嵌套的回到函數(shù)往往會(huì)破壞整個(gè)程序的調(diào)用堆棧,并且像return,throw等這些用于代碼流程控制的關(guān)鍵詞都不能正常使用(因?yàn)榍耙粋€(gè)回調(diào)函數(shù)往往會(huì)影響到它后面所有的回調(diào)函數(shù))。

事件監(jiān)聽(tīng)

事件監(jiān)聽(tīng)在UI編程中隨處可見(jiàn)。比如我給一個(gè)按鈕綁定一個(gè)點(diǎn)擊事件,給一個(gè)輸入框綁定一個(gè)鍵盤(pán)敲擊事件等等。比如下面的代碼,


$('#button').on('click', function () {
    console.log('我被點(diǎn)了');
});

上面使用了JQuery的語(yǔ)法,給一個(gè)按鈕綁定了一個(gè)事件。當(dāng)事件觸發(fā)時(shí),會(huì)執(zhí)行綁定的邏輯。這比較容易理解。

除了界面事件之外,通常我們還有各種網(wǎng)絡(luò)請(qǐng)求事件,比如ajax,websocket等等。這些網(wǎng)絡(luò)請(qǐng)求在不同階段也會(huì)觸發(fā)各種事件,如果程序中有綁定相關(guān)處理邏輯,那么當(dāng)事件觸發(fā)時(shí)就會(huì)去執(zhí)行相關(guān)邏輯。

除此之外,我們還可以自定義事件。比如,


$('#div').on('data-loaded', function () {
    console.log('data loaded');
});
$('#div').trigger('data-loaded');

上面采用JQuery的語(yǔ)法,我們自定義了一個(gè)事件,叫做”data-loaded”,并在此事件上定義了一個(gè)觸發(fā)邏輯。當(dāng)我們通過(guò)trigger觸發(fā)這個(gè)事件時(shí),之前綁定的邏輯就會(huì)執(zhí)行了。

觀察者模式

之前在事件監(jiān)聽(tīng)中提到了自定義事件,其實(shí)自定義事件是觀察者模式的一種具體表現(xiàn)。觀察者模式,又稱(chēng)為消息訂閱/發(fā)布模式。它的含義是,我們先假設(shè)有一個(gè)“信號(hào)中心”,當(dāng)某個(gè)任務(wù)執(zhí)行完畢就向信號(hào)中心發(fā)出一個(gè)信號(hào)(事件),然后信號(hào)中心收到這個(gè)信號(hào)之后將會(huì)進(jìn)行廣播。如果有其他任務(wù)訂閱了該信號(hào),那么這些任務(wù)就會(huì)收到一個(gè)通知,然后執(zhí)行任務(wù)相關(guān)的邏輯。

下面是觀察者模式的一個(gè)簡(jiǎn)單實(shí)現(xiàn)(可參閱用AngularJS實(shí)現(xiàn)觀察者模式),


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);

觀察者模式的實(shí)現(xiàn)方式有很多,不過(guò)基本核心都差不多,都會(huì)有消息訂閱和發(fā)布。從本質(zhì)上說(shuō),前面所說(shuō)的事件監(jiān)聽(tīng)也是一種觀察者模式。

觀察者模式用好了自然好處多多,能夠把解耦做的相當(dāng)好。但是復(fù)雜的系統(tǒng)如果要用觀察者模式來(lái)做邏輯,必須要做好事件訂閱和發(fā)布的設(shè)計(jì),否則會(huì)導(dǎo)致程序的運(yùn)行流程混亂。

Promise模式

Promise嚴(yán)格來(lái)說(shuō)不是一種新技術(shù),它只是一種語(yǔ)法糖,一種機(jī)制,一種代碼結(jié)構(gòu)和流程,用于管理異步回調(diào)。

jQuery中的Promise實(shí)現(xiàn)源自Promises/A規(guī)范。使用promise來(lái)管理回調(diào),可以將回調(diào)邏輯扁平化,可以避免之前提到的回調(diào)地獄。示例代碼如下,


function fn1() {
    var dfd = $.Deferred();
    setTimeout(function () {
        console.log('fn1');
        dfd.resolve();
    }, 1000);
    return dfd.promise();
}
function fn2() {
    console.log('fn2');
}
fn1().then(fn2);

針對(duì)之前提到的回調(diào)地獄和異常難以捕獲的問(wèn)題,使用promise都可以輕松的解決。


A().then(B).then(C).then(D).catch(ERROR);

看,一行就搞定了。不過(guò)使用promise處理異步調(diào)用,有一點(diǎn)需要注意,就是所有的異步函數(shù)都要promise化。所謂promise化的意思就是需要對(duì)異步函數(shù)進(jìn)行封裝,讓其返回一個(gè)promise對(duì)象。比如,


function A() {
    var promise = new Promise(function (resolve, reject) {
        // your logic 
    });
    return promise;
}

ES6中的方案

ES6于今年6月份左右已經(jīng)正式發(fā)布了。其中新增了不少內(nèi)容。其中有兩項(xiàng)內(nèi)容可能用來(lái)解決異步回調(diào)的內(nèi)容。

ES6中的Promise

最新發(fā)布的ECMAScript2015中已經(jīng)涵蓋了promise的相關(guān)內(nèi)容,不過(guò)ES6中的Promise規(guī)范其實(shí)是Promise/A+規(guī)范,可以說(shuō)它是Promise/A規(guī)范的增強(qiáng)版。

現(xiàn)代瀏覽器Chrome,F(xiàn)irefox等已經(jīng)對(duì)Promise提供了原生支持。詳細(xì)的文檔可以參閱MDN。

簡(jiǎn)單來(lái)說(shuō),ES6中promise的內(nèi)容具體如下,

  • promise有三種狀態(tài):pending(等待)、fulfilled(成功)、rejected(失?。?。其中pending為初始狀態(tài)。
  • promise的狀態(tài)轉(zhuǎn)換只能是:pending->fulfilled或者pending->rejected。轉(zhuǎn)換方向不能顛倒,且fulfilled和rejected狀態(tài)不能相互轉(zhuǎn)換。每一種狀態(tài)轉(zhuǎn)換都會(huì)觸發(fā)相關(guān)調(diào)用。
  • pending->fulfilled時(shí),promise會(huì)帶有一個(gè)value(成功狀態(tài)的值);pending->rejected時(shí),promise會(huì)帶有一個(gè)reason(失敗狀態(tài)的原因)
  • promise擁有then方法。then方法必須返回一個(gè)promise。then可以多次鏈?zhǔn)秸{(diào)用,且回調(diào)的順序跟then的聲明順序一致。
  • then方法接受兩個(gè)參數(shù),分別是“pending->fulfilled”的調(diào)用和“pending->rejected”的調(diào)用。
  • then還可以接受一個(gè)promise實(shí)例,也可以接受一個(gè)thenable(類(lèi)then對(duì)象或者方法)實(shí)例。

總得來(lái)說(shuō)promise的內(nèi)容比較簡(jiǎn)單,涉及到三種狀態(tài)和兩種狀態(tài)轉(zhuǎn)換。其實(shí)promise的核心就是then方法的實(shí)現(xiàn)。

下面是來(lái)自MDN上Promise的代碼示例(稍作改動(dòng)),


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);
});

上述代碼的執(zhí)行結(jié)果是,先打印”p1 start”然后經(jīng)過(guò)2秒左右再次打印”p1 resolved”。

當(dāng)然我們還可以添加多個(gè)回調(diào)。我們可以通過(guò)在前一個(gè)then方法中調(diào)用return將promise往后傳遞。比如,


p1.then(function(v) {
    console.log('1: ', v);
    return v + ' 2';
}).then(function(v) {
    console.log('2: ', v);
});

不過(guò)在使用Promise的時(shí)候,有一些需要注意的地方,這篇文章We have a problem with promises翻譯文)中總結(jié)得很好,有興趣的可自行參閱。

不管是ES6中的promise還是jQuery中的promise/deferred,的確可以避免異步代碼的嵌套問(wèn)題,使整體代碼結(jié)構(gòu)變得清晰,不用再受callback hell折磨。但是也僅僅止步于此,因?yàn)樗](méi)有觸碰js異步回調(diào)真正核心的內(nèi)容。

現(xiàn)在業(yè)界有許多關(guān)于PromiseA+規(guī)范的實(shí)現(xiàn),不過(guò)博主個(gè)人覺(jué)得bluebird是個(gè)不錯(cuò)的庫(kù),可以值得一用,如果你有選擇困難癥,不妨試一試????????????

ES6中Generator

ES6中引入的Generator可以理解為一種協(xié)程的實(shí)現(xiàn)機(jī)制,它允許函數(shù)在運(yùn)行過(guò)程中將Javascript執(zhí)行權(quán)交給其他函數(shù)(代碼),并在需要的時(shí)候返回繼續(xù)執(zhí)行。

我們可以使用Generator配合ES6中Promise,進(jìn)一步將異步調(diào)用扁平化(轉(zhuǎn)化成同步風(fēng)格)。

下面我們來(lái)看一個(gè)例子,


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代碼中,我們定義了一個(gè)Generator函數(shù),且創(chuàng)建了一個(gè)promise,promise內(nèi)使用setTimeout模擬了一個(gè)異步任務(wù)。

接下來(lái)我們來(lái)執(zhí)行這個(gè)Generator函數(shù),因?yàn)?code>yield返回的是一個(gè)promise,所以我們需要使用then方法,


var g = gen();
var result = g.next();
result.value.then(function(str){
    console.log(str);
    // 對(duì)resolve的數(shù)據(jù)重新包裝,然后傳遞給下一個(gè)promise
    return {
        msg: str
    };
}).then(function(data){
    g.next(data);
});

最終的結(jié)果如下,


async task start
// 經(jīng)過(guò)2秒左右
async task end
{msg: 'async task end'}

其實(shí)關(guān)于Generator還有很多的內(nèi)容可以說(shuō),這里由于篇幅的關(guān)系就不展開(kāi)了。業(yè)界已經(jīng)有了基于Generator處理異步調(diào)用的功能庫(kù),比如co、task.js。

ES7中的async和await

在單線程的Javascript上做異步任務(wù)(甚至并發(fā)任務(wù))的確是一個(gè)讓人頭疼的問(wèn)題,總會(huì)越到各種各樣的問(wèn)題。從最早的函數(shù)回調(diào),到Promise,再到Generator,涌現(xiàn)的各種解決方案,雖然都有所改進(jìn),但是仍然讓人覺(jué)得并沒(méi)有徹底的解決這個(gè)問(wèn)題。

舉個(gè)例子來(lái)說(shuō),我現(xiàn)在就是想讀取一個(gè)文件,這么簡(jiǎn)單的一件事,何必要考慮那么多呢?又是回調(diào),又是promise的,煩不煩吶。我就想像下面這么簡(jiǎn)單的寫(xiě)代碼,難道不行么?


function task() {
    var file1Content = readFile('file1path');
    var file2Content = readFile(fileContent);
    console.log(file2Content);
}

想要做的事情很簡(jiǎn)單,讀取第一個(gè)文件,它的內(nèi)容是要讀取的第二個(gè)文件的文件名。

值得慶幸的是,ES7中的asyncawait可以幫你做到這件事。不過(guò)要稍微改動(dòng)一下,


async function task() {
    var file1Content = await readFile('file1path');
    var file2Content = await readFile(fileContent);
    console.log(file2Content);
}

看,改動(dòng)的地方很簡(jiǎn)單,只要在task前面加上關(guān)鍵詞async,在函數(shù)內(nèi)的異步任務(wù)前添加await聲明即可。如果忽略這些額外的關(guān)鍵字,簡(jiǎn)直就是完完全全的同步寫(xiě)法嘛。

其實(shí),這種方式就是前端提到的Generator和Promise方案的封裝。ECMAScript組織也認(rèn)為這是目前解決Javascript異步回調(diào)的最佳方案,所以可能會(huì)在ES7中將其納入到規(guī)范中來(lái)。需要注意的是,這項(xiàng)特性是ES7的提案,依賴(lài)Generator,所以慎用(目前來(lái)說(shuō)基本用不了)!

fibjs

除了上述的幾種方案之外,其實(shí)還有另外一種方案。就是使用協(xié)程的方案來(lái)解決單線程上的異步調(diào)用問(wèn)題。

之前我們也提到過(guò),Generator的yield可以暫停函數(shù)執(zhí)行,將執(zhí)行權(quán)臨時(shí)轉(zhuǎn)交給其他任務(wù),待其他任務(wù)完畢之后,再交還回執(zhí)行權(quán)。這其實(shí)就是協(xié)程的基本模型。

業(yè)界有一款基于V8引擎的服務(wù)端開(kāi)發(fā)框架fibjs,它的實(shí)現(xiàn)機(jī)制跟Node.js是不一樣的。fibjs采用fiber解決v8引擎的多路復(fù)用,并通過(guò)大量c++組件,將重負(fù)荷運(yùn)算委托給后臺(tái)線程,釋放v8線程,爭(zhēng)取更大的并發(fā)時(shí)間。

一句話,fibjs從底層,使用的纖程模型解決了異步調(diào)用的問(wèn)題。關(guān)于fibjs,有興趣的話可以查閱相關(guān)資料。不過(guò)我個(gè)人對(duì)它是持謹(jǐn)慎態(tài)度的。原因是如下兩點(diǎn),

  • 生態(tài)原因。
  • 使用了js,但是又摒棄了js的異步。

不過(guò)還是可以作為興趣去研究一下的。


以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)