Recursive AngularJS Directives

It has been a long time since I’ve done any greenfield UI development. Most of my projects over the past several years have involved extending existing Web Forms or ASP.NET MVC apps with some new controls here or there. You can imagine my delight in being able to work on a brand new system with AngularJS as the front-end technology.

I’ve heard a number of good things about AngularJS over the past year or so and have familiarized myself with enough of its core concepts to hold (hopefully) an intelligent conversation about it but this is my first time actually using it on a real project. Given that I still have plenty to learn it didn’t surprise me too much when I encountered my first real roadblock – a recursive directive.

The problem I was facing is that I needed to display some hierarchical data. If the structure would never be more than two levels deep it would be easy to simply nest an ngRepeat within another ngRepeat and get on with life. But I needed to handle a structure containing an arbitrary number of levels as depicted by the following array (for the purposes of this discussion, let’s assume the array is bound to a scope property named items):

[ { name: "Item 1",
    items: [{ name: "Item 1.1", items: [] },
            { name: "Item 1.2",
              items: [{ name: "Item 1.2.1", items: [] },
                      { name: "Item 1.2.2", items: [] }] }] },
  { name: "Item 2",
    items: [{ name: "Item 2.1", items: [] },
            { name: "Item 2.2", items: [] }] },
  { name: "Item 3", items: [] },
  { name: "Item 4",
    items: [{ name: "Item 4.1", items: [] },
            { name: "Item 4.2", items: [] }] } ]

My first thought when creating the directive was to simply define it with a self-referencing template much like this:

+function (ng) {
    var HierarchyViewer = (function () {
        var HierarchyViewer = function () {
            this.restrict = "A";
            this.template =
                "<p>{{item.name}}</p>\
                <ul>\
                    <li ng-repeat=\"item in items\"\
                        hierarchy-viewer=\"item\"></li>\
                </ul>";
        };

        return HierarchyViewer;
    })();

    HierarchyViewer.create = function () { return new HierarchyViewer(); };

    ng.
        module("mainApp").
        directive("hierarchyViewer", HierarchyViewer.create);
}(angular);

I then attached the directive to a div element using the standard attribute syntax, browsed to the page, and…nothing. Upon inspecting the console, I discovered that the directive was causing a stack overflow (“Out of stack space” error in IE 11, “Maximum call stack size exceeded” in Chrome).

My lack of understanding how directive processing works led me to this point; I incorrectly believed that the nested directive are applied only when the item had items in the items array. Instead, the directive is prepared for bindings during the compilation phase which means that any nested directives are processed as well. In this case, we end up in an infinite loop because each level includes a nested instance of the same directive.

The trick to working around this issue is to manually inject the nested directive as necessary during the linking phase and use the $compile service to force the inserted directive to compile. This requires a slightly more sophisticated directive definition but it’s still quite simple.

We begin by replacing the binding HTML in the view. Rather than using a div as in the previous attempt, we’ll use the structure that will serve as the inner template in the revised directive. That HTML is as follows:

<ul>
    <li ng-repeat="item in items" hierarchy-viewer="item"></li>
</ul>

Notice here how the li element includes both the ngRepeat and hierarchyViewer directives. This will create a new instance of the hierarchyViewer directive for each item at the items collection root. Setting the hierarchy-viewer value to item won’t impact the directive’s behavior yet but it will be important in a little while.

With that change in place we can remove the unordered list from the template leaving us with only the p element as follows:

var HierarchyViewer = function () {
    this.restrict = "A";
    this.template = "<p>{{item.name}}</p>";
};

Running the page now will result in each root level item being displayed rather than failing with a stack overflow. This is a great step in the right direction but we clearly still have some work to do to display the entire structure.

Injecting the nested directives requires us to do a few things:

  1. Provide the directive with the $compile service
  2. Define an isolated scope
  3. Define a link function to conditionally inject the nested directive
  4. Invoke the compiler service on the injected elements to process them

Injecting the $compile service is a matter of updating the directive registration thus instructing AngularJS to inject the appropriate instance. In addition to passing in the $compile, I like to set it as a property on the directive object. I find that this approach makes the code a bit more readable as we can isolate various bits of functionality without relying too heavily on parameters or closures. Here’s the revised code with the $compile service injected:

+function (ng) {
    var HierarchyViewer = (function () {
        var HierarchyViewer = function ($compile) {
            this.$compile = $compile;
            this.restrict = "A";
            this.template = "<p>{{item.name}}</p>";
        };

        return HierarchyViewer;
    })();

    HierarchyViewer.create = function ($compile) { return new HierarchyViewer($compile); };

    ng.
        module("mainApp").
        directive("hierarchyViewer", HierarchyViewer.create);
}(angular);

Next up is defining the isolated scope. Without the isolated scope, we’d end up in an even nastier problem than the stack overflow, an infinite loop caused by trying to nest additional directives using the root level of the array.

The isolated scope for this demonstration includes only a single property, item. To keep things simple, we’ll set its source as the directive attribute itself as shown:

var HierarchyViewer = function ($compile) {
    this.$compile = $compile;
    this.restrict = "A";
    this.template = "<p>{{item.name}}</p>";
    this.scope = { "item": "=hierarchyViewer" };
};

We’re finally ready to define the link function. One of the mistakes I made when implementing the link function the first time was to try making it a prototype member of the HierarchyViewer type like this:

var HierarchyViewer = function ($compile) {
    // Implementation omitted
};

HierarchyViewer.prototype.link = function (scope, element) {
    // Implementation omitted
};

The problem with this approach is that when AngularJS invokes the link function, it doesn’t invoke it directly but rather through an apply call with null passed as the context. The result is that, within the execution context, this doesn’t represent the directive, it represents the window. This means we lose the reference to the directive and by extension, the $compile service. This prevents us from processing the injected directives.

There are two primary ways to work around this issue. The first approach is to define the function inline within the constructor function. This approach makes the constructor function too cluttered for my tastes so I prefer to define the function outside of the constructor function and set it as a property of the object with angular.bind. Here’s the link function’s full implementation:

var innerTemplate =
    "<ul>\
        <li ng-repeat=\"child in item.items\"\
            hierarchy-viewer=\"child\"></li>\
    </ul>";

var link = function (scope, element) {
    var item = scope.item || {};

    if (item.items && angular.isArray(item.items) && item.items.length > 0) {
        element.append(innerTemplate);
        this.$compile(element.contents()[1])(scope);
    }
};

As you can see, there’s not much to the link function. In short, it’s checking whether the current scope’s item has any children and, if so, injects the inner template into the host element before invoking the $compile service. Pay special attention to line 12 where we invoke the $compile service. Rather than passing all of the host element’s content after appending the element, we grab only the final element from the returned array. We do this to ensure that only the newly added content is compiled and not recompile the portions that were already processed.

We can finally hook up the link function as a directive property by adding the following to the constructor function:

this.link = angular.bind(this, link);

This is all we need to create a simple recursive directive so loading the page should now present all of the items contained within the original array. For your convenience, here’s the entire code listing for the directive created in this article:

+function (ng) {
    var HierarchyViewer = (function () {
        var innerTemplate =
            "<ul>\
                <li ng-repeat=\"child in item.items\"\
                    hierarchy-viewer=\"child\"></li>\
            </ul>";

        var link = function (scope, element) {
            var item = scope.item || {};

            if (item.items && angular.isArray(item.items) && item.items.length > 0) {
                element.append(innerTemplate);
                this.$compile(element.contents()[1])(scope);
            }
        };

        var HierarchyViewer = function ($compile) {
            this.$compile = $compile;
            this.restrict = "A";
            this.template = "<p>{{item.name}}</p>";
            this.scope = { "item": "=hierarchyViewer" };
            this.link = angular.bind(this, link);
        };

        return HierarchyViewer;
    })();

    HierarchyViewer.create = function ($compile) { return new HierarchyViewer($compile); };

    ng.
        module("mainApp").
        directive("hierarchyViewer", HierarchyViewer.create);
}(angular);

I’ve found that this approach has worked well for my needs but if you know of a more effective way to accomplish this I’d love to hear about it.

Advertisements