AngularJS : What is the best way to bind to a global event in a directive

60,383

Solution 1

I have chosen another method, to effectively localise global events, like window resizing. It converts Javascript events to Angular scope events, via another directive.

app.directive('resize', function($window) {
  return {
    link: function(scope) {
      function onResize(e) {
        // Namespacing events with name of directive + event to avoid collisions
        scope.$broadcast('resize::resize');
      }

      function cleanUp() {
        angular.element($window).off('resize', onResize);
      }

      angular.element($window).on('resize', onResize);
      scope.$on('$destroy', cleanUp);
    }
  }
});

Which can be used, in the basic case, on the root element of the app

<body ng-app="myApp" resize>...

And then listen for the event in other directives

<div my-directive>....

coded up as:

app.directive('myDirective', function() {
  return {
    link: function(scope, element) {
      scope.$on('resize::resize', function() {
        doSomethingFancy(element);
      });
    });
  }
});

This has a number of benefits over other approaches:

  • Not brittle to the exact form on how directives are used. Your Option 2 requires my-directive when angular treats the following as equivalent: my:directive, data-my-directive, x-my-directive, my_directive as can be seen in the guide for directives

  • You have a single place to affect exactly how the Javascript event is converted to the Angular event, which then affects all listeners. Say you later want to debounce the javascript resize event, using the Lodash debounce function. You could amend the resize directive to:

    angular.element($window).on('resize', $window._.debounce(function() {
      scope.$broadcast('resize::resize');
    },500));
    
  • Because it doesn't necessarily fire the events on $rootScope, you can restrict the events to only part of your app just by moving where you put the resize directive

    <body ng-app="myApp">
      <div>
        <!-- No 'resize' events here -->
      </div>
      <div resize>
        <!-- 'resize' events are $broadcast here -->
      </div>
    
  • You can extend the directive with options, and use it differently in different parts of your app. Say you want different debounced versions in different parts:

    link: function(scope, element, attrs) {
      var wait = 0;
      attrs.$observe('resize', function(newWait) {
        wait = $window.parseInt(newWait || 0);
      });
      angular.element($window).on('resize', $window._.debounce(function() {
        scope.$broadcast('resize::resize');
      }, wait));
    }
    

    Used as:

    <div resize>
      <!-- Undebounced 'resize' Angular events here -->
    </div>
    <div resize="500">
      <!-- 'resize' is debounced by 500 milliseconds -->
    </div>
    
  • You can later extend the directive with other events that might be useful. Maybe things like resize::heightIncrease. resize::heightDecrease, resize::widthIncrease, resize::widthDecrease. You then have one place in your app that deals with remembering and processing the exact dimensions of the window.

  • You can pass data along with the events. Say like the viewport height/width where you might need to deal with cross-browser issues (depending on how far back you need IE support, and whether you include another library to help you).

    angular.element($window).on('resize', function() {
      // From http://stackoverflow.com/a/11744120/1319998
      var w = $window,
          d = $document[0],
          e = d.documentElement,
          g = d.getElementsByTagName('body')[0],
          x = w.innerWidth || e.clientWidth || g.clientWidth,
          y = w.innerHeight|| e.clientHeight|| g.clientHeight;
      scope.$broadcast('resize::resize', {
        innerWidth: x,
        innerHeight: y
      });
    });
    

    which gives you a single place to add to the data later. E.g. say you want to send the difference in dimensions since the last debounced event? You could probably add a bit of code to remember the old size and send the difference.

Essentially this design provides a way to convert, in a configurable manner, global Javascript events to local Angular events, and local not just to an app, but local to different parts of an app, depending on the placement of the directive.

Solution 2

When developing on top of a framework, I often find it helpful to think agnostically about a problem before designing an idiomatic. Answering the "what" and the "why" drives out the "how".

The answer here really depends on the complexity of doSomethingFancy(). Is there data, a set of functionality, or domain object(s) associated with instances of this directive? Is it a purely presentational concern, like adjusting the width or height properties of certain elements to appropriate proportions of the window size? Make sure you're using the right tool for the job; don't bring the whole Swiss Army knife when the job calls for tweezers and you have access to a standalone pair. For sake of continuing in this vein, I'm going to operate with the assumption that doSomethingFancy() is a purely presentational function.

The concern of wrapping a global browser event in an Angular event could be handled by some simple run phase configuration:

angular.module('myApp')
    .run(function ($rootScope) {
        angular.element(window).on('resize', function () {
            $rootScope.$broadcast('global:resize');  
        })
    })
;

Now Angular doesn't have to do all of the work associated with a directive on each $digest, but you're getting the same functionality.

The second concern is operating on n number of elements when this event is fired. Again, if you don't need all of the bells and whistles of a directive, there are other ways to make this happen. You could expand or adapt the approach in the run block above:

angular.module('myApp')
    .run(function () {
        angular.element(window).on('resize', function () {
            var elements = document.querySelectorAll('.reacts-to-resize');
        })
    })
;

If you do have more complex logic that needs to happen on the resize event, it still does not necessarily mean that one or more directives are the best way to handle it. You could use a simple mediator service that gets instantiated instead of the aforementioned anonymous run phase configuration:

/**
 * you can inject any services you want: $rootScope if you still want to $broadcast (in)
 * which case, you'd have a "Publisher" instead of a "Mediator"), one or more services 
 * that maintain some domain objects that you want to manipulate, etc.
 */
