1. 程式人生 > >前端非同步程式設計系列之事件釋出/訂閱模式(2/4)

前端非同步程式設計系列之事件釋出/訂閱模式(2/4)

上一篇文章中,主要是介紹了什麼是非同步程式設計,而這從這篇文章開始,我會介紹一些非同步程式設計的一些解決方案。

目前非同步程式設計的解決方案主要有一下幾種:

1.事件釋出/訂閱模式

2.Promise/Deferred模式

3.流程控制庫

而我們這一篇文章主要是介紹第一種,即事件釋出/訂閱模式,後續會介紹Promise/Deferred模式以及es6的Promise實現,至於第三種,我並不是太過於瞭解,所以,近期不會介紹。

好了,話不都說,下面就開始介紹事件釋出/訂閱模式是如何改善我們的非同步程式設計的。




事件釋出/訂閱模式

說到前端的事件,腦子第一想到的估計就是js給dom新增的那些事件,但是,這裡的非同步程式設計靠的並不是那些已經被js定義好的那些事件,真正用於非同步程式設計的是事件釋出/訂閱的這種模式:

在這個模式中,會存在一個事件物件,他的作用在於釋出事件,叫做釋出者。

還有一個叫做觀察者(或者訂閱者)的用來訂閱釋出者所釋出出來的事件。

當釋出者中所釋出的某一個事件發生時,釋出者會通知(其實就是呼叫)所有訂閱了這個事件的觀察者。

其實他們的關係就好比雜誌社和讀者的關係:

雜誌社釋出了一種雜誌,讀者可以訂閱這種雜誌。而每當雜誌社每個月釋出最新的一期的雜誌時,都會把最新的雜誌寄給所有訂閱了這種雜誌的讀者。就是這麼個關係。

在這裡或者說在前端中,事件物件(釋出者)是一個物件,他可以釋出事件,取消事件,觸發事件,並能夠通知所有訂閱了這個事件的觀察者。而觀察者說白了就是一個函式、方法,當釋出者觸發了某個事件時,通知觀察者其實就等價於呼叫這個函式罷了。

好,現在來說一說這種事件釋出/訂閱模式和非同步程式設計的關係以及他為什麼會適合非同步程式設計的思想

 非同步程式設計在執行了某個非同步操作時,直接返回,不會再去理會,而我們只需要為其新增一個回撥函式,用於當非同步操作結束時,再執行這個回撥函式即可。

而事件模式和這種非同步思維極其類似。我訂閱某個事件,並把事件發生時的處理函式作為觀察者新增上去,當事件發生時,釋出者會執行觀察者這個處理函式。他是回撥函式的事件化,極其類似卻更加靈活。

熟悉前端的都非常熟悉dom事件了,不過,其實專門用於非同步程式設計的事件釋出/訂閱模式,可是比前端大量dom事件更為簡單,因為使用者非同步程式設計的事件訂閱/釋出模式不存在事件捕獲,冒泡,preventDefault()以及sotpPropagation()這些東西。所以,是不是聽起來鬆了一口氣。確實是這樣,因為非同步程式設計的事件釋出/訂閱模式根據上面所解釋的,只需要以下幾個函式:

on  使用者註冊觀察者

removeListener  刪除某一個事件的所有觀察者(其實就等於刪除這個事件了)

emit  觸發某一個事件,從而呼叫這個事件的所有觀察者。

// 其他

once  註冊只執行一次的事件,只觸發一次就刪除的事件

removeAllListener  刪除這個事件物件的所有事件。

我們來看一下事件訂閱/釋出模式的使用:

// 訂閱
emitter.on("event1", function (message) {
    console.log(message);
});
// 觸發
emitter.emit('event1', "I am message!");
// 列印 I am message!

其中emitter.on方法中第一個引數是訂閱的事件名稱,第二個引數是事件發生時的處理函式,也就是觀察者。而emitter.emit則是用來觸發event1這個事件,"I am message!" 是傳遞的引數,這個引數會傳遞給觀察者。

一個事件可以有多個觀察者,也就是可以有多個處理函式,並且事件和觀察者可以隨意的刪除和修改,非常的靈活,並且事件發生和處理函式之間可以很方便的解耦。我不管這個事件發生時怎麼去處理,也不用管這個事件有多少個觀察者,而且資料通過訊息引數的方式可以很靈活的傳遞。

