观察者(发布——订阅)模式

观察者模式

  观察者模式广泛应用于客户端JavaScript编程中。所有的浏览器事件(鼠标悬停,按键等事件)是该模式的例子。它的另一个名字也称自定义事件,与那些由浏览器触发的相比,自定义事件表示是由你编程实现的事件。此外,该模式的另一个别名是订阅——发布模式
  设计这种模式背后的主要动机是促进形成松散耦合。在这种模式中,并不是一个对象调用另一个对象的方法,而是一个对象订阅另一个对象的特定活动并在状态改变后获得通知。订阅者也称之为观察者,而被观察者的对象称为发布者或主题。当发生了一个重要的事件时,发布者将会通知(调用)所有订阅者并且可能经常以事件对象形式传递消息。

1. 现实中的观察者模式

  以售楼处为例,小明想要买房,于是招待人员记下小明的手机。小兵,小龙也买房,招待人员获得他们的手机通通给记在花名册上,过几天,有了他们中意的房子,工作人员便会翻开花名册,打电话伺候。
  在这个例子中,小明,小龙,小兵是订阅者,他们订阅房子的信息。售楼处是发布者,一有消息便会依次打电话给购房者。

2. DOM事件

  实际上,只要我们曾经在DOM节点上面绑定过事件函数,那我们就曾经使用过观察者模式,来看看下面这两句简单的代码发生了什么事情:

    document.body.addEventListener('click', function () {
        alert('1');
    }, false);
    document.body.click();//模拟用户点击

  在这里需要监控用户点击document.body的动作,但是我们没办法预知用户将在什么时候点击。所以我们订阅document.body上的click事件,当body节点被点击时,body节点便会向订阅者发布这个消息。

3. 自定义事件

  除了DOM事件,我们还会经常实现一些自定义的事件,这种依靠自定义事件完成的观察者模式可以用于任何JS代码中。
  现在看看如何一步步实现观察者模式:

  1. 首先要指定好谁充当发布者(比如售楼处)
  2. 然后给发布者添加一个缓存列表,用于存放回调函数以便通知订阅者(比如花名册)
  3. 最后发布消息的时候,发布者会遍历这个缓存列表,依次触发里面存放的订阅者回调函数(打开花名册,依次打电话)

  另外,我们还可以往回调函数里填入一些参数,订阅者可以接受这些参数。这是很有必要的,比如售楼处可以在发给订阅者的短信里加上房子的单价,面积等信息,订阅者接受到这些信息之后可以进行各自的处理:

    var salesOffices = {};  //定义售楼处
    salesOffices.clientList = [];   //缓存列表,存放订阅者的回调函数
    salesOffice.listen = function (fn) {    //增加订阅者
        this.clientList.push(fn);   //订阅的消息添加进缓存列表
    };
    salesOffice.trigger = function () { //发布消息
        for (var i = 0, fn; fn = this.clientList[i++];) {
            fn.apply(this, arguments); //arguments是发布消息时带上的参数
        }
    };

  下面我们来进行一些简单的测试:

    var salesOffices = {};  //定义售楼处
    salesOffices.clientList = [];   //缓存列表,存放订阅者的回调函数
    salesOffices.listen = function (fn) {    //增加订阅者
        this.clientList.push(fn);   //订阅的消息添加进缓存列表
    };
    salesOffices.trigger = function () { //发布消息
        for (var i = 0, fn; fn = this.clientList[i++];) {
            fn.apply(this, arguments); //arguments是发布消息时带上的参数
        }
    };
    salesOffices.listen(function (price, squareMeter) { //小明订阅消息
        console.log('a价格= ' + price);
        console.log('squareMeter= ' + squareMeter);
    });
    salesOffices.listen(function (price, squareMeter) { //小龙订阅消息
        console.log('b价格= ' + price);
        console.log('squareMeter= ' + squareMeter);
    });
    salesOffices.trigger(2000000, 88);
    salesOffices.trigger(3000000, 110);
    /*输出:
        a价格= 2000000 
        squareMeter= 88 
        b价格= 2000000 
        squareMeter= 88 
        a价格= 3000000 
        squareMeter= 110 
        b价格= 3000000 
        squareMeter= 110 
    */

  至此,我们已经实现了一个最简单的观察者模式,但这里还存在一些问题。我们看到订阅者接收到了发布者发布的每个消息,虽然小明只想买88平米的房子,但是发布者把110平米的信息也推送给了小明,这对小明来说很是麻烦。所以我们有必要增加一个标示key,让订阅者只订阅自己感兴趣的消息。改写后的代码如下:

    var salesOffices = {};  //定义售楼处
    salesOffices.clientList = [];   //缓存列表,存放订阅者的回调函数
    salesOffices.listen = function (key, fn) {    //增加订阅者
        if (this.clientList[key] === undefined) {   //如果还没有订阅过此类消息,给该类消息创建一个缓存列表
            this.clientList[key] = [];
        }
        this.clientList[key].push(fn);   //订阅的消息添加进缓存列表
    };
    salesOffices.trigger = function () { //发布消息
        var key = Array.prototype.shift.call(arguments);    //取出消息类型
        fns = this.clientList[key]; //取出该消息对应的回调函数集合
        if (!fns && fns.length === 0) { //如果没有订阅该消息,则返回
            return false;
        }
        for (var i = 0, fn; fn = fns[i++];) {
            fn.apply(this, arguments); //arguments是发布消息时带上的参数
        }
    };
    salesOffices.listen('squareMeter88', function (price) { //小明订阅88平米房子的消息
        console.log('a价格= ' + price);
    });
    salesOffices.listen('squareMeter110', function (price) { //小龙订阅110平米房子的消息
        console.log('b价格= ' + price);
    });
    salesOffices.trigger('squareMeter88', 2000000);
    salesOffices.trigger('squareMeter110', 3000000);
    /*输出:
        a价格= 2000000
        b价格= 3000000
    */

  现在,订阅者可以只订阅自己感兴趣的事了。

