Naghmouchi Omar Naghmouchi Omar - 14 days ago 4
AngularJS Question

Manipulating DOM in angularJS : best practice?

We are building a big web application using

AngularJS
.
We use custom directive a lot for different cases. When it comes to do DOM manipulation, binding event, etc...It happens, that we define functions that manipulates the DOM in a custom directive's
link
function, but we call it from the controller (we define functions in the
$scope
so it can be accessible by the given controller). I think the angular way to do it, would be to define a separate custom directive for each function and use it directly from the template but in our case i don't know to what point it will be confortable to do so, we have already a lot of custom directive, so is it BAD to do what we are doing (defining function that manipulate the DOM in a directive and call it from controller), does that even has a meaning or it's just like we are manipulating the DOM in controller ? For us, it's kinda of concern separation, we never define function that manipulate the DOM in controller, only in directive, but call it from the controller doesn't seems to be so right, is it?

An example showing how our custom directive look like:

angular.module('exp', []).directive('customdirectiveExp', ['', function(){
// Runs during compile
return {
name: 'customDirectiveExp',
controller: "ControllerExp",
controllerAs: "ctrl",
templateUrl: 'templateExp',
link: function($scope, iElm, iAttrs, controller) {

/* These function will be called from the ControllerExp when it needs so.
Function can do any things like manipulating the DOM, addin
event listner ...
*/
scope.manipulateDom1 = function(){
// DOM manipualtion
};

scope.manipulateDom2 = function(){
// DOM manipualtion
};

scope.manipulateDom3 = function(){
// DOM manipualtion
};

}
};
}]);

Answer

I think the "don't manipulate the DOM from controllers" mantra is back from the days, when directives mainly/only used linking functions (or directive controllers where just a way to intercommunicate with other directives).

The currently suggested best practice is to use "components" (which can be realized via directives), where basically all the directive logic leaves in the controller. (Note for example that in Angular 2 there is no linking functions and each component/directive is basically a class/controller (plus some metadata).)

In that context, I believe it is perfectly fine to manipulate the DOM in a directive's template from within the directive's controller.


The idea is to keep your templates/HTML declarative. Compare the following snippets:

<!--
  `SomeController` reaches out in the DOM and
  makes changes to `myComponent`'s template --- BAD
-->
<div ng-controller="SomeController">
  ...
  <my-component></my-component>
  ...
</div>

vs

<div ng-controller="SomeController">
  ...
  <!--
    `myComponent`'s controller makes changes to
    `myComponent`'s template --- OK
  -->
  <my-component></my-component>
  ...
</div>

In the first (bad) example, myComponent will have different behavior/appearance depending on where in the DOM it appears (e.g. is it under SomeController ?). What's more important, it is very hard to find out what other (unrelated) part might be changing myComponent's behavior/appearance.

In the second (good) example, myComponent's behavior and appearance will be consistent across the app and it is very easy to find out what it will be: I just have to look in the directive's definition (one place).


There are a couple of caveats though:

  1. You don't want to mix your DOM manipulation code with other logic. (It would make your code less maintainable and harder to test).

  2. Often, you want to manipulate the DOM in the post-linking phase, when all children are in place (compiled + linked). Running the DOM manipulation code during controller instantiation would mean that the template content has not been processed yet.

  3. Usually, you don't want to run the DOM manipulation when your controller is not instantiated in the context of a directive, because that would mean you always need a compiled template in order to test your controller. This is undesirable, because it makes unit tests slower, even if you only want to test other parts of the controller logic that are not DOM/HTML related.

So, what can we do ?

  • Isolate your DOM manipulation code in a dedicated function. This function will be called when appropriate (see below), but all DOM interaction will be in one place, which makes it easier to review.

  • Expose that function as a controller method and call it from your directive's linking function (instead of during controller initialization). This ensures that the DOM will be in the desired state (if that is necessary) and also decouples "stand-alone" controller instantiation from DOM manipulation.

What we gain:

  • If your controller is instantiated as part of directive's compiling/linking, the method will be called and the DOM will be manipulated, as expected.

  • In unit-tests, if you don't need the DOM manipulation logic, you can instantiate the controller directly and test it's business logic (independently of any DOM or compiling).

  • You have more control over when the DOM manipulation happens (in unit tests). E.g. you can instantiate the controller directly, but still pass in an $element, make any assertions you might want to make, then manually call the DOM-manipulating method and assert that the element is transformed properly. It is also easier to pass is a mocked $element and stuff like adding event listeners, without having to set up a real DOM.

The downside of this approach (exposing method and calling it from the linking function), is the extra boilerplate. If you are using Angular 1.5.x, you can spare the boilerplate by using the directive controller lifecycle hooks (e.g. $onInit or $postLink), without a need to have a linking function, just to get hold of the controller and call a method on it. (Bonus feature: Using the 1.5.x component syntax with lifecycle hooks, would make it easier to migrate to Angular 2.)

Examples:

Before v1.5.x

.directive('myButton', function myButtonDirective() {
  // DDO
  return {
    template: '<button ng-click="$ctrl.onClick()></button>',
    scope: {}
    bindToController: {
      label: '@'
    }
    controllerAs: '$ctrl',
    controller: function MyButtonController($element) {
      // Variables - Private
      var self = this;

      // Functions - Public
      self._setupElement = _setupElement;
      self.onClick = onClick;

      // Functions - Definitions
      function _setupElement() {
        $element.text(self.label);
      }

      function onClick() {
        alert('*click*');
      }
    },
    link: function myButtonPostLink(scope, elem, attrs, ctrl) {
      ctrl._setupElement();
    }
  };
})

After v1.5.x

.component('myButton', {
  template: '<button ng-click="$ctrl.onClick()></button>',
  bindings: {
    label: '@'
  }
  controller: function MyButtonController($element) {
    // Variables - Private
    var self = this;

    // Functions - Public
    self.$postLink = $postLink;
    self.onClick = onClick;

    // Functions - Definitions
    function $postLink() {
      $element.text(self.label);
    }

    function onClick() {
      alert('*click*');
    }
  }
})
Comments