那麼事件訂閱/釋出模式他對比普通的回撥函式,可以解決非同步程式設計的哪些問題呢?

 

 

 

 

 

 

 

 

1.利用事件解決雪崩問題

首先這一個是上述非同步程式設計存在的問題中所沒有提及的,因為他也不算是非同步本身程式設計的問題吧。那到底是啥問題呢?

這個例子是典型,所以我直接從書中拿來用了啊:

在計算機中,通常快取用於加速對同一資料的重複請求,所謂雪崩問題就是在高訪問,大併發的情況下,快取失效的情景。這時候,大量資料請求同時訪問伺服器,伺服器無法同時處理這麼多的處理請求,導致影響網站整體響應速度。

比如請求資料庫:

var select = function (callback) {
    db.select("SQL", function (results) {
        callback(results);
    });
};

如果這時候,站點剛好啟動,那麼快取不存在,那麼同一條sql會被反覆在資料庫中查詢,影響服務的整體效能(這裡查詢出來的資料設定為都是相同的結果)。

那麼我們應該在第一條資料請求時才真正執行查詢資料庫,而後續的請求如果發現如果已經有一條資料庫在執行了,那麼就應該等待第一條資料請求返回結果,然後讓後續的請求都直接使用這個結果即可。這樣想是不是比較合理,那麼該如何實現?

這時候可以使用事件的once方法這種執行一次就刪除的特點來很方便的實現(事件佇列)。

var event= new events;
var status = "ready";  //狀態鎖  ready為準備中  pending為執行中
var select = function (callback) {
    event.once("selected", callback);
    if (status === "ready") {
         status = "pending";
         db.select("SQL", function (results) {
            event.emit("selected", results);
            status = "ready";
         });
     }
};

上述程式碼中,使用status來標識,判斷是否有請求去訪問資料庫了,並且把所有請求的回撥都作為觀察者,觀察selected事件的發生。當第一條請求A進入時,執行select方法,把回撥新增至觀察者,然後發現status為ready,也就是沒有請求在查詢資料庫,那麼就會去查詢資料庫,並把狀態設定為正在請求“pending”。然後立馬第二條請求進來,發現已經在查詢資料庫了,那麼就不再查詢,只是添加回調在觀察者佇列中。然後等待資料庫查詢出結果後,會觸發selected事件,把查詢出的結果傳遞給所有請求的回撥函式,並執行回撥,並更改status的狀態。

這裡針對相同的sql語句,保證查詢開始到結束的過程永遠只有一次。而其他請求只需要等待查詢返回結果即可。這樣,可以節省重複資料請求的開銷,而且不止用於快取失效的情況,還可以用於有些時候不太好設定快取的情況。

上面這個例子是《深入淺出node.js》書籍中的非同步程式設計解決方案中的。然後說一下我在實際中遇到的一個類似問題,感覺也可以使用這種方式解決:

在我寫一個抽獎小程式時,後臺大佬,為了跟隨微信官方小程式的登陸標準,從而商量使用登陸態(一串字串)而不是openid來作為使用者的登陸狀態。這個登陸態啊,和openid不一樣,他對於使用者來說不是唯一的,而且還設定了一天的時間限制,一天沒有登陸就失效了。而且,每個介面都在請求時,都需要傳遞登陸態過去驗證。其實吧,這本身也沒什麼大問題,不過是為了舉例從而拿出來說而已:

1.介面A,B,C請求時都需要登入態。

2.而登入態沒有傳遞,或者登入態過期時,都會返回一個300給我。

3.如果返回300了,這時候,我會請求wx.login獲取code並且使用code向後臺請求login介面獲取到新的登陸態(這個介面不需要傳登陸態)然後,再使用這個新的登陸態重新請求那個A,B,C,並設定登陸態快取。

﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋我是華麗分割線﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊

┈━═┈━═┈━═┈━═┈━═☆、┈━═┈━═┈━═┈━═┈━═☆、┈━═┈━═┈━═┈━═┈━═☆、┈━═┈━═┈━═┈━═┈━═☆、

看著沒毛病是吧,但是問題在這裡:介面的請求,不是一起的,沒有任何關聯,也就是A,B,C三個介面是分開請求的,那麼:

