JavaScript設計模式之觀察者模式

Chris威發表於2019-03-04

嗯~~~

開門見山,這次我也就不賣關子了,今天我們就來聊一聊 JavasSript 設計模式中的 觀察者模式 ,首先我們來認識一下,什麼是觀察者模式?

什麼是觀察者模式?

觀察者模式(Observer)

通常又被稱為 釋出-訂閱者模式訊息機制,它定義了物件間的一種一對多的依賴關係,只要當一個物件的狀態發生改變時,所有依賴於它的物件都得到通知並被自動更新,解決了主體物件與觀察者之間功能的耦合,即一個物件狀態改變給其他物件通知的問題。

單純的看定義,對於前端小夥伴們,可能這個概念還是比較模糊,對於觀察者模式還是一知半解,ok,那我就來看個生活中比較貼切的例子,相信你立馬就懂了~

JavaScript設計模式之觀察者模式

生活中的觀察者模式

每次小米出新款手機都是熱銷,我看中了小米3這款手機,想去小米之家購買,但是到店後售貨員告訴我他們這款手機很熱銷,他們已經賣完了,現在沒有貨了,那我不可能每天都跑過來問問吧,這樣很耽誤時間的,於是我將我的手機號碼留給銷售小姐姐,如果他們店裡有貨,讓她打電話通知我就好了,這樣就不用擔心不知道什麼時候有貨,也不需要天天跑去問了,如果你已經成功買到了手機呢,那麼銷售小姐姐之後也就不需要通知你了~

這樣是不是清晰了很多~諸如此類的案例還有很多,我也就不在贅述了。

觀察者模式的使用

不瞞你說,我敢保證,過來看的每個人都使用過觀察者模式~

什麼,你不信?

那麼來看看下面這段程式碼~

    document.querySelector('#btn').addEventListener('click',function () {
        alert('You click this btn');
    },false)
複製程式碼

怎麼樣,是不是很眼熟!

沒錯,我們平時對 DOM 的事件繫結就是一個非常典型的 釋出-訂閱者模式 ,這裡我們需要監聽使用者點選按鈕這個動作,但是我們卻無法知道使用者什麼時候去點選,所以我們訂閱 按鈕上的 click 事件,只要按鈕被點選時,那麼按鈕就會向訂閱者釋出這個訊息,我們就可以做對應的操作了。

除了我們常見的 DOM 事件繫結外,觀察者模式應用的範圍還有很多~

比如比較當下熱門 vue 框架,裡面不少地方都涉及到了觀察者模式,比如:

資料的雙向繫結

JavaScript設計模式之觀察者模式

利用 Object.defineProperty() 對資料進行劫持,設定一個監聽器 Observer,用來監聽所有屬性,如果屬性上發上變化了,就需要告訴訂閱者 Watcher 去更新資料,最後指令解析器 Compile 解析對應的指令,進而會執行對應的更新函式,從而更新檢視,實現了雙向繫結~

子元件與父元件通訊

Vue 中我們通過 props 完成父元件向子元件傳遞資料,子元件與父元件通訊我們通過自定義事件即 $on,$emit來實現,其實也就是通過 $emit 來發布訊息,並對訂閱者 $on 做統一處理 ~

ok,說了這麼多,該我們自己露一手了,接下來我們來自己建立一個簡單的觀察者~

JavaScript設計模式之觀察者模式

建立一個觀察者

首先我們需要建立一個觀察者物件,它包含一個訊息容器和三個方法,分別是訂閱訊息方法 on , 取消訂閱訊息方法 off ,傳送訂閱訊息 subscribe

    const Observe = (function () {
    	//防止訊息佇列暴露而被篡改,將訊息容器設定為私有變數
    	let __message = {};
    	return {
        	//註冊訊息介面
            on : function () {},
            //釋出訊息介面
    		subscribe : function () {},
            //移除訊息介面
            off : function () {}
        }
    })();
複製程式碼

好的,我們的觀察者雛形已經出來了,剩下的就是完善裡面的三個方法~

註冊訊息方法

註冊訊息方法的作用是將訂閱者註冊的訊息推入到訊息佇列中,因此需要傳遞兩個引數:訊息型別和對應的處理函式,要注意的是,如果推入到訊息佇列是如果此訊息不存在,則要建立一個該訊息型別並將該訊息放入訊息佇列中,如果此訊息已經存在則將對應的方法突入到執行方法佇列中。

    //註冊訊息介面
    on: function (type, fn) {
        //如果此訊息不存在,建立一個該訊息型別
        if( typeof __message[type] === 'undefined' ){
        	// 將執行方法推入該訊息對應的執行佇列中
            __message[type] = [fn];
        }else{
        	//如果此訊息存在,直接將執行方法推入該訊息對應的執行佇列中
            __message[type].push(fn);
        }
    }
複製程式碼

釋出訊息方法