4. 观察者模式的通用实现

  现在我们已经看到了如何让售楼处拥有接受订阅和发布事件的功能。假设现在小明又去另一个售楼处买房子,那么这段代码是否必须在另一个售楼处对象上重写一次呢,有没有办法可以让所有对象都拥有发布——订阅功能呢?
  答案是有的,JavaScript作为一门解释执行的语言,给对象动态添加职责是理所当然的事情。
  所以我们把发布——订阅的功能提取出来,放在一个单独的对象内:

    var event = {
        clientList: [],
        listen: function (key, fn) {
            if (!this.clientList[key]) {
                this.clientList[key] = [];
            }
            this.clientList[key].push(fn);
        },
        trigger: function () {
            var key = Array.prototype.shift.call(arguments);
            var fns = this.clientList[key];
            if (!fns && fns.length === 0) {
                return false;
            }
            for (var i = 0, fn; fn = fns[i++];) {
                fn.apply(this, arguments);
            }
        }
    };

  再定义一个installEvent函数,这个函数可以给所有的对象都动态安装发布——订阅功能:

    var installEvent = function (obj) {
        for (var i in event) {
            obj[i] = event[i];
        }
    };

  再来测试,我们给售楼处对象salesOffice是动态增加发布——订阅功能:

    var salesOffices = {};
    installEvent(salesOffices);
    salesOffices.listen('squareMeter88', function (price) {
        console.log('a价格= ' + price);
    });
    salesOffices.listen('squareMeter110', function (price) {
        console.log('b价格= ' + price);
    });
    salesOffices.trigger('squareMeter88', 2000000);     //输出:2000000
    salesOffices.trigger('squareMeter110', 3000000);    //输出:3000000 