我第一次進入小程式時,或者快取登陸態過期的時候,那麼:

A介面的請求行為:請求A介面 -> 報300 -> wx.lonin並請求login介面獲取新登陸態 -> 使用新登陸態再請求A介面並設定登陸態快取。

然而麻煩的是,剛進入小程式頁面時,A,B,C介面同時請求,各不相干,而且絕對會全部報300(因為剛進入小程式沒有登陸態快取),但他們三個互相不知道,是獨立的,所以B,C也同時走了一遍和介面A一樣的請求行為,也就是全部都重新請求了一遍新的登陸態。然而這會有一點點影響頁面載入的速度的,雖然問題不大但例子卻很典型。

這時候如果使用一般的解決辦法:

那也無非就是用一個數組,然後報300時,判斷如果正在請求新的登陸態了,那麼就把這個請求介面函式給存到陣列中先存著,然後等登陸態請求到了,在把這些請求從陣列中取出來請求一下。

看到這,不就是上面資料庫中使用事件訂閱/釋出模式的once方法解決的問題一樣的套路嗎:在A(或者其他請求)請求返回300時,把A請求新增到一個名為logining的once事件中,請求新的登陸態(這時候會刪除掉快取,並且有一個登陸態請求中的status欄位),並等待新的登陸態返回,然後B,C如果也返回了300,這時候發現登陸態正在請求中,那麼也把B,C請求新增到logining這個once事件中,等待新的登陸態請求返回。等待新的登陸態請求返回了,就直接觸發logining事件,重新用新的登陸態請求A,B,C介面,並把status設定為準備中。這樣就不用多次請求新的登陸態了。

此處可能還有一種情況,A在重新請求登陸態時,B,C可能等到A重新請求到新的登陸態了,B,C還沒有返回狀態,這時候,當B,C請求返回時,A已經使用了新的登陸態,並且,沒有介面正在請求新的登陸態,那麼B又會重新進行請求新的登陸態的行為,這明顯不是想要的。所以,這時候可以在介面重新請求新登陸態之前加一個判斷,判斷快取中的登陸和我這一次失敗時所使用的登陸態是否是一樣的,如果是一樣的,那麼就請求新的登陸態,如果不是,那麼使用快取中登陸態的再次重新請求這個介面。至於如何拿到我這次請求所使用的登陸態,可以根據你的程式碼編寫情況看是否拿得到,或者和後端商量,在發現登陸態失效時,再把這個登陸態再返回給你即可。

這是事件釋出/訂閱模式可以解決的第一個問題:重複請求相同資料的問題。並且實現起來相當容易

2.程式碼巢狀過深和多非同步協同問題

程式碼巢狀過深和多非同步協同問題我在我的關於非同步程式設計的第一篇文章:何為非同步程式設計 中已經提出來了,即如果每個操作依賴於另外一個非同步操作的結果,那麼可能多個非同步操作可能會出現許多個非同步回撥的巢狀過深的問題,而多非同步協同問題指:如果一個操作依賴於多個非同步操作的返回值,那麼如何既利用非同步操作帶來的效能提升,又能夠避免巢狀,寫出優美的程式碼呢?我們可以嘗試使用事件釋出/訂閱模式來解決一下這些問題。

1.使用事件釋出/訂閱模式解決巢狀問題:以nodejs中讀取檔案為例,先讀取test.txt檔案內容,然後以test.txt檔案中內容為路徑,請求test2.txt檔案的資料,並打印出來

test.txt檔案的內容為:./test2.txt

test2.txt檔案的內容為:我是test2檔案的內容

const fs = require("fs");  // nodejs 檔案模組(用於操作檔案的模組)
const event = require("events");  // nodejs 的事件模組
const readFileEvent = new event.EventEmitter();  //建立事件物件
// 監聽事件
readFileEvent.on("readText1Succeed",(data) => {
    let file1Data = data;
    // 根據讀取到的test.txt檔案的內容為地址,讀取test2.txt檔案的內容。
    fs.readFile(file1Data.toString(),(err,data) => {
        readFileEvent.emit("readText2Succeed",data)
    })
})
// 繫結檔案test2.txt讀取成功時的處理函式
readFileEvent.on("readText2Succeed",(data) => {
    console.log("檔案2的內容為:"+data.toString());
})

