JavaScript设计模式之观察者模式

18,411 阅读9分钟

嗯~~~

开门见山,这次我也就不卖关子了,今天我们就来聊一聊 JavasSript 设计模式中的 观察者模式 ,首先我们来认识一下,什么是观察者模式?

什么是观察者模式?

观察者模式(Observer)

通常又被称为 发布-订阅者模式消息机制,它定义了对象间的一种一对多的依赖关系,只要当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新,解决了主体对象与观察者之间功能的耦合,即一个对象状态改变给其他对象通知的问题。

单纯的看定义,对于前端小伙伴们,可能这个概念还是比较模糊,对于观察者模式还是一知半解,ok,那我就来看个生活中比较贴切的例子,相信你立马就懂了~

生活中的观察者模式

每次小米出新款手机都是热销,我看中了小米3这款手机,想去小米之家购买,但是到店后售货员告诉我他们这款手机很热销,他们已经卖完了,现在没有货了,那我不可能每天都跑过来问问吧,这样很耽误时间的,于是我将我的手机号码留给销售小姐姐,如果他们店里有货,让她打电话通知我就好了,这样就不用担心不知道什么时候有货,也不需要天天跑去问了,如果你已经成功买到了手机呢,那么销售小姐姐之后也就不需要通知你了~

这样是不是清晰了很多~诸如此类的案例还有很多,我也就不在赘述了。

观察者模式的使用

不瞒你说,我敢保证,过来看的每个人都使用过观察者模式~

什么,你不信?

那么来看看下面这段代码~

    document.querySelector('#btn').addEventListener('click',function () {
        alert('You click this btn');
    },false)

怎么样,是不是很眼熟!

没错,我们平时对 DOM 的事件绑定就是一个非常典型的 发布-订阅者模式 ,这里我们需要监听用户点击按钮这个动作,但是我们却无法知道用户什么时候去点击,所以我们订阅 按钮上的 click 事件,只要按钮被点击时,那么按钮就会向订阅者发布这个消息,我们就可以做对应的操作了。

除了我们常见的 DOM 事件绑定外,观察者模式应用的范围还有很多~

比如比较当下热门 vue 框架,里面不少地方都涉及到了观察者模式,比如:

数据的双向绑定

利用 Object.defineProperty() 对数据进行劫持,设置一个监听器 Observer,用来监听所有属性,如果属性上发上变化了,就需要告诉订阅者 Watcher 去更新数据,最后指令解析器 Compile 解析对应的指令,进而会执行对应的更新函数,从而更新视图,实现了双向绑定~

子组件与父组件通信

Vue 中我们通过 props 完成父组件向子组件传递数据,子组件与父组件通信我们通过自定义事件即 $on,$emit来实现,其实也就是通过 $emit 来发布消息,并对订阅者 $on 做统一处理 ~

ok,说了这么多,该我们自己露一手了,接下来我们来自己创建一个简单的观察者~

创建一个观察者

首先我们需要创建一个观察者对象,它包含一个消息容器和三个方法,分别是订阅消息方法 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'
        }
    })

接着,上图~

我们顺利的实现了一个简单的双向绑定,棒棒哒 ~

结语

现在,是不是已经对观察者模式有比较深刻的理解了呢?其实,我这里说了这么多,只是起到了一个抛砖引玉的作用,重要的是设计思想,要学会将这种设计思想合理的应用到我们实际的开发过程中,可能过程会比较艰难,但是纸上得来终觉浅,绝知此事要躬行啊,大家加油~

哦,对了,今天 1024 啊 , 大家节日快乐哈~