使用EventEmitter2(觀察者模式)構建前端應用(一)

1. 前言

最近面試季,有很多同窗在面試前端的時候遇到一些問題來問個人的時候,才發現以前博客裏面介紹的關於前端架構有些東西沒有說清楚,特別是關於如何使用事件巧妙地進行模塊的解耦。特地寫這篇博客詳細說一下。html

原本想一篇寫完,可是寫着寫着發現廢話比較多。決定開個系列分2~3篇來寫,本文主要介紹:前端

  1. 觀察者模式在前端中的體現形式
  2. 事件在組件化的前端架構中的應用

這算是(一),接下來的(二)會介紹事件在前端遊戲開發中的應用。node

1.1 觀察者模式在前端中的表現形式——事件機制

(瞭解的同窗能夠直接跳過這一節)git

在構建前端應用的時候免不了要和事件打交道,有些同窗可能以爲事件不就是鼠標點擊執行特定的函數之類的嗎?程序員

此「事件」非彼「事件」。這裏的「事件」,其實是指「觀察者模式(Observer Pattern)」在前端的一種呈現方式。所謂觀察者模式能夠類比博客「訂閱/推送」,你經過RSS訂閱了某個博客,那麼這個博客有新的博文就會自動推送給你;當你退訂閱這個博客,那麼就不會再推送給你。github

用JavaScript代碼能夠怎麼表示這麼一個場景?面試

var blog = new Blog; // 假設已有一個Blog類實現subscribe、publish、unsubscribe方法

var readerFunc1 = function(blogContent) { 
    console.log(blogContent + " will be shown here.");
}
var readerFunc2 = function(blogContent) { 
    console.log(blogContent + " will be shown here, too.");
}

blog.subscribe(readerFunc1); // 讀者1訂閱博客
blog.subscribe(readerFunc2); // 讀者2訂閱博客

blog.publish("This is blog content."); // 發佈博客內容,上面的兩個讀者的函數都會被調用
blog.unsubscribe(readerFunc1); // 讀者1取消訂閱
blog.publish("This is another blog content."); // readerFunc1函數再也不調用,readerFunc2繼續調用

能夠把上面的「新文章」當作是一個事件,「訂閱文章」則是「監聽」這個事件,「發佈新文章」則是「觸發」這個事件,「取消訂閱文章」就是「取消監聽」「新文章」這個事件。假如「監聽」用on來表示,「觸發」用emit來表示,「取消監聽」用off來表示,那麼上面的代碼能夠從新表示爲:ajax

var blog = new Blog; // 假設已有一個Blog類實現on、emit、off方法

var readerFunc1 = function(blogContent) { 
    console.log(blogContent + " will be shown here.");
}
var readerFunc2 = function(blogContent) { 
    console.log(blogContent + " will be shown here, too.");
}

blog.on("new post", readerFunc1); // 讀者1監聽事件
blog.on("new post", readerFunc2); // 讀者2監聽事件

blog.emit("new post", "This is blog content."); // 發佈博客內容,觸發事件,上面的兩個讀者的函數都會被調用
blog.off("new post", readerFunc1); // 讀者1取消監聽事件
blog.emit("new post", "This is another blog content."); // readerFunc1函數再也不調用,readerFunc2繼續調用

這就是前端中觀察者模式的一種具體的表現,使用on來監聽特定的事件,emit觸發特定的事件,off取消監聽特定的事件。再舉一個場景「小貓聽到小狗叫就會跑」:npm

var dog = new Dog;
var cat = new Cat;

dog.on("park", function() { 
    cat.run(); 
});

dog.emit("park");

巧妙利用觀察者模式可讓前端應用開發耦合性變得更加低,開發效率更高。可能說「變得更有趣」會顯得有點不專業,但確實會變得有趣。編程

1.2 EventEmitter2

上面可能比較疑惑的一個點就是,onemitoff函數該怎麼實現?