釋出訊息,其功能就是當觀察者釋出一個訊息是將所有訂閱者訂閱的訊息依次執行,也需要傳兩個引數,分別是訊息型別和對應執行函式時所需要的引數,其中訊息型別是必須的。

    //釋出訊息介面
    subscribe: function (type, args) {
    	//如果該訊息沒有註冊,直接返回
    	if ( !__message[type] )  return;
    	//定義訊息資訊
    	let events = {
        	type: type,           //訊息型別
        	args: args || {}       //引數
        },
        i = 0,                         // 迴圈變數
        len = __message[type].length;   // 執行佇列長度
    	//遍歷執行函式
    	for ( ; i < len; i++ ) {
    		//依次執行註冊訊息對應的方法
            __message[type][i].call(this,events)
    	}
    }
複製程式碼

移除訊息方法

移除訊息方法,其功能就是講訂閱者登出的訊息從訊息佇列中清除,也需要傳遞訊息型別和執行佇列中的某一函式兩個引數。這裡為了避免刪除是,訊息不存在的情況,所以要對其訊息存在性製作校驗。

    //移除訊息介面
    off: function (type, fn) {
    	//如果訊息執行佇列存在
    	if ( __message[type] instanceof Array ) {
    		// 從最後一條依次遍歷
    		let i = __message[type].length - 1;
    		for ( ; i >= 0; i-- ) {
    			//如果存在改執行函式則移除相應的動作
    			__message[type][i] === fn && __message[type].splice(i, 1);
    		}
    	}
    }
複製程式碼

ok,到此,我們已經實現了一個基本的觀察者模型,接著就是我們大顯身手的時候了~ 趕緊拿出來測試測試啊~

大顯身手

首先我們先來一個簡單的測試,看看我們自己建立的觀察者模式執行效果如何?

   //訂閱訊息
    Observe.on('say', function (data) {
    	console.log(data.args.text);
    })
    Observe.on('success',function () {
        console.log('success')
    });
    
    //釋出訊息
    Observe.subscribe('say', { text : 'hello world' } )
    Observe.subscribe('success');  
複製程式碼

我們在訊息型別為 say 的訊息中註冊了兩個方法,其中有一個接受引數,另一個不需要引數,然後通過 subscribe 釋出 saysuccess 訊息,結果跟我們預期的一樣,控制檯輸出了 hello world 以及 success ~

看!我們已經成功的實現了我們的觀察者~ 為自己點個贊吧!

自定義資料的雙向繫結

上面說到,vue 雙向繫結是資料劫持和釋出訂閱做實現的,現在我們藉助這種思想,自己來實現一個簡單的資料的雙向繫結~

首先當然是要有頁面結構了,這裡不講究什麼,我就隨手一碼了~

<div id="app">
    <h3>資料的雙向繫結</h3>
    <div class="cell">
        <div class="text" v-text="myText"></div>
        <input class="input" type="text" v-model="myText" >
    </div>
</div>
複製程式碼

相信你已經知道了,我們要做到就是 input 標籤的輸入,通過 v-text 繫結到類名為 textdiv 標籤上~

首先我們需要建立一個類,這裡就叫做 myVue 吧。

class myVue{
    constructor (options){
        // 傳入的配置引數
        this.options = options;
        // 根元素
        this.$el = document.querySelector(options.el);
        // 資料域
        this.$data = options.data;
        
        // 儲存資料model與view相關的指令,當model改變時,我們會觸發其中的指令類更新,保證view也能實時更新
        this._directives = {};
        // 資料劫持,重新定義資料的 set 和 get 方法
        this._obverse(this.$data);
        // 解析器,解析模板指令,並將每個指令對應的節點繫結更新函式,新增監聽資料的訂閱者,一旦資料有變動,收到通知,更新檢視
        this._compile(this.$el);
    }
}
複製程式碼

這裡我們定義了 myVue 建構函式,並在構造方法中進行了一些初始化操作,上面做了註釋,這裡我就不在贅述,主要來看裡面關鍵的兩個方法 _obverse_compile

首先是 _observe 方法,他的作用就是處理傳入的 data ,並重新定義 datasetget 方法,保證我們在 data 發生變化的時候能跟蹤到,併發布通知,主要用到了 Object.defineProperty() 這個方法,對這個方法還不太熟悉的小夥伴們,請猛戳這裡~

_observe

    //_obverse 函式,對data進行處理,重寫data的set和get函式
    _obverse(data){
    	let val ;
    	//遍歷資料
        for( let key in data ){
            // 判斷是不是屬於自己本身的屬性
            if( data.hasOwnProperty(key) ){
            	this._directives[key] = [];
            }
        
            val = data[key];        
            //遞迴遍歷
            if ( typeof val === 'object' ) {
            	//遞迴遍歷
            	this._obverse(val);
            }
            
            // 初始當前資料的執行佇列
            let _dir = this._directives[key];
        
            //重新定義資料的 get 和 set 方法
            Object.defineProperty(this.$data,key,{
            	enumerable: true,
            	configurable: true,
            	get: function () {
            		return val;
            	},
            	set: function (newVal) {
            		if ( val !== newVal ) {
            			val = newVal;
            			// 當 myText 改變時,觸發 _directives 中的繫結的Watcher類的更新
            			_dir.forEach(function (item) {
            			    //呼叫自身指令的更新操作
            				item._update();
            			})
            		}
            	}
            })
        }
    }