5. 取消订阅事件

  有时候,我们也许需要取消订阅事件的功能。比如小明突然不想买房子了,为了避免继续接到售楼处的电话,小明需要取消之前订阅的事件。现在我们给event对象增加remove方法:

    event.remove = function (key, fn) {
        var fns = this.clientList[key];
        if (!fns) { //如果key对应的消息没有被人订阅,则直接返回
            return false;
        }
        if (!fn) {  //如果没有传入具体的回调函数,表示需要取消key对应消息的所有订阅
            fns.length = 0;
        }else{
            for (var i = 0, _fn; _fn = fns[i]; i++) {
                if (_fn === fn) {
                    fns.splice(i, 1);   //删除订阅者的回调函数
                }
            }
            
        }
    };
    var installEvent = function (obj) {
        for (var i in event) {
            obj[i] = event[i];
        }
    };
    var salesOffices = {};
    installEvent(salesOffices);
    salesOffices.listen('squareMeter88', fn1 = function (price) {
        console.log('a价格= ' + price);
    });
    salesOffices.listen('squareMeter88', fn2 = function (price) {
        console.log('b价格= ' + price);
    });
    salesOffices.remove('squareMeter88', fn1);
    salesOffices.trigger('squareMeter88', 2000000);     //输出:2000000

6. 全局的发布——订阅对象

  在现实中,买房子未必要亲自去售楼处,我们只要把订阅的请求交给中介公司,而各大房产公司也只需要通过中介公司来发布房子信息。这样一来,我们不用关心消息是来自哪个房产公司,我们在一的是能否顺利接受消息。当然,为了保证订阅者和发布者能顺利通信,订阅者和发布者都必须知道这个中介公司。
  同样在程序中,发布——订阅模式可以用一个全局的Event对象来实现,订阅者不需要了解消息来自哪个发布者,发布者也不知道消息会推送给哪些订阅者,Event作为一个类似“中介者”的角色,把订阅者和发布者联系起来。见如下代码:

    var Event = (function () {
        var clientList = {},
            listen,
            trigger,
            remove;
        listen = function (key, fn) {
            if (!clientList[key]) {
                clientList[key] = [];
            }
            clientList[key].push(fn);
        };
        trigger = function () {
            var key = Array.prototype.shift.call(arguments);
            var fns = clientList[key];
            if (!fns && fns.length === 0) {
                return false;
            }
            for (var i = 0, fn; fn = fns[i++];) {
                fn.apply(this, arguments);
            }
        };
        remove = function (key, fn) {
            var fns = this.clientList[key];
            if (!fns) {
                return false;
            }
            if (!fn) {
                fns.length = 0;
            }else{
                for (var i = 0, _fn; _fn = fns[i]; i++) {
                    if (_fn === fn) {
                        fns.splice(i, 1);
                    }
                }
            
            }
        };
        return {
            listen: listen,
            trigger: trigger,
            remove: remove
        };
    })();
    Event.listen('squareMeter88', function(price) {
        console.log('a价格= ' + price);
    });
    Event.trigger('squareMeter88', 2000000);     //输出:2000000

7. 模块间的通信

  上一节中实现的发布——订阅模式的实现,是基于一个全局的Event对象,我们利用它可以在两个封装良好的模块中进行通信,这两个模块可以完全不知道对方的存在。就如同有了中介公司之后,我们不再需要知道房子的消息来自哪个售楼处。
  比如现在有两个模块,a模块里面有一个按钮,每次点击按钮之后,b模块里的div中会显示按钮的总点击次数,我们用全局发布——订阅模式完成下面的代码,使得a模块和b模块可以在保持封装性的前提下进行通信。

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<title></title>
</head>
<body>
    <button id="count">dian我</button>
    <div id="div"></div>
<script>
    var a = (function () {
        var count = 0;
        var button = document.getElementById('count');
        button.onclick = function() {
            Event.trigger('add', count++);
        };
    })();
    var b = (function () {
        var div = document.getElementById('div');
        Event.listen('add', function (count) {
            div.innerHTML = count;
        });
    })();
</script>
</body>
</html>

  但在这里我们要留意另一个问题,模块之间用了太多的全局发布——订阅模式来通信,那么模块与模块之间的联系就被隐藏到了背后。我们最终会搞不清楚消息来自哪个模块,或者消息会流向哪些模块,这又会给我们的维护带来一些麻烦,也许某个模块的作用就是暴露一些借口给其他模块调用。


  参考书目:《JavaScript模式》,《JavaScript设计模式与开发实践》

posted @ 2015-10-10 22:14  微日月  阅读(1137)  评论(0编辑  收藏  举报