【问题标题】:Why would one use the Publish/Subscribe pattern (in JS/jQuery)?为什么要使用发布/订阅模式(在 JS/jQuery 中)?
【发布时间】:2012-11-10 20:47:28
【问题描述】:

所以,一位同事向我介绍了发布/订阅模式(在 JS/jQuery 中),但我很难理解为什么人们会使用这种模式而不是“正常” ' JavaScript/jQuery。

例如,以前我有以下代码...

$container.on('click', '.remove_order', function(event) {
    event.preventDefault();
    var orders = $(this).parents('form:first').find('div.order');
    if (orders.length > 2) {
        orders.last().remove();
    }
});

而且我可以看到这样做的好处,例如......

removeOrder = function(orders) {
    if (orders.length > 2) {
        orders.last().remove();
    }
}

$container.on('click', '.remove_order', function(event) {
    event.preventDefault();
    removeOrder($(this).parents('form:first').find('div.order'));
});

因为它引入了针对不同事件等重用 removeOrder 功能的能力。

但是,如果它做同样的事情,你为什么要决定实现发布/订阅模式并达到以下长度? (仅供参考,我使用了jQuery tiny pub/sub

removeOrder = function(e, orders) {
    if (orders.length > 2) {
        orders.last().remove();
    }
}

$.subscribe('iquery/action/remove-order', removeOrder);

$container.on('click', '.remove_order', function(event) {
    event.preventDefault();
    $.publish('iquery/action/remove-order', $(this).parents('form:first').find('div.order'));
});

我肯定已经阅读过有关该模式的信息,但我无法想象为什么有必要这样做。我看过的解释如何实现这种模式的教程只涵盖了我自己的基本示例。

我想 pub/sub 的用处会在更复杂的应用程序中表现出来,但我无法想象。恐怕我完全没有抓住重点;但如果有的话,我想知道重点!

您能否简洁地解释一下为什么以及在什么情况下这种模式是有利的?像我上面的例子一样,对代码 sn-ps 使用 pub/sub 模式是否值得?

【问题讨论】:

    标签: javascript jquery design-patterns publish-subscribe


    【解决方案1】:

    这完全是关于松散耦合和单一职责,这与过去几年非常现代的 JavaScript 中的 MV* (MVC/MVP/MVVM) 模式密切相关。

    Loose coupling 是一种面向对象的原则,其中系统的每个组件都知道自己的职责,而不关心其他组件(或至少尽量不关心它们)。松耦合是一件好事,因为您可以轻松地重用不同的模块。您没有与其他模块的接口耦合。使用发布/订阅时,您只需与发布/订阅接口耦合,这没什么大不了的——只有两种方法。因此,如果您决定在不同的项目中重用一个模块,您只需复制并粘贴它即可,它可能会起作用,或者至少您不需要太多努力就能让它起作用。

    在谈论松耦合时,我们应该提到separation of concerns。如果您正在使用 MV* 架构模式构建应用程序,您总是有一个模型和一个视图。模型是应用程序的业务部分。您可以在不同的应用程序中重复使用它,因此将它与您想要显示它的单个应用程序的 View 耦合并不是一个好主意,因为通常在不同的应用程序中您有不同的视图。因此,使用发布/订阅进行模型视图通信是一个好主意。当您的模型发生更改时,它会发布一个事件,视图会捕获它并自行更新。发布/订阅没有任何开销,它可以帮助您解耦。以同样的方式,您可以将应用程序逻辑保存在控制器中(例如,MVVM、MVP,它不完全是控制器),并使视图尽可能简单。当你的 View 改变(或者用户点击某个东西,例如)它只是发布一个新事件,Controller 捕获它并决定做什么。如果您熟悉MVC 模式或Microsoft 技术(WPF/Silverlight)中的MVVM,您可以将发布/订阅视为Observer pattern。这种方法用于 Backbone.js、Knockout.js (MVVM) 等框架。

    这是一个例子:

    //Model
    function Book(name, isbn) {
        this.name = name;
        this.isbn = isbn;
    }
    
    function BookCollection(books) {
        this.books = books;
    }
    
    BookCollection.prototype.addBook = function (book) {
        this.books.push(book);
        $.publish('book-added', book);
        return book;
    }
    
    BookCollection.prototype.removeBook = function (book) {
       var removed;
       if (typeof book === 'number') {
           removed = this.books.splice(book, 1);
       }
       for (var i = 0; i < this.books.length; i += 1) {
          if (this.books[i] === book) {
              removed = this.books.splice(i, 1);
          }
       }
       $.publish('book-removed', removed);
       return removed;
    }
    
    //View
    var BookListView = (function () {
    
       function removeBook(book) {
          $('#' + book.isbn).remove();
       }
    
       function addBook(book) {
          $('#bookList').append('<div id="' + book.isbn + '">' + book.name + '</div>');
       }
    
       return {
          init: function () {
             $.subscribe('book-removed', removeBook);
             $.subscribe('book-aded', addBook);
          }
       }
    }());
    

    另一个例子。如果您不喜欢 MV* 方法,您可以使用一些不同的方法(我将在接下来描述的方法和最后提到的方法之间存在交叉点)。只需在不同的模块中构建您的应用程序。例如看看 Twitter。

    如果您查看界面,您只会看到不同的框。您可以将每个框视为不同的模块。例如,您可以发布推文。此操作需要更新一些模块。首先,它必须更新您的个人资料数据(左上框),但它还必须更新您的时间线。当然,您可以保留对这两个模块的引用并使用它们的公共接口分别更新它们,但发布事件更容易(也更好)。由于更松散的耦合,这将使您的应用程序的修改更容易。如果您开发依赖于新推文的新模块,您只需订阅“publish-tweet”事件并处理它。这种方法非常有用,可以使您的应用程序非常解耦。您可以非常轻松地重用您的模块。

    这是最后一种方法的基本示例(这不是原始的 twitter 代码,它只是我的一个示例):

    var Twitter.Timeline = (function () {
       var tweets = [];
       function publishTweet(tweet) {
          tweets.push(tweet);
          //publishing the tweet
       };
       return {
          init: function () {
             $.subscribe('tweet-posted', function (data) {
                 publishTweet(data);
             });
          }
       };
    }());
    
    
    var Twitter.TweetPoster = (function () {
       return {
           init: function () {
               $('#postTweet').bind('click', function () {
                   var tweet = $('#tweetInput').val();
                   $.publish('tweet-posted', tweet);
               });
           }
       };
    }());
    

    Nicholas Zakas 对这种方法进行了精彩的演讲。对于 MV* 方法,我所知道的最好的文章和书籍是由 Addy Osmani 发表的。

    缺点:您必须小心过度使用发布/订阅。如果您有数百个事件,那么管理所有事件可能会变得非常混乱。如果你没有使用命名空间(或者没有以正确的方式使用它),你也可能会发生冲突。可以在https://github.com/ajacksified/Mediator.js 找到一个看起来很像发布/订阅的 Mediator 的高级实现。它具有命名空间和事件“冒泡”等功能,当然可以中断。发布/订阅的另一个缺点是很难进行单元测试,可能很难隔离模块中的不同功能并独立测试它们。

    【讨论】:

    • 谢谢,这很有道理。我对 MVC 模式很熟悉,因为我一直将它与 PHP 一起使用,但我没有从事件驱动编程的角度考虑它。 :)
    • 感谢您的描述。真的帮助我理解了这个概念。
    • 这是一个很好的答案。无法阻止自己对此投票:)
    • 很好的解释,多个示例,进一步阅读建议。 A++。
    【解决方案2】:

    主要目标是减少代码之间的耦合。这是一种基于事件的思维方式,但“事件”并不与特定对象相关联。

    我将在下面用一些看起来有点像 JavaScript 的伪代码写出一个大例子。

    假设我们有一个 Radio 类和一个 Relay 类:

    class Relay {
        function RelaySignal(signal) {
            //do something we don't care about right now
        }
    }
    
    class Radio {
        function ReceiveSignal(signal) {
            //how do I send this signal to other relays?
        }
    }
    

    每当无线电接收到信号时,我们希望有多个中继器以某种方式中继消息。继电器的数量和类型可以不同。我们可以这样做:

    class Radio {
        var relayList = [];
    
        function AddRelay(relay) {
            relayList.add(relay);
        }
    
        function ReceiveSignal(signal) {
            for(relay in relayList) {
                relay.Relay(signal);
            }
        }
    
    }
    

    这很好用。但现在想象一下,我们想要一个不同的组件也接收 Radio 类接收的部分信号,即 Speakers:

    (对不起,如果类比不是一流的......)

    class Speakers {
        function PlaySignal(signal) {
            //do something with the signal to create sounds
        }
    }
    

    我们可以再次重复这个模式:

    class Radio {
        var relayList = [];
        var speakerList = [];
    
        function AddRelay(relay) {
            relayList.add(relay);
        }
    
        function AddSpeaker(speaker) {
            speakerList.add(speaker)
        }
    
        function ReceiveSignal(signal) {
    
            for(relay in relayList) {
                relay.Relay(signal);
            }
    
            for(speaker in speakerList) {
                speaker.PlaySignal(signal);
            }
    
        }
    
    }
    

    我们可以通过创建一个像“SignalListener”这样的接口来使这变得更好,这样我们只需要 Radio 类中的一个列表,并且总是可以在我们想要收听信号的任何对象上调用相同的函数.但这仍然会在我们决定的任何接口/基类/等与 Radio 类之间产生耦合。基本上,每当您更改 Radio、Signal 或 Relay 类之一时,您都必须考虑它可能如何影响其他两个类。

    现在让我们尝试一些不同的东西。让我们创建一个名为 RadioMast 的第四个类:

    class RadioMast {
    
        var receivers = [];
    
        //this is the "subscribe"
        function RegisterReceivers(signaltype, receiverMethod) {
            //if no list for this type of signal exits, create it
            if(receivers[signaltype] == null) {
                receivers[signaltype] = [];
            }
            //add a subscriber to this signal type
            receivers[signaltype].add(receiverMethod);
        }
    
        //this is the "publish"
        function Broadcast(signaltype, signal) {
            //loop through all receivers for this type of signal
            //and call them with the signal
            for(receiverMethod in receivers[signaltype]) {
                receiverMethod(signal);
            }
        }
    }
    

    现在我们有了一个我们知道的模式,我们可以将它用于任意数量和类型的类,只要它们:

    • 知道 RadioMast(处理所有消息传递的类)
    • 了解发送/接收消息的方法签名

    所以我们将 Radio 类更改为其最终的简单形式:

    class Radio {
        function ReceiveSignal(signal) {
            RadioMast.Broadcast("specialradiosignal", signal);
        }
    }
    

    我们将扬声器和继电器添加到 RadioMast 的此类信号的接收器列表中:

    RadioMast.RegisterReceivers("specialradiosignal", speakers.PlaySignal);
    RadioMast.RegisterReceivers("specialradiosignal", relay.RelaySignal);
    

    现在 Speakers 和 Relay 类除了有一个可以接收信号的方法之外,对任何事情的了解都是零,而作为发布者的 Radio 类知道它向其发布信号的 RadioMast。这就是使用消息传递系统(如发布/订阅)的意义所在。

    【讨论】:

    • 真的很高兴有一个具体的例子来展示如何实现 pub/sub 模式比使用“普通”方法更好!谢谢!
    • 不客气!就我个人而言,我经常发现当涉及到新的模式/方法时,我的大脑不会“点击”,直到我意识到它为我解决了一个实际问题。 sub/pub 模式非常适合在概念上紧密耦合的架构,但我们仍然希望尽可能地将它们分开。想象一个游戏,你有数百个对象,所有对象都必须对周围发生的事情做出反应,这些对象可以是一切:玩家、子弹、树、几何图形、gui 等。
    • JavaScript 没有 class 关键字。请强调这一事实,例如。通过将您的代码分类为伪代码。
    • 其实在 ES6 中有一个 class 关键字。
    【解决方案3】:

    其他答案在展示该模式的工作原理方面做得很好。我想解决隐含的问题“旧方法有什么问题?”,因为我最近一直在使用这种模式,我发现它涉及到转变我的想法。

    假设我们订阅了一份经济公告。该公告发布了一个标题:“道琼斯指数下跌 200 点”。这将是一个奇怪且有点不负责任的信息。但是,如果它发布:“Enron 今天早上申请了第 11 章破产保护”,那么这是一条更有用的信息。请注意,该消息可能导致道琼斯指数下跌 200 点,但这是另一回事。

    发送命令和通知刚刚发生的事情是有区别的。考虑到这一点,请使用您的原始版本的 pub/sub 模式,暂时忽略处理程序:

    $.subscribe('iquery/action/remove-order', removeOrder);
    
    $container.on('click', '.remove_order', function(event) {
        event.preventDefault();
        $.publish('iquery/action/remove-order', $(this).parents('form:first').find('div.order'));
    });
    

    在用户操作(点击)和系统响应(订单被删除)之间已经存在隐含的强耦合。在您的示例中有效地,该操作正在发出命令。考虑这个版本:

    $.subscribe('iquery/action/remove-order-requested', handleRemoveOrderRequest);
    
    $container.on('click', '.remove_order', function(event) {
        event.preventDefault();
        $.publish('iquery/action/remove-order-requested', $(this).parents('form:first').find('div.order'));
    });
    

    现在,处理程序正在对发生的感兴趣的事情做出响应,但没有义务删除订单。事实上,处理程序可以做各种与删除订单不直接相关但仍可能与调用操作相关的事情。例如:

    handleRemoveOrderRequest = function(e, orders) {
        logAction(e, "remove order requested");
        if( !isUserLoggedIn()) {
            adviseUser("You need to be logged in to remove orders");
        } else if (isOkToRemoveOrders(orders)) {
            orders.last().remove();
            adviseUser("Your last order has been removed");
            logAction(e, "order removed OK");
        } else {
            adviseUser("Your order was not removed");
            logAction(e, "order not removed");
        }
        remindUserToFloss();
        increaseProgrammerBrowniePoints();
        //etc...
    }
    

    命令和通知之间的区别是用这种模式做出的有用区别,IMO。

    【讨论】:

    • 如果您的最后 2 个函数(remindUserToFlossincreaseProgrammerBrowniePoints)位于不同的模块中,您会在 handleRemoveOrderRequest 中一个接一个地发布 2 个事件,还是有一个 @ 987654327@ 在remindUserToFloss() 完成后将事件发布到browniePoints 模块?
    【解决方案4】:

    这样您就不必对方法/函数调用进行硬编码,您只需发布事件而无需关心谁在听。这使得发布者独立于订阅者,减少了应用程序的两个不同部分之间的依赖关系(或耦合,无论您喜欢什么术语)。

    下面是wikipedia提到的耦合的一些缺点

    紧耦合系统倾向于表现出以下发展 特征,通常被视为缺点:

    1. 一个模块的更改通常会导致其他模块的更改产生连锁反应。
    2. 由于模块间依赖性增加,模块的组装可能需要更多的努力和/或时间。
    3. 特定模块可能更难重用和/或测试,因为必须包含依赖模块。

    考虑类似封装业务数据的对象。它有硬编码的方法 每当设置年龄时调用以更新页面:

    var person = {
        name: "John",
        age: 23,
    
        setAge: function( age ) {
            this.age = age;
            showAge( age );
        }
    };
    
    //Different module
    
    function showAge( age ) {
        $("#age").text( age );
    }
    

    现在,如果不包含 showAge 函数,我将无法测试 person 对象。还, 如果我还需要在其他 GUI 模块中显示年龄,我需要对该方法调用进行硬编码 .setAge,现在 person 对象中存在 2 个不相关模块的依赖关系。这也只是 当您看到这些调用被调用并且它们甚至不在同一个文件中时,很难维护。

    请注意,在同一个模块中,您当然可以有直接的方法调用。但业务数据和肤浅 按照任何合理的标准,gui 行为不应驻留在同一模块中。

    【讨论】:

    • 这里我不理解“依赖”的概念;我的第二个示例中的依赖项在哪里,我的第三个示例中缺少什么?我看不出我的第二个和第三个 sn-ps 之间有任何实际区别——它似乎只是在没有真正原因的情况下在函数和事件之间添加了一个新的“层”。我可能是盲目的,但我认为我需要更多的指针。 :(
    • 您能否提供一个示例用例,其中发布/订阅比仅仅创建一个执行相同操作的函数更合适?
    • @Maccath 简单地说:在第三个例子中,你不知道或者必须知道removeOrder 甚至存在,所以你不能依赖它。在第二个例子中,你必须知道。
    • 虽然我仍然觉得有更好的方法来处理您在此处描述的内容,但我至少相信这种方法是有目的的,尤其是在有许多其他开发人员的环境中。 +1
    • @Esailija - 谢谢,我想我明白了一点。所以......如果我完全删除了订户,它不会出错或任何东西,它只会什么都不做?如果您想执行某项操作,但不一定知道在发布时哪个功能最相关,但订阅者可能会根据其他因素而改变,您会说这可能有用吗?
    【解决方案5】:

    PubSub 实现常见于有的地方——

    1. 有一个类似 Portlet 的实现,其中有多个 Portlet 在事件总线的帮助下进行通信。这有助于在 aync 架构中创建。
    2. 在一个因紧密耦合而受损的系统中,pubsub 是一种有助于在各个模块之间进行通信的机制。

    示例代码 -

    var pubSub = {};
    (function(q) {
    
      var messages = [];
    
      q.subscribe = function(message, fn) {
        if (!messages[message]) {
          messages[message] = [];
        }
        messages[message].push(fn);
      }
    
      q.publish = function(message) {
        /* fetch all the subscribers and execute*/
        if (!messages[message]) {
          return false;
        } else {
          for (var message in messages) {
            for (var idx = 0; idx < messages[message].length; idx++) {
              if (messages[message][idx])
                messages[message][idx]();
            }
          }
        }
      }
    })(pubSub);
    
    pubSub.subscribe("event-A", function() {
      console.log('this is A');
    });
    
    pubSub.subscribe("event-A", function() {
      console.log('booyeah A');
    });
    
    pubSub.publish("event-A"); //executes the methods.
    

    【讨论】:

      【解决方案6】:

      "The Many Faces of Publish/Subscribe" 的论文很好读,他们强调的一件事是在三个“维度”上解耦。这是我的粗略总结,但也请参考论文。

      1. 空间解耦。交互方不需要相互了解。发布者不知道谁在听,有多少人在听,或者他们对事件做了什么。订阅者不知道是谁在制作这些事件,有多少制作者等等。
      2. 时间解耦。交互方在交互过程中不需要同时处于活动状态。例如,订阅者可能会在发布者发布某些事件时断开连接,但它可以在它上线时做出反应。
      3. 同步解耦。发布者在产生事件时不会被阻塞,订阅者可以在订阅的事件到达时通过回调异步通知。

      【讨论】:

        【解决方案7】:

        简单回答 最初的问题是寻找一个简单的答案。这是我的尝试。

        Javascript 没有为代码对象提供任何机制来创建自己的事件。所以你需要一种事件机制。发布/订阅模式将满足这一需求,您可以自行选择最适合自己需求的机制。

        现在我们可以看到对 pub/sub 模式的需求,那么您是否愿意以不同于处理 pub/sub 事件的方式来处理 DOM 事件?为了降低复杂性以及关注点分离 (SoC) 等其他概念,您可能会看到一切统一的好处。

        因此自相矛盾的是,更多的代码可以更好地分离关注点,这可以很好地扩展到非常复杂的网页。

        我希望有人认为这是一个足够好的讨论,无需详细说明。

        【讨论】:

          猜你喜欢
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 2016-12-14
          • 1970-01-01
          • 2012-07-01
          • 1970-01-01
          相关资源
          最近更新 更多