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

IT技术 javascript jquery design-patterns publish-subscribe
2021-03-11 10:38:09

所以,一位同事向我介绍了发布/订阅模式(在 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 的用处会在更复杂的应用程序中体现出来,但我无法想象。恐怕我完全没有抓住重点;但我想知道这一点,如果有的话!

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

6个回答

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

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

在谈论松散耦合时,我们应该提到关注点分离. 如果您正在使用 MV* 架构模式构建应用程序,您总是有一个模型和一个视图。模型是应用程序的业务部分。您可以在不同的应用程序中重用它,因此将它与单个应用程序的 View 结合起来不是一个好主意,您希望在其中显示它,因为通常在不同的应用程序中您有不同的视图。因此,使用发布/订阅进行模型-视图通信是一个好主意。当您的模型更改它发布一个事件时,视图会捕获它并更新自身。您没有发布/订阅的任何开销,它可以帮助您解耦。以同样的方式,您可以将应用程序逻辑保留在控制器中(例如 MVVM、MVP,它不完全是控制器)并尽可能保持视图简单。当你的 View 改变(或者用户点击某物,例如)它只是发布一个新事件,Controller 捕获它并决定做什么。如果你熟悉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* 方法,您可以使用一些不同的方法(我接下来要描述的方法和最后提到的方法之间存在交集)。只需在不同的module中构建您的应用程序。例如看看twitter。

twittermodule

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

这是最后一种方法的基本示例(这不是原始 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 的高级实现它具有命名空间和诸如事件“冒泡”之类的功能,当然可以被中断。发布/订阅的另一个缺点是硬单元测试,可能很难隔离module中的不同功能并独立测试它们。

谢谢你的描述。真的帮助我理解了这个概念。
2021-04-21 10:38:09
谢谢,有道理。我对 MVC 模式很熟悉,因为我一直在 PHP 中使用它,但我没有在事件驱动编程方面考虑过它。:)
2021-04-25 10:38:09
很好的解释,多个例子,进一步的阅读建议。一个++。
2021-05-11 10:38:09
这是一个很好的答案。无法阻止自己对此进行投票:)
2021-05-17 10:38:09

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

我将在下面用一些看起来有点像 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。这是使用诸如发布/订阅之类的消息传递系统的要点。

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

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

想象一下,我们订阅了一份经济公报。该公告发布了一个标题:“将道琼斯指数下调 200 点”。这将是一个奇怪且有点不负责任的信息。然而,如果它发表了:“安然今天早上申请了第 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 个函数 ( remindUserToFloss& increaseProgrammerBrowniePoints) 位于不同的module中,您会在其中一个接一个地发布 2 个事件,handleRemoveOrderRequest还是在完成后将flossModule事件发布到browniePointsmoduleremindUserToFloss()
2021-05-10 10:38:09

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

以下是维基百科提到的耦合的一些缺点

紧耦合系统往往表现出以下发展特征,这些特征通常被视为缺点:

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

考虑诸如封装业务数据的对象之类的东西。它有硬编码的方法调用来在设置年龄时更新页面:

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 module中显示年龄,我需要对该方法调用进行硬编码, .setAge现在 person 对象中存在 2 个不相关module的依赖关系。当您看到正在进行的调用并且它们甚至不在同一个文件中时,也很难维护。

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

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

PubSub 实现常见于有 -

  1. 有一个类似于 portlet 的实现,其中有多个 portlet 在事件总线的帮助下进行通信。这有助于在 aync 架构中创建。
  2. 在一个被紧密耦合破坏的系统中,pubsub 是一种有助于在不同module之间进行通信的机制。

示例代码 -

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.