function ResizeMediator($window) {
    function doSomethingFancy() {
        // whatever fancy stuff you want to do
    }

    angular.element($window).bind('resize', function () {
        // call doSomethingFancy() or maybe some other stuff
    });
}

angular.module('myApp')
    .service('resizeMediator', ResizeMediator)
    .run(resizeMediator)
;

Now we have an encapsulated service that can be unit tested, but doesn't run unused execution phases.

A couple concerns that would also factor into the decision:

  • Dead listeners - with Option 1, you're creating at least one event listener for every instance of the directive. If these elements are being dynamically added to or removed from the DOM, and you don't call $on('$destroy'), you're running the risk of event handlers applying themselves when their elements no longer exist.
  • Performance of width/height operators - I'm assuming that there is box-model logic here, given that the global event is the browser resize. If not, ignore this one; if so, you'll want to be careful about which properties you're accessing and how often, because browser reflows can be a huge culprit in performance degradation.

It's likely that this answer is not as "Angular" as you were hoping for, but it's the way I'd solve the problem as I understand it with the added assumption of box-model-only logic.

Solution 3

In my opinion I would go with method #1 and a little tweak of using the $window service.

angular.module('app').directive('myDirective', function($window){

     function doSomethingFancy(el){
         // In here we have our operations on the element
    }

    return {
        link: function(scope, element){
             // Bind to the window resize event for each directive instance.
             anguar.element($window).bind('resize', function(){
                  doSomethingFancy(element);
             });
        }
    };
});

#2 In reference to this approach and a slight change in thinking here - you could put this event listener somewhere higher up in say app.run - and here when the event happens you can broadcast another event which the directive picks up and does something fancy when that event takes place.

EDIT: The more I think about this method the more I actually start to like it over the first one... Great robust way to listen to the window resize event - maybe in the future something else needs to "know" this info as well and unless you do something like this you are forced to setup - yet again - another event listener to the window.resize event.

app.run

app.run(function($window, $rootScope) {
  angular.element($window).bind('resize', function(){
     $rootScope.$broadcast('window-resize');
  });
}

Directive angular.module('app').directive('myDirective', function($rootScope){

     function doSomethingFancy(el){
         // In here we have our operations on the element
    }

    return {
        link: function(scope, element){
             // Bind to the window resize event for each directive instance.
             $rootScope.$on('window-resize', function(){
                  doSomethingFancy(element);
             });
        }
    };
});

Finally An awesome source of how to do stuff is to follow the angular-ui guys for example the ui-bootstrap. I have learned a bunch of how to stuff from these guys for example the joys of learning to unit test in angular. They provide a great clean codebase to checkout.

Solution 4

Here's one way you could do it, just store your elements in an array, then in the "global event" you can loop through the elements and do what you need to do.

angular.module('app').directive('myDirective', function($window){

    var elements = [];

    $window.on('resize', function(){
       elements.forEach(function(element){
           // In here we have our operations on the element
       });
    });

    return {
        link: function(scope, element){
            elements.push(element);
        }
    };
});

Solution 5

The second approach feels more brittle, since Angular offers many ways to refer to the directive in the template (my-directive, my_directive, my:directive, x-my-directive, data-my-directive, etc.) so a CSS selector covering them all might get really complex.

This probably isn't a big deal if you only use the directives internally or they consist of a single word. But if other developers (with different coding conventions) might be using your directives, you may want to avoid the second approach.

But I'd be pragmatic. If you're dealing with a handful of instances, go with #1. If you have hundreds of them, I'd go with #2.

Share:
60,383
Bas Slagter
Author by

Bas Slagter

Software Engineer. Graduated at the University of Amsterdam (MSc)

Updated on July 08, 2022

Comments

  • Bas Slagter
    Bas Slagter almost 2 years

    Imagine the situation in AngularJS where you want to create a directive that needs to respond to a global event. In this case, let's say, the window resize event.

    What is the best approach for this? The way I see it, we have two options: 1. Let every directive bind to the event and do it's magic on the current element 2. Create a global event listener that does a DOM selector to get each element on which the logic should be applied.

    Option 1 has the advantage that you already have access to the element on which you want to do some operations. But...options 2 has the advantage that you do not have to bind multiple times (for each directive) on the same event which can be a performance benefit.

    Let's illustrate both options:

    Option 1:

    angular.module('app').directive('myDirective', function(){
    
         function doSomethingFancy(el){
             // In here we have our operations on the element
        }
    
        return {
            link: function(scope, element){
                 // Bind to the window resize event for each directive instance.
                 angular.element(window).on('resize', function(){
                      doSomethingFancy(element);
                 });
            }
        };
    });
    

    Option 2:

    angular.module('app').directive('myDirective', function(){
    
        function doSomethingFancy(){
             var elements = document.querySelectorAll('[my-directive]');
             angular.forEach(elements, function(el){
                 // In here we have our operations on the element
             });
        }
    
        return {
            link: function(scope, element){
                 // Maybe we have to do something in here, maybe not.
            }
        };
    
        // Bind to the window resize event only once.
        angular.element(window).on('resize', doSomethingFancy);
    });
    

    Both approaches are working fine but I feel that option two is not really 'Angular-ish'.

    Any ideas?