若是要本身實現一遍也不很複雜:每一個「事件名」對應的就是一個函數數組,每次on某個事件的時候就是把函數壓到對應的函數數組當中;每次emit的時候至關於把事件名對應的函數數組遍歷一遍進行調用;每次off的時候把目標函數從數組當中剔除。這裏有個簡單的實現,有興趣的能夠了解一下。

重複發明輪子的事情就不要作了,其實現成有不少JavaScript的事件庫,直接拿來用就行了。比較流行、經常使用的就是EventEmitter2這個事件庫,本文主要使用這個庫來展開對觀察者模式在前端應用中的討論。但實際上,你可使用任何本身構建的或者第三方的事件庫來實踐本文所說起的應用方式

2. EventEmitter2 簡介

EventEmitter原本是Node.js中自帶的events模塊中的一個類,可見Node.js文檔。可供開發者自定義事件,後來有人把它從新實現了一遍,優化了實現方式,提升了性能,新增了一些方便的API,這就是EventEmitter2。固然,後來陸續出現了EventEmitter3EventEmitter4。可見沒有女友的程序員也是比較無聊地只好重複發明和優化輪子。

EventEmitter2能夠供瀏覽器、或者Node.js使用。安裝過程和API就不在這裏累述,參照官方文檔便可。使用Browserify或者Node.js能夠很是方便地引用EvenEmitter2,只須要require便可。示例:

var EventEmitter2 = require('eventemitter2').EventEmitter2;
var emitter = new EventEmitter2;

emitter.on("Hello World", function() {
    console.log("Somebody said: Hello world.");
});

emitter.emit("Hello World"); // 輸出 Somebody said: Hello world.

2.1 EventEmitter2做爲父類給給子類提供事件機制

但在實際應用當中,不多單純EventEmitter直接實例化來使用。比較多的應用場景是,爲某些已有的類添加事件的功能。如上面的第一章中的「小貓聽到小狗叫就會跑」的例子,CatDog類自己就有本身的類屬性、方法,須要的是爲已有的Cat、Dog添加事件功能。這裏就須要讓EventEmitter做爲其餘類的父類進行繼承。

var EventEmitter2 = require('eventemitter2').EventEmitter2;

// Cat子類繼承父類構造字
function Cat() {
    EventEmitter2.apply(this);
    // Cat 構造子,屬性初始化等
} 

// 原型繼承
Cat.prototype = Object.create(EventEmitter2.prototype); 
Cat.prototype.constructor = Cat; 

// Cat類方法
Cat.prototype.run = function () {
    console.log("This cat is running...");
}

var cat = new Cat;
console.assert(typeof cat.on == "function"); // => true
console.assert(typeof cat.run == "function"); // => true

很棒是吧,這樣就能夠即有EventEmitter2的原型方法,也能夠定義Cat自身的方法。

這一點都不棒!每次定義一個類都要從新寫一堆囉嗦的東西,下面作個繼承的改進:構建一個函數,只須要傳入已經定義好的類就能夠在不影響類原有功能的狀況下,讓其擁有EventEmitter2的功能:

// Function `eventify`: Making a class get power of EventEmitter2!
// @copyright: Livoras
// @date: 2015/3/27
// All rights reserve!

function eventify(klass) {
    if (klass.prototype instanceof EventEmitter2) {
        console.warn("Class has been eventified!");
        return klass;
    }

    function Tempt() {
        klass.apply(this, arguments);
        EventEmitter2.call(this);
    };
    function Tempt2() {};

    Tempt2.prototype = Object.create(EventEmitter2.prototype)
    Tempt2.prototype.constructor = EventEmitter2;

    var temptProp = Object.create(Tempt2.prototype);
    var klassProp = klass.prototype;

    for (var attr in klassProp) {
        temptProp[attr] = klassProp[attr];
    }

    Tempt.prototype = temptProp;
    Tempt.prototype.constructor = klass;

    return Tempt;
}

