Wednesday, October 28, 2015

Angular Directives as ES6 Classes

I have been working on having my angular 1.x directive's controllers exist as classes, just like I already do with pure controllers and with services.  I'm honing in on a pattern for directives that I like.

This accomplishes is a few things:
  • Consolidating the directive into a single class reduces global namespace and scope pollution.
  • When you use an ES6 loader (like System.js or webpack) there's just one thing to export/import: the class itself
  • app.js still has to call a registration function, but it's now simpler.  It calls a static class method with one parameter, the module to which to attach the directive.  No longer does app.js have to know what parameters to inject into some directive that's off in a separate file.
  • Writing your controllers as classes encourages you to write directive logic in more testable ways.  Hiding the call to angular's own directive registration keep's the class's business private.  I have found that doing this helps me clarify what's done in the controller vs. the link function, and thinking about that has made my link functions very short (as I think they should be).
  • I still provided a way to hook in to the directive lifecycle to do things like cancel timers, in-process requests to ngResource, etc.

So for the pattern looks like this:

/*
 * A directive called "Reformer", in a Class
 */
export class Reformer{

   static inject() {  Reformer.$inject = [ '$interval', '$q' ];  }
   constructor($interval, $q) {
      this.$interval = $interval;
      this.$q = $q;
   }
   
   tick() {
      console.write("Ping!");
   }

   initialize() {
      this.timer = this.$interval( () => { this.tick() }, 1000);
   }

   destroy() {
      this.$interval.cancel( this.timer );
   }


   static register(module) {
      module.directive('reformer', function () {
         return {
            restrict: 'E',
            templateUrl: 'reformerTemplate.html',
            transclude: false,
            replace: true,
            scope: false,
            controller: Reformer,
            link: function (scope, elem, attrs, ctrl) {
               ctrl.initialize();

               scope.$on('destroy', function () {
                  ctrl.destroy();
                });
             }
          };
      });
   }
}
Reformer.inject();  /* set up injection */

Now, from app.js you just call the static registration method:

import { Reformer } from 'reformer';
Reformer.register(mainModule);


If you're wondering why I did the $inject spec that way, it's because it drives me nuts to have the constructor at one end and the $inject spec at the other end of the file.

This same basic technique works with services, but since they are a little simpler I did not document them here.


1 comment:

  1. A friend suggested it would be more testable to register the controller separately, then register the directive using the controller by name. I felt like that's less refactorable (I hate referring to components by strings) but his opinion was that it's so much more testable when you truly separate the controller from the directive that it's worth it. Contemplating this ...

    ReplyDelete