// 讀取test.txt的內容
fs.readFile("./test.txt",(err,data) => {
    readFileEvent.emit("readText1Succeed",data)
})
// 列印:
我是test2檔案的內容

以上就是使用事件釋出/訂閱模式來實現的檔案讀取操作。使用事件的訂閱,來監聽檔案的一的讀取成功,然後在檔案一讀取成功時,讀取檔案二,當檔案二讀取成功時觸發檔案二的成功事件,因為檔案讀取成功時的處理函式可以在別的地方通過on進行繫結,所以,就沒有多回調的巢狀問題。

那麼事件訂閱/釋出模式又是如何解決多非同步協同問題呢?在此之前,我們先來回顧一下需要多非同步協同的個api的呼叫過程:

1.wx.login登陸獲取code

2.使用code向後臺請求openid

3.使用openid獲取使用者繫結的門店,還需要通過openid獲取這個使用者的團購id(此處需要多非同步協同)

4.再用通過門店和團購id,請求該門店的團購商品。

他的問題在於:請求門店和請求團購id不在同一個介面中,第3步中,獲取門店和團購id他們都是兩個不同的非同步操作。但是,我的下一步4中的操作要依賴這兩個非同步的結果那麼如何既可以使用非同步請求帶來的效能提升又可以比較優美且不那麼麻煩的編寫和處理好程式碼呢?

一般的方法為使用哨兵變數,即用來記錄次數之類的變數:

(以下程式碼為了可理解性,使用了半偽程式碼表示)

var count = 0;  //哨兵變數
var results = {};  //儲存每個介面返回的結果的變數
var done = function (key, value) {
    results[key] = value;
    count++;
    if (count === 2) {
        // 當門店和團購id都獲取到時,執行4。
    }
}
使用openid獲取門店
        當獲取成功執行done函式:done('store',data)
使用openid獲取團購id
        當獲取成功執行done函式:done('bulkId',data)

上面程式碼中,獲取門店和獲取團購id成功返回時,都會判斷其他請求是否也返回了,當門店和團購id都返回時,才執行後面的第4步

一般這麼寫沒什麼問題的。不嫌麻煩也還可以。那麼看一下使用事件訂閱/釋出模式怎麼寫吧:

var events = require("events");
var emitter = new events.EventEmitter();  // 建立新的事件物件
var count = 0;  //哨兵變數
var results = {};  //儲存每個介面返回的結果的變數
var done = function (key, value) {
    results[key] = value;
    count++;
    if (count === 2) {
        // 當門店和團購id都獲取到時,執行4。
    }
}
emitter.on("done", done);
使用openid獲取門店
        當獲取成功時執行觸發done事件:emitter.emit("done", "store", data);
使用openid獲取團購id
        當獲取成功時執行觸發done事件:emitter.emit("done", "bulkId", data);

這種其實就是上一個例子的一種事件釋出/訂閱模式的寫法罷了。不過真正在實際用途中,還是應該要封裝起來再用的,不然就不怎麼舒服了。

比如在附件中的event.js中的all方法可以這樣使用:

event.all("store", "bulkId", function (store, bulkId) {
    // TODO
});

這個all其實就是對上面程式碼的一種封裝。只有當"store", "bulkId"這兩個事件全部都觸發一次時,all的第二個引數的處理函式才會被執行,並且,他的引數就是這兩個事件觸發時傳遞的引數。

還有一個和all不同的tail方法,他的區別在於all方法觸發時,處理函式只會執行一次,而tail的方法如果觸發了一次,監聽的"store", "bulkId"中任一一個事件再次觸發時,都會重新觸發tail的處理函式,並且傳遞的引數是使用最新的引數。

還有其他方法:after用於註冊,當一個事件觸發固定次數時,才會執行給after所新增的回撥函式。

以上就是事件釋出/訂閱模式針對巢狀回撥和多非同步協同這兩個非同步程式設計的典型問題所做出的改善。不過,我們不妨也來看看他對於非同步程式設計的異常處理,採取了一種怎樣的方式。

3.事件釋出訂閱的異常處理