上面的代碼能夠的實現原理在這裏並不重要的,有興趣的能夠接下來的博客,會繼續討論eventify的實現原理。在這裏只須要知道,有了eventify就能夠很方便的給類添加EventEmitter2的功能,使用方法以下:

// Dog類的構造函數和原型方法定義
function Dog(name) {
  this.name = name;
}

Dog.prototype.park = function() {
  console.log(this.name + " parking....");
}

// 使Dog具備EventEmitter2功能
Dog = eventify(Dog);
var dog = new Dog("Jerry");

dog.on("somebody is coming", function() {
    dog.park();
})

dog.emit("somebody is coming") // 輸出 Jerry is parking....

如上面的代碼,如今沒有必要爲Dog類從新書寫類繼承代碼,只須要按正常的方式定義好Dog類,而後傳入eventify函數便可使Dog獲取EventEmitter2的功能。本文接下來的討論會持續使用eventify函數。

注意:若是你正在使用CoffeeScript,直接使用CoffeeScript自帶的extends進行類繼承便可,無需上面複雜的代碼:

class Dog extends EventEmitter2
    constructor: ->
        super.apply @, arguments
    park: ->
        // ...

3. EventEmitter2 在組件化的前端架構中的應用

3.1 組件化的前端架構

當一個前端應用足夠複雜的時候,每每須要對應用進行「組件化」。所謂組件化,就是把一個大的應用拆分紅多個小的應用。每一個「應用」具備本身獨特的結構和內容、樣式和業務邏輯,這些小的應用稱爲「組件」(Component)。組件的複用性通常很強,是DRY原則的應用典範,多個組件的嵌套、組合,構建成了一個完成而複雜的應用。

舉我在《一種SPA(單頁面應用)架構》舉過的例子,博客的評論功能組件:

block

這個評論組件的功能大概如此:可顯示多條評論(comment);每條評論多條有本身的回覆(reply);評論或者回復都會顯示有用戶頭像,鼠標放到用戶頭像上會顯示該用戶的信息(相似微博的功能)

這裏能夠把這個功能分好幾個組件:

  1. 總體評論功能做爲一個組件:commentsBox
  2. commentsBox有子組件(child component)comment負責顯示用戶的評論
  3. 每一個comment組件有子組件replay負責顯示用戶對評論的回覆
  4. commentsBox有子組件user-info-card負責顯示用戶的信息

組件這樣的關係能夠用樹的結構來表示:

這裏要注意的是組件之間的關係通常有兩種:嵌套和組合。嵌套,如,每一個commentBox有comment和user-info-card,comment和user-info-card是嵌套在commentBox當中的,因此這兩個組件和commentBox之間都是嵌套的關係;組合,comment和user-info-card都是做爲commentBox的子組件存在,他們兩個互爲兄弟,是組合的關係。處理組件之間的嵌套和組合關係是架構層面須要解決的最重要的問題之一,不在本文討論範圍內,故不累述。但接下來咱們討論的「組件之間以事件的形式進行消息傳遞」和這些組件之間的關係密切相關。

當開始按照上面的設計進行組件化的時候,咱們首先要作的是爲每一個組件構建一個超類,全部的組件都應該繼承這個超類:

component.js:

eventify = require("./eventify.js");

// Component構造函數
function Component(parent) {
    this.$el = $("...")
    this.parent = parent;
}

// Component原型方法
Component.prototype.init = function () {/* ... */};

module.exports = eventify(Component);

這裏爲了方便起見,Component基本什麼內容都沒有,幾乎只是一個「空」的類,而它經過eventify函數得到了「超能力」,因此繼承Component的類一樣具備事件的功能。

注意Component構造函數,每一個Component在示例化的時候應該傳入一個它所屬的父組件的實例parent,接下來會看到,組件之間的消息通訊能夠經過這個實例來完成。而$el能夠看做是該組件所負責的HTML元素。

3.2 父子、兄弟組件之間的消息傳遞

如今把注意力放在commentsBox、comment、user-info-card三個組件上,暫且忽略reply。

