为什么会使用发布/订阅模式(在JS / jQuery中)?

105

所以,我的同事向我介绍了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功能的能力,适用于不同的事件等。
但是,如果它可以做到相同的事情,为什么要决定实现发布/订阅模式并采取以下措施呢?(FYI,我使用了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'));
});

我确实阅读过这个模式,但我无法想象为什么会有必要使用它。我看过的教程只涵盖了和我的例子一样基础的示例,解释了如何实现这种模式。
我想象发布/订阅模式在更复杂的应用程序中会发挥作用,但我无法想象一个这样的应用程序。我担心我完全没有抓住重点;但如果有的话,我想知道这个重点!
您能简明扼要地解释一下,在什么情况下这种模式有优势吗?像我上面的代码片段一样,值得使用发布/订阅模式吗?
7个回答

228

这全部都涉及到松耦合和单一责任,与JavaScript中的MV*(MVC/MVP/MVVM)模式密不可分,这些模式在最近几年非常流行。

松耦合是面向对象的原则之一,其中系统的每个组件都知道自己的职责,并且不关心其他组件(或者至少尽可能地不关心)。松耦合是一件好事,因为您可以轻松地重用不同的模块。您不会受到其他模块接口的影响。使用发布/订阅,您只与发布/订阅接口相耦合,这并不是什么大问题 - 只有两种方法。因此,如果您决定在不同的项目中重用一个模块,您只需复制粘贴它,它很可能可以工作,或者至少您不需要太多的工作来使其正常工作。

谈到松耦合时,我们应该提到关注点分离。如果您正在使用MV*架构模式构建应用程序,则始终具有一个或多个模型和视图。模型是应用程序的业务部分。您可以在不同的应用程序中重用它,因此不建议将其与要显示的单个应用程序的视图耦合,因为通常在不同的应用程序中具有不同的视图。因此,对于模型-视图通信,使用发布/订阅是一个好主意。当您的模型发生更改时,它会发布一个事件,视图捕获它并更新自己。您不会有任何来自发布/订阅的额外负担,它有助于解耦。以同样的方式,您可以将应用程序逻辑保留在控制器中(例如MVVM,MVP并不完全是控制器),并尽可能简化视图。当您的视图发生更改(或用户单击某些内容时,例如),它只会发布一个新事件,控制器捕获它并决定如何处理。如果您熟悉MVC模式或Microsoft技术(WPF/Silverlight)中的MVVM,则可以将发布/订阅看作是观察者模式。这种方法在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。

Twitter Modules

如果你查看界面,你会发现有不同的方块。你可以把每个方块看作一个不同的模块。例如,你可以发布一条推文。这个操作需要更新几个模块。首先,它必须更新你的个人资料数据(左上角的框),但它还必须更新你的时间轴。当然,你可以保留对这两个模块的引用,并分别使用它们的公共接口进行更新,但是通过发布事件更容易(也更好)。这将使得应用程序的修改更加容易,因为耦合度更低。如果你开发了一个依赖于新推文的新模块,只需订阅“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发表。

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


3
谢谢,这很有道理。我对MVC模式很熟悉,因为我经常在PHP中使用它,但是我从未考虑过它与事件驱动编程的关系。 :) - Maccath
2
感谢这个描述。真的帮助我理解了这个概念。 - kyler
1
这是一个非常好的答案。我忍不住点了个赞 :) - Naveed Butt
1
非常好的解释,多个例子,进一步阅读建议。A++。 - Carson

16

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

下面我将用类似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类接收到的信号,即扬声器:

(如果比喻不是最好的,请原谅...)

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发布信号。这就是使用发布/订阅消息传递系统的目的。


非常棒的是有一个具体的例子,展示了如何实现发布/订阅模式比使用“普通”方法更好!谢谢! - Maccath
1
不客气!就我个人而言,我经常发现当我意识到某个模式或方法对我解决了实际问题时,我的大脑才会“点击”。订阅/发布模式非常适用于概念上紧密耦合但我们仍然希望尽可能保持它们分离的架构。例如,想象一下一个游戏,其中有数百个对象都必须对周围发生的事情做出反应,而这些对象可以是任何东西:玩家、子弹、树木、几何图形、GUI等等。 - Anders Arpi
3
JavaScript 没有 class 关键字。以下是伪代码示例,强调该事实: // 以下是伪代码 定义 myClass myClass.prototype.constructor = function() {} myClass.prototype.someMethod = function() {} - Rob W
实际上,在ES6中有一个class关键字。 - Minko Gechev