複製程式碼

上面的程式碼也很簡單,註釋也都很清楚,不過有個問題就是,我在遞迴遍歷資料的時候,偷了個小懶 --,這裡我只涉及到了一些簡單的資料結構,複雜的例如迴圈引用的這種我沒有考慮進入,大家可以自行補充一下哈~

接著我們來看看 _compile 這個方法,它實際上是一個解析器,其功能就是解析模板指令,並將每個指令對應的節點繫結更新函式,新增監聽資料的訂閱者,一旦資料有變動,就收到通知,然後去更新檢視變化,具體實現如下:

_compile

_compile(el){
    //子元素
    let nodes = el.children;
    for( let i = 0 ;  i < nodes.length ; i++ ){
    	let node = nodes[i];
    	// 遞迴對所有元素進行遍歷,並進行處理
    	if( node.children.length ){
    		this._compile(node);
    	}
    
        //如果有 v-text 指令 , 監控 node的值 並及時更新
        if( node.hasAttribute('v-text')){
            let attrValue = node.getAttribute('v-text');
            //將指令對應的執行方法放入指令集
            this._directives[attrValue].push(new Watcher('text',node,this,attrValue,'innerHTML'))
        }
    
    	//如果有 v-model屬性,並且元素是INPUT或者TEXTAREA,我們監聽它的input事件
        if( node.hasAttribute('v-model') && ( node.tagName === 'INPUT' || node.tagName === 'TEXTAREA')){
            let _this = this;
            //新增input時間
            node.addEventListener('input',(function(){
            	let attrValue = node.getAttribute('v-model');
            	//初始化賦值
            	_this._directives[attrValue].push(new Watcher('input',node,_this,attrValue,'value'));
                return function () {
                    //後面每次都會更新
                    _this.$data[attrValue] = node.value;
            	}
            })())
        }
    }
}
複製程式碼

上面的程式碼也很清晰,我們從根元素 #app 開始遞迴遍歷每個節點,並判斷每個節點是否有對應的指令,這裡我們只針對 v-textv-model,我們對 v-text 進行了一次 new Watcher(),並把它放到了 myText 的指令集裡面,對 v-model 也進行了解析,對其所在的 input 繫結了 input 事件,並將其通過 new Watcher()myText 關聯起來,那麼我們就應該來看看這個 Watcher 到底是什麼?

Watcher 其實就是訂閱者,是 _observer_compile 之間通訊的橋樑用來繫結更新函式,實現對 DOM 元素的更新

Warcher

class Watcher{
    /*
    * name  指令名稱,例如文字節點,該值設為"text"
    * el    指令對應的DOM元素
    * vm    指令所屬myVue例項
    * exp   指令對應的值,本例如"myText"
    * attr  繫結的屬性值,本例為"innerHTML"
    * */
    constructor (name, el, vm, exp, attr){
        this.name = name;
        this.el = el;
        this.vm = vm;
        this.exp = exp;
        this.attr = attr;
    
        //更新操作
        this._update();
    }
    
    _update(){
    	this.el[this.attr] = this.vm.$data[this.exp];
    }
}
複製程式碼

每次建立 Watcher 的例項,都會傳入相應的引數,也會進行一次 _update 操作,上述的 _compile 中,我們建立了兩個 Watcher 例項,不過這兩個對應的 _update 操作不同而已,對於 div.text 的操作其實相當於 div.innerHTML=h3.innerHTML = this.data.myText , 對於 input 相當於 input.value=this.data.myText , 這樣每次資料 set 的時候,我們會觸發兩個 _update 操作,分別更新 divinput 中的內容~

廢話不多說,趕緊測試一下吧~

先初始化一下~

    //建立vue例項
    const app = new myVue({
        el : '#app' ,
        data : {
            myText : 'hello world'
        }
    })

複製程式碼

接著,上圖~

JavaScript設計模式之觀察者模式

我們順利的實現了一個簡單的雙向繫結,棒棒噠 ~

結語

現在,是不是已經對觀察者模式有比較深刻的理解了呢?其實,我這裡說了這麼多,只是起到了一個拋磚引玉的作用,重要的是設計思想,要學會將這種設計思想合理的應用到我們實際的開發過程中,可能過程會比較艱難,但是紙上得來終覺淺,絕知此事要躬行啊,大家加油~

哦,對了,今天 1024 啊 , 大家節日快樂哈~

JavaScript設計模式之觀察者模式

相關文章