目前要實現的功能是:鼠標放到comment組件的用戶頭像上,就會顯示用戶信息。要把這個功能完成大概是這麼一個事件流程:comment組件監聽用戶鼠標放在頭像上的交互事件,而後經過this.parent向父組件(commentsBox)傳遞該事件(this.parent就是commentsBox),commentsBox獲取到該事件之後觸發一個事件給user-info-card,user-info-card能夠經過this.parent監聽到該事件,顯示用戶信息。

// comment-component.js
// 從Component類中繼承得到Comment類
// ...

// 原型方法
Comment.prototype.init = function () {
    var that = this;
    this.$el.find("div.avatar").on("mouseover", function () { 
        // 這裏的that.parent至關於父組件CommentsBox,在Comment組件被示例化的時候傳入
        that.parent.emit("comment:user-mouse-on-avatar", this.userId);
    })
}

上述代碼爲當用戶把鼠標放到用戶頭像的時候觸發一個事件comment:user-mouse-on-avatar,這裏須要注意的是,經過組件名:事件名給這樣的事件命名方式能夠區分事件的來源組件或目標組件,是一種比較好的編程習慣。

// comments-box-component.js
// 從Component類中繼承得到CommentsBox類
// ...

// 原型方法
CommentsBox.prototype.init = function() {
    var that = this;
    this.on("comment:user-mouse-on-avatar", function (userId) { // 這裏接受到來自Comment組件的事件
        that.emit("user-info-card:show-user-info", userId); // 把這個事件傳遞給user-info-card組件                                                                        
    });
}

上述代碼中commentsBox獲取到來自comment組件的comment:user-mouse-on-avatar事件,因爲user-info-card組件也同時擁有commentsBox的實例,因此commentsBox能夠經過觸發自身的事件user-info-card:show-user-info來給user-info-card組件傳遞事件。再一次注意這裏到事件名,user-info-card:前綴說明這個事件是由user-info-card組件所接收的。

// user-info-card-component.js
// 從Component類中繼承得到UserInfoCard類
// ...

// 原型方法
UserInfoCard.prototype.init = function () {
    var that = this;
    this.parent.on("user-info-card:show-user-info", function (userId) {
        $.ajax({ // 經過ajax獲取用戶數據
            url: "/users/" + userId,
            method: "GET"
        }).success(function(data) { 
            that.render(data); // 渲染用戶信息
            that.show(); // 顯示信息
        })
    });
}

上述代碼中,user-info-card組件經過this.parent獲取到來自其父組件(也就是commentsBox)的事件user-info-card:show-user-info,而且獲得所傳入的用戶id;而後經過ajax向服務器發送用戶id,請求用戶數據渲染頁面數據而後顯示。

這樣,消息就經過事件機制從comment到達了它的父組件commentsBox,而後經過commentsBox到達它的兄弟組件user-info-card。完成了一個父子組件之間、兄弟之間的消息傳遞過程:

按照這種消息傳遞方式的事件有四種類型:

  1. this.parent.emit,觸發父組件的事件,由父組件監聽,至關於告知父組件本身所發生的事情。
  2. this.parent.on,監聽父組件的事件,由父組件觸發,至關於接收處理來自父組件的指令。
  3. this.emit,觸發本身的事件,由子組件監聽,至關於向某個子組件發送命令。
  4. this.on,監聽本身的事件,由子組件觸發,至關於接受處理來自子組件的事件。

每一個組件只要hold住一個其父組件實例,就能夠完成:

  1. 和父組件直接進行消息通訊
  2. 經過父組件和本身的兄弟組件間接進行消息通訊

兩個功能。

3.3 使用事件總線(eventbus)進行跨組件消息傳遞

如今能夠把注意力放到reply組件上,reply做爲comment的子組件,負責顯示這條評論下的回覆。相似地,它有回覆者的用戶頭像,鼠標放上去之後也能夠顯示用戶的信息。