5
其他回答已经很好地展示了这种模式的工作原理。我想回答一下隐含的问题“旧方法有什么问题?”因为我最近一直在使用这个模式,我发现它需要我改变思维方式。
想象一下我们订阅了一份经济简报。该简报发布了一个标题:“降低道琼斯指数200点”。这将是一个奇怪且有些不负责任的消息。但如果它发布了:“安然公司今天上午申请破产保护”,那么这就是一个更有用的消息。请注意,这条消息可能会导致道琼斯指数下跌200点,但那又是另一回事了。
发送命令和通知刚刚发生的事情之间存在区别。有了这个想法,先忽略处理程序,看看你最初的发布/订阅模式:
$.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...
}

区分命令和通知在这个模式中是有用的区分,我认为。

如果您的最后两个函数(remindUserToFlossincreaseProgrammerBrowniePoints)位于不同的模块中,您会在handleRemoveOrderRequest中连续发布两个事件吗?还是当remindUserToFloss()完成时,您会让flossModulebrowniePoints模块发布一个事件? - Bryan P

4
为了避免硬编码方法/函数调用,您只需发布事件而不必关心谁在听。这使发布者独立于订阅者,减少了应用程序中2个不同部分之间的依赖性(或耦合,无论您喜欢哪个术语)。
以下是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函数。此外,如果我还需要在其他GUI模块中显示年龄,则需要在setAge中硬编码该方法调用,现在person对象有两个不相关模块的依赖性。当你看到这些调用甚至不在同一个文件中时,维护起来也很困难。
请注意,在同一模块内,您当然可以直接调用方法。但是,按照任何合理的标准,业务数据和表面的GUI行为不应驻留在同一模块中。

1
你能提供一个样例使用场景,说明在哪种情况下使用发布/订阅更为适合,而不仅仅是编写一个执行相同任务的函数吗? - Jeffrey Sweeney
@Maccath 简单来说:在第三个例子中,你不知道或者不必知道 removeOrder 的存在,因此你不能依赖它。而在第二个例子中,你必须知道。 - Esailija
虽然我仍然觉得有更好的方法来处理你在这里描述的问题,但我至少相信这种方法论有其目的,特别是在有许多其他开发人员的环境中。+1 - Jeffrey Sweeney
@JeffreySweeney 这样的消息传递提供了最低程度的耦合,同时仍然允许模块之间进行通信。你有想到更好的方法吗? - Esailija
1
@Esailija - 谢谢,我觉得我有点明白了。那么...如果我完全删除订阅者,它不会出错或做任何事情,它只是什么都不做?你会说这在某些情况下可能很有用,比如你想执行一个操作,但在发布时可能不知道哪个函数最相关,但订阅者可能会根据其他因素而改变? - Maccath
显示剩余2条评论

1
这篇名为"The Many Faces of Publish/Subscribe"的论文极具参考价值,其中一种重要思想是三维解耦。以下是我的简单概括,请务必查阅原文以获取更详尽信息。
  1. 空间解耦。参与交互的各方不需要知道彼此的身份。发布者并不知道谁在监听、监听者的数量,或者他们如何使用该事件。订阅者也不知道生产者的身份、生产者数量等信息。
  2. 时间解耦。 参与交互的各方不需要同时在线。例如,在生产者发布事件时,某个订阅者可能处于离线状态,但当它上线时就能对该事件做出反应。
  3. 同步解耦。 在生产者产生事件时,订阅者不会被阻塞,而是通过回调函数异步地收到通知。

1

PubSub实现通常在以下情况下见到 -

  1. 有一个类似于门户网站的实现,其中有多个门户网站通过事件总线进行通信。这有助于创建异步架构。
  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.

0

简单回答 原问题寻找一个简单的答案。以下是我的尝试。

Javascript不提供任何机制让代码对象创建自己的事件。因此,你需要一种事件机制。发布/订阅模式将满足这个需求,而选择最适合你自己需求的机制则取决于你自己。

现在我们可以看到需要使用发布/订阅模式,那么你是否宁愿处理DOM事件与处理发布/订阅事件有所不同呢?为了降低复杂性和其他概念(如关注点分离),你可能会发现一切保持一致的好处。

所以矛盾的是,更多的代码创造了更好的关注点分离,这在非常复杂的网页上也能很好地扩展。

我希望有人会觉得这是一个足够好的讨论,而不必深入细节。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接