Bas Slagter Bas Slagter - 3 months ago 80
AngularJS Question

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

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?

Answer

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.

Comments