user-info-card是commentsBox的子組件,reply是comment的子組件;user-info-card和reply既不是父子也不是兄弟節點關係,reply沒法按照上面的方式比較直接地把事件傳遞給它;reply的鼠標放到頭像上的事件須要先傳遞給其父組件comment,而後通過comment傳遞給commentsBox,最後經過commentsBox傳遞給user-info-card組件。以下:

看起來好像比較麻煩,reply離它根組件commentsBox高度爲二,嵌套了兩層。假設reply嵌套了不少層,那麼事件的傳遞就相似瀏覽器的事件冒泡同樣,須要先冒泡到根節點commentsBox,再由跟節點把事件發送給user-info-card。

若是要真的這樣寫會帶來至關大的維護成本,當組件之間的交互方式更改了甚至只是單單修改了事件名,中間層的負責事件轉發的都須要把代碼從新修改。並且,這些負責轉發的組件須要維護和本身業務邏輯並不相關的邏輯,違反單一職責原則。

解決這個問題的方式就是:提供一個組件之間共享的事件對象eventbus,能夠負責跨組件之間的事件傳遞。全部的組件均可以從這個這個總線上觸發事件,也能夠從這個總線上監聽事件。

commom/eventbus.js

var EventEmitter2 = require('eventemitter2').EventEmitter2;

module.exports = new EventEmitter2; // eventbus是一個簡單的EventEmitter2對象

那麼reply組件和user-info-card就能夠經過eventbus進行之間的信息交換,在reply組件中:

// reply.js
// 從Component類中繼承得到Reply類
// ...

eventbus = require("../common/eventbus.js");

// 原型方法
Reply.prototype.init = function () {
    var that = this;
    this.$el.find("div.avatar").on("mouseover", function () { 
        // 觸發eventbus上的事件user-info-card:show-user-info
        eventbus.emit("user-info-card:show-user-info", that.userId); 
    })
}

在user-info-card組件當中:

// user-info-card-component.js
// 從Component類中繼承得到UserInfoCard類
// ...

eventbus = require("../common/eventbus.js");

// 原型方法
UserInfoCard.prototype.init = function () {
    var that = this;

    // 原來的邏輯不變
    this.parent.on("user-info-card:show-user-info", getUserInfoAndShow); 

    // 新增獲取eventbus的事件
    eventbus.on("user-info-card:show-user-info", getUserInfoAndShow); 

    function getUserInfoAndShow (userId) {
        $.ajax({ // 經過ajax獲取用戶數據
            url: "/users/" + userId,
            method: "GET"
        }).success(function(data) { 
            that.render(data); // 渲染用戶信息
            that.show(); // 顯示信息
        });   
    };
};

這樣user-info-card和就跨越了組件嵌套組合的關係,直接進行組件之間的信息事件的交互。

3.4 問題就來了

那麼問題就來了:

  1. 既然eventbus這麼方便,爲何不全部組件都往eventbus上發送事件,這樣不就不須要組件的事件轉發,方便多了嗎?
  2. 何時使用eventbus進行事件傳遞,何時經過組件轉發事件?

若是全部的組件都往eventbus上post事件,那麼就會帶來eventbus上事件的維護的困難;咱們能夠類比一下JavaScript裏面的全局變量,假如全部函數都不本身維護局部變量,而都使用全局變量會帶來什麼問題?想一想都以爲可怕。既然這個事件交互只是在局部組件之間交互的,那麼就儘可能不要把它post到eventbus,eventbus上的事件應該儘可能少,越少越好。

那何時使用eventbus上的事件?這裏給出一個原則:當組件嵌套了三層以上的時候,帶來局部事件轉發維護困難的時候,就能夠考慮祭出eventbus。而在實際當中不多會出現三層事件傳播這種狀況,也可保持eventbus事件的簡潔。(按照這個原則上面的reply是不須要使用eventbus的,可是爲了闡述eventbus而使用,這點要注意。)

(系列待續)


做者:戴嘉華
轉載請註明出處,保留 原文連接 和做者信息

相關文章
相關標籤/搜索