Search code examples
javascriptangularjsangularjs-scopedom-events

How to encapsulate single and temporal events in a service?


I'm trying to encapsulate the events in a service in order to implement a mechanics to subscribe / unsubscribe the listeners when a controller's scope is destroyed. This because I have been using the rootScope.$on in the following way:

if(!$rootScope.$$listeners['event']) {
    $rootScope.$on('event', function(ev, data){
        // do some...
    });
}

or

$scope.$on('$destroy', function(ev, data){
    // unsubscribe the listener
});

So I just need one listener of this event, I need to delete the existing listener when the controller is no longer alive, because the function I registered earlier is still being triggered.

So I need to implement a $destroy event listener on my controller, to destroy the listener when the scope is destroyed, but I don't want to do that code each time I create an event. That's why I want to create a service in where I'm going to encapsulate the events.

angular.module('core').factory('event', [
    function() {
        var service = {};
        service.events = {};
        service.on = function(scope, eventId, callback) {
            scope.$on('$destroy', function(ev, other){
                //unsubscribe
            });
            service.events[eventId] = callback;
            // scope = null; I guess ?
        };
        service.emit = function(eventId, data){
            if (service.events[eventId])
                service.events[eventId](data);
            else
                return new Error('The event is not subscribed');
        };
        return service;
    }
]);

This could be done using $rootScope instead of my own methods but encapsulating the $on and $emit of $rootScope, but at the end I'll have the same issue here.

So these are my questions:

  1. Is a good practice to pass the scope ref value to a service?
  2. What is the meaning of $$destroyed? when this is true means that angularJS has no internal references to the instance?
  3. Should I do a scope = null in my service to let GC delete the object or does angularJS handle an explicit delete?
  4. Is there a better way to do what I want?

Solution

  • What you are trying to accomplish is basically an event bus.
    You have also described very well what is wrong with the current implementation. A different way to approach the problem is to decorate the $rootScope with your bus (or any other event bus for that matter). Here is how:

    app.config(function ($provide) {
    $provide.decorator('$rootScope', ['$delegate', '$$bus', function ($delegate, $$bus) {
      Object.defineProperty($delegate.constructor.prototype, '$bus', {
        get: function () {
          var self = this;
    
          return {
            subscribe: function () {
              var sub = $$bus.subscribe.apply($$bus, arguments);
    
              self.$on('$destroy',
                function () {
                  console.log("unsubscribe!");
                  sub.unsubscribe();
    
                });
            },
    
            publish: $$bus.publish
          };
        },
        enumerable: false
      });
    
      return $delegate;
    }]);
    });
    

    Considering the following $$bus implementation (kept basic for simplicity):

    app.factory('$$bus', function () {
      var api = {};
      var events = {};
    
      api.subscribe = function (event) {
        if (!events.hasOwnProperty(event.name)) {
          events[event.name] = [event];
        } else {
          events[event.name].push(event);
        }
        return {
          unsubscribe: function () {
            api.unsubscribe(event);
          }
        }
      };
    
      api.publish = function (eventName, data) {
        if (events.hasOwnProperty(eventName)) {
          console.log(eventName);
    
          angular.forEach(events[eventName], function (subscriber) {
            subscriber.callback.call(this, data);
          });
        }
      };
    
      api.unsubscribe = function (event) {
        if (events.hasOwnProperty(event.name)) {
          events[event.name].splice(events[event.name].indexOf(event), 1);
          if (events[event.name].length == 0) {
            delete events[event.name];
          }
        }
      };
    
      return api;
    });
    

    Now all you have to do is subscribe or publish events. The unsubscribe will take place automatically (when the $scope is destroyed):

      $scope.$bus.subscribe({
        name: 'test', callback: function (data) {
          console.log(data);
        }
      });
    

    And later on publish an event:

      $scope.$bus.publish('test', {name: "publishing event!"});
    

    An important point to make is that the events themselves are subscribed to each individual $scope and not on the $rootScope. That is how you "know" which $scope to release.

    I think it answers your question. With that in mind, you can obviously make this mechanism much sophisticated (such as controller event listener released when a view routed, unsubscribe automatically only to certain events, etc.). Good luck!

    ** This solution is taken form Here which uses a different bus framework (other then that it is the same).