简体   繁体   中英

How to create a pub/sub pattern using AngularJS

I am writing a SPA using angular (1.4.7) and to keep the complexity down I'd been attempting to abstract the persistence logic to a factory/repository.

This is nothing special and seems to work fine.

One feature I'd like to implement is the ability for a "parent" scope to update when the user updates some personal information.

See the example https://jsfiddle.net/h1r9zjt4/

I've taken a look at various ways of implementing this and a few ways I've seen are:

  • Use $rootScope to share common objects
    I've been attempting to steer away from using scopes and to only use the controllerAs syntax. This seems to be the advised solution to keep a strict/robust separation between controller and view.
  • Use $scope.$parent to access the required property
    For similar reasons, this couples my view implementation to my controller implementation.
  • Use $on/$emit to communicate between controllers
    As well as sounding like an eventual maintenance nightmare, this inherently means that controllers know about other controllers. Not ideal.

My ideal scenario would be to have a pub/sub scenario.

My user updating their details will be handled by the repository which in-turn sends a command or fulfils a promise to all subscribers of that repository.

Is this a standard angular pattern? If not, what would a suitable alternative be?

Although it's mostly associated with the React world, what you are looking for is Flux . It's even been ported to Angular in the form of flux-angular .

Flux enforces a pattern for how you think about data flowing through your application.

The shared models that allow you to publish and subscribe to changes with are called stores . However, you don't speak to them in the conventional pubsub way.

Stores

A store is responsible for looking after some data and handling any actions that you trigger. For instance a store for a counter might look something like this:

app.store('CounterStore', function() {
  return {
    count: 0,
    increment: function() {
      this.count = this.count + 1;
      this.emitChange();
    },
    decrement: function() {
      this.count = this.count - 1;
      this.emitChange();
    },
    exports: {
      getCount: function() {
        return this.count;
      }
    }
  };
});

Then inject your store into a controller or directive to listen for changes.

Think of this as the subscription part of a pub/sub architecture.

app.directive('Counter', function() {
  return {
    template: '<div ng-bind='count'></div>',
    controller: function($scope, CounterStore) {
      $scope.listenTo(CounterStore, function() {
        $scope.count = CounterStore.getCount();
      });
    }
  };
});

Actions

The other piece in the Flux puzzle is dispatching actions. This is the publishing part of a pub/sub architecture, on steroids.

Rather than emitting events like you could do with the root scope's event emitter, you dispatch serializable actions and Flux does the rest for you.

Let's define a final directive to control the counter in the previous directive, using Flux.

app.directive('CounterControls', function() {
  return {
    template: '<button ng-click="inc()">+</button>' + 
              '<button ng-click="dec()">-</button>',
    controller: function($scope, flux) {
      $scope.inc = function() {
        flux.dispatch('increment')
      };

      $scope.dec = function() {
        flux.dispatch('decrement');
      };
    }
  };
});

This code doesn't even know about the store! It just knows that these are the actions that should be dispatched when these buttons are clicked.

Once these actions have been dispatched, Flux uses the name of the action to call the appropriate functions within the stores. These stores update their data and if necessary, they emit a change, notifying the subscribers so that they can update their data too.

It might seem like a lot of code for sharing a counter between two directives, but it's a very powerful idea and in the long term will keep the architecture of your application clean and concise.

Conclusion

Flux is a pretty cool architecture. Here's a run down of why it might suit you better than the other solutions you mentioned.

Separation of Concerns

Flux allows you to move all state management code out into loosely coupled modules called stores . This way none of your controllers will ever have to know about any other controllers.

Serializable Actions

If you make sure that you only dispatch actions that can be serialized, then you can keep a track of every action that's fired in your application, meaning it's possible to recreate any state, by simplying re-playing the same actions again.

To get some idea of just how cool this can be, check out this video about time travel with a Flux implementation called Redux.

One Way Data

It's easier to reason about your program when data only flows in one direction. When you use Flux, there's no reason to ever communicate with any components other than your children.

  A---+
 / \  |
/   v |
B   D |
|  /  |
| /   |
C-----+

In a more traditional pub/sub architecture, if directive C wanted to communicate with directive A and D it would have to maintain a complex entangled hierarchy, which gets more and more difficult to manage each time you let one directive or controller know about another.

It's not clear which way the data is flowing because directives can communicate with eachother, regardless of where they are.

  A <------------+
 / \             |
v   v            |
B   D  <----- [store]
|                ^
v                |
C --> [action] --+

With Flux, your directives only communicate with their children and with stores — data flows in one direction round your application, making it much easier to work out how a value got somewhere, or why a function was called.

Using $on/$emit is definitely a viable option, but you need to be careful not to overuse it, since it might lead to a very complex application that is hard to debug and track.

Another way (which i think is better in most cases) is to use services. Since services are singletons by nature, data on a service will be shared across all application.

So you can have a service that is injected in both the parent and child controller and once a change is made on the child, it will update a property on the service and the parent will $watch that attribute and act upon change:

var app = angular.module('myApp', []);

app.factory('sharedService', function() {
    return {
        sharedAttr: ''
    }

});    

app.controller('childCtrl', function($scope, sharedService) {
    $scope.onAttrChange = function() {
        sharedService.sharedAttr = 'Value Changed';
    }
});

app.controller('parentCtrl', function($scope, sharedService) {
    $scope.$watch(function() {
            return sharedService.sharedAttr;
        },
        function(newVal, oldVal) {
            //do something with newValue
        });
});    

I use postaljs and inject a $bus to $scopes as show the blog An angular.js event bus with postal.js

Note that code snippet at blog throws an Unable to get property 'length' of undefined , I fixed it as:

app.config(function($provide) {
$provide.decorator('$rootScope', [
    '$delegate',
    function($delegate) {
        Object.defineProperty($delegate.constructor.prototype,
            '$bus', {
                get: function() {
                    var self = this;

                    return {
                        subscribe: function() {
                            var sub = postal.subscribe.apply(postal, arguments);

                            self.$on('$destroy',
                                function() {
                                    sub.unsubscribe();
                                });
                        },
                        //Fix to avoid postaljs v 2.0.4:513 Unable to get property 'length' of undefined
                        channel: function() { return postal.channel.apply(postal,arguments);  },
                        publish: function() { postal.publish.apply(postal,arguments); }
                    };
                },
                enumerable: false
            });

        return $delegate;
    }
]);

Subscribe controller:

var subscription = $scope.$bus.subscribe({
            channel: "organizations",
            topic: "item.changed",
            callback: function(data, envelope) {
                // `data` is the data published by the publisher.
                // `envelope` is a wrapper around the data & contains
                // metadata about the message like the channel, topic,
                // timestamp and any other data which might have been
                // added by the sender.
            }
        });

Publish controller:

channel = $scope.$bus.channel('organizations');
channel.publish("item.changed",data);

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM