1. 程式人生 > >Javascript中常見的非同步程式設計模型

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。這樣肯定是不好的。

除了上面這些問題之外,我覺得回撥函式真正的核心問題在於,巢狀的回到函式往往會破壞整個程式的呼叫堆疊,並且像returnthrow等這些用於程式碼流程控制的關鍵詞都不能正常使用(因為前一個回撥函式往往會影響到它後面所有的回撥函式)。

事件監聽

事件監聽在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。轉換方向不能顛倒,且fulfilledrejected狀態不能相互轉換。每一種狀態轉換都會觸發相關呼叫。
  • 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方法中呼叫returnpromise往後傳遞。比如,

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中的asyncawait

在單執行緒的Javascript上做非同步任務(甚至併發任務)的確是一個讓人頭疼的問題,總會越到各種各樣的問題。從最早的函式回撥,到Promise,再到Generator,湧現的各種解決方案,雖然都有所改進,但是仍然讓人覺得並沒有徹底的解決這個問題。

舉個例子來說,我現在就是想讀取一個檔案,這麼簡單的一件事,何必要考慮那麼多呢?又是回撥,又是promise的,煩不煩吶。我就想像下面這麼簡單的寫程式碼,難道不行麼?

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

想要做的事情很簡單,讀取第一個檔案,它的內容是要讀取的第二個檔案的檔名。

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

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

除了上述的幾種方案之外,其實還有另外一種方案。就是使用協程的方案來解決單執行緒上的非同步呼叫問題。

之前我們也提到過,Generatoryield可以暫停函式執行,將執行權臨時轉交給其他任務,待其他任務完畢之後,再交還回執行權。這其實就是協程的基本模型。

業界有一款基於V8引擎的服務端開發框架fibjs,它的實現機制跟Node.js是不一樣的。fibjs採用fiber解決v8引擎的多路複用,並通過大量c++元件,將重負荷運算委託給後臺執行緒,釋放v8執行緒,爭取更大的併發時間。

一句話,fibjs從底層,使用的纖程模型解決了非同步呼叫的問題。關於fibjs,有興趣的話可以查閱相關資料。不過我個人對它是持謹慎態度的。原因是如下兩點,

  • 生態原因。
  • 使用了js,但是又摒棄了js的非同步。

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