非同步方法中,異常處理還是佔用了一定的精力的。回撥函式的異常處理一般無非就是如微信小程式介面中的,傳入一個成功時的回撥,和一個異常時的回撥函式。或者如node中的,只傳入一個回撥函式,第一個引數為err,如果有異常則err為異常物件,如果沒有異常,err為undefined。但是他們其實針對的異常捕獲都是針對於介面的呼叫時的異常捕獲,介面呼叫的成功或者失敗,而如果是我們寫的回撥函式中丟擲的異常,那他是無法捕獲到的,這一點要注意了。

而事件釋出/訂閱模式的異常處理通常為:

每一個事件物件中應該有一個error事件,我們給這個error事件新增處理函式,當發生異常時,應該觸發這個error異常事件,並把錯誤物件傳遞給error事件的處理函式(所有在這個事件物件中的事件,發生異常都觸發這個事件物件的error事件,不是每個單獨的事件都有error事件,而是這個事件物件的所有事件共享error事件,不過你可以通過傳遞給error處理函式中的引數新增一個事件型別名稱來標識是哪個事件觸發的錯誤,並且,最好是根據這個來,這樣,可以為不同事件處理不同的錯誤)。

一個異常處理的例子:如果讀取test.txt檔案時發生錯誤,那麼會觸發readFileEvent事件物件的error事件,進行錯誤處理。

fs.readFile("./test.txt",(err,data) => {
    if(err) {  //發生錯誤,觸發error事件
        readFileEvent.emit("error",err);  
        return;
    }
    //處理檔案內容
})
// 監聽error事件,進行錯誤處理
readFileEvent.on("error",(err) => {
    // 這裡進行錯誤處理
})

其實在使用時,可以進行一個程式碼約定,約定:當發生錯誤時,再傳遞一個發生錯誤的標示符,那麼只有當某個標示符匹配時,才執行相應的處理函式,比如:

fs.readFile("./test.txt",(err,data) => {
    if(err) {
        readFileEvent.emit("error",err,"testError")
        return;
    }
    //處理檔案內容
})
fs.readFile("./test2.txt",(err,data) => {
    if(err) {
        readFileEvent.emit("error",err,"test2Error")
        return;
    }
    //處理檔案內容
})
// 針對讀取檔案test時發生的錯誤處理
readFileEvent.on("error",(err,errorType) => {
    if(errorType == "testError") {
        //    錯誤:testError處理
    }
})
// 針對讀取檔案test2時發生的錯誤處理
readFileEvent.on("error",(err,errorType) => {
    if(errorType == "test2Error") {
        //    錯誤:test2Error處理
    }
})
// 當不傳遞錯誤型別時的錯誤處理,即,通用的錯誤處理
readFileEvent.on("error",(err,errorType) => {
    if(errorType === undefined) {
    //    錯誤處理
    }
})

以上大致就是事件釋出/訂閱模式的內容。其實事件的釋出訂閱模式相對於普通的非同步回撥函式來說,大致可以改善非同步程式設計的巢狀問題,多非同步協同以及還可以利用once方法解決佇列的雪崩問題。

不過,他其實還是有一些缺點存在的,比如:在解決回撥巢狀問題時,其實每一個非同步請求都需要事先設定好,一步一步的按照非同步介面的順序進行事件的監聽以及觸發,在一開始接觸時對於這種程式碼風格的改變還是有點不適應的,一旦再加上錯誤處理,而且想要精細的根據不同錯誤設定不同的處理方式,那就需要細心一點才能夠處理好程式碼的關係了。

所以,事件釋出/訂閱模式還有許多值得研究的地方,對於如何將這種模式能夠運用實際程式碼中,根據不同的需要使用他去解決實際的問題才是我們學習瞭解事件訂閱/釋出的根本目的。

附加上我自己對事件訂閱/釋出模式的一種實現,不過只是很基礎的,並且僅僅用作學習練習之用(好像csnd資源不能設定免積分下載啊,):https://download.csdn.net/download/qq_33024515/10598482

但是在生產環境中,應該使用那些成熟的事件庫類,比如:

eventproxy      github地址:https://github.com/JacksonTian/eventproxy
           EventEmitter    github地址:https://github.com/Olical/EventEmitter
 

下一篇我將會著重介紹由社群提出制定的Promise/Deferred模式,看一下由社群的力量所制定出來的規範,如何靠他來寫出優雅的非同步程式設計程式碼。

敬請期待!