help w/ directive that wraps arbitrary content which includes ng-repeat

1,565 views
Skip to first unread message

Nikita Tovstoles

unread,
Dec 11, 2013, 2:47:26 PM12/11/13
to ang...@googlegroups.com
Hi, 

I am trying to build a directive that:
  1. wraps arbitrary content
  2. prepends anchor tags to all 'h4' elements found in that content
Here's a plunker of what I have so far.

Seems to work fine if wrapped content is static but:
  • entire wrapped content is repeated twice
    • is my invocation of transcludeFn effectively repeating work that ng-trasclude is doing? Do I need to replace ng-transclude with own impl?
  • h4 tags generated by nested ng-repeat are not found by the directive
    • looks like 'clone' passed to cloneLinkFn in transcludeFn is pre-ng-repeat execution. thought that $compile(clone)(scope) would then produce post-ng-repeat version but that did not work either it seems.
I'd really appreciate a tip or explanation of what I am doing wrong.

thank you,
-nikita

Daniel Tabuenca

unread,
Dec 11, 2013, 8:01:05 PM12/11/13
to ang...@googlegroups.com

The problem is as follows:

When directives compile and link, they often don’t immediately update their own dom, but rather they setup watches which will update every time the model values they depend on change (including the very first time). These watches are registered and will execute on the next $apply / $digest cycle.

This means that while the ng-repeat directive compiled and linked by the time your directive tries to manipulate the dom, the dom does not include the repeated elements because those are executed as part of a watch on the underlying collection. Also, you will not be able to add your <h4>s to any new dom that comes in due to the underlying collection changing.

One solution you could do is to do away with the template, transclusion, and link function, and just create a compile function.

Through the compile function, you can modify the dom before directives are linked and potentially before they are even compiled (by adjusting priority). This lets you add your h4’s to the template itself, which is better because then the ng-repeat will use this new template to generate the html at each iteration. This is also good because then your <h4> will be there even as items are subsequently added/removed from the underlying collection.

Here is one way you could accomplish this:


app.directive('anchorize', function() {
  return {
    restrict: 'A',
    compile: function(element){
      var template = angular.element('<div><h4>must not have anchor</h4></div>');
      var contents = element.contents();

      contents.find('h4').prepend('<a href="#">anchor</a> ');     
      template.append(contents);

      element.html('');
      element.append(template);
    }
  };
});

Notice we are not using a link() function at all. For this type of directory it is unecessary to have a link function. The purpose of the link function is to “link” scope data to it’s visual representation in the dom. Since we aren’t dealing with any scope data we can just do everything we need to do in the compile function.

Notice also that I removed transclude: and template:. This gives us the flexibility to do everything ourselves within the compile() function.

A transclude function is essentially just a link function pre-bound to the correct scope. Since we are just going to manipulate the dom at the compile stage, there are no scope or link functions involved, so we can just manipulate the dom directly without calling a transclude function.

The reason for removing the template, is that if you include it, the contents of the dom will have already been replaced by the template html by the time we reach the compile function so we can’t see the old html that we care about. We can easily implement the same functionality ourselves within the compile function to get the exact behavior we want.

Hopefully this makes sense. Here is a working plunkr with this sample:

http://plnkr.co/edit/HHyIKrjAMkKmUHkZmfBj?p=preview

As far as the other question you had about ng-transclude and needing to replace ng-transclude with your own implementation, the answer is yes. All the ng-transclude directive does is call the transclude function for you. It’s implementation is very simple, all it essentially does is:


  $transclude(function(clone) {
      $element.html('');
      $element.append(clone);
    });

So if you decide to use the transclude function that’s passed into your link function, you are essentially chosing to have more fine-grained control. It gives you the dom and lets you do whatever you want with it rather then relying on the simplistic ng-transclude behavior.


Nikita Tovstoles

unread,
Dec 12, 2013, 3:14:38 PM12/12/13
to ang...@googlegroups.com
Thanks for a detailed reply, Daniel.

Your mods do work, though I am struggling to understand why. I guess I don't yet understand:
  • how often and when do directive's compile() and link() functions execute? during digest()? does link() (if available) execute immediately after compile()?
  • in your modified compile(), element.contents() contains output of nested ng-repeat directive - which means that directive already compiled? but output of ng-repeat depends on scope, and i thought compile() - for all directives in DOM - runs *before* scope is ascertained. or does ng-repeat's higher priority apply here (i thought priorities only matter for sorting directives on the same element).
I will certainly re-read the docs and keep looking at AngularJS src. any other resources (or examples) you'd recommend to review?

thank you,

-nikita

Daniel Tabuenca

unread,
Dec 12, 2013, 8:02:50 PM12/12/13
to ang...@googlegroups.com

Compile and link functions usually execute only once when you call compile and link. It will not really run ever again, so In order to update the dom, most directives will setup watches that actually fill in or update the dom as values in the scope change.

Take for example using ng-bind (keep in mind ng-bind is ultimately the directive that gets used whenever you do things like {{name}}:

app.controller('MainCtrl', function($scope) {
  $scope.name = 'World';
});
 <body ng-controller="MainCtrl">
        Hello <span ng-bind="name"></span>
  </body>

Now let’s take a look at how the ng-bind directive is implemented (simplified just a bit for this discussion):

var ngBindDirective = ngDirective(function(scope, element, attr) {
  element.addClass('ng-binding');
  scope.$watch(attr.ngBind, function ngBindWatchAction(value) {
    element.text(value == undefined ? '' : value);

  });
});

What you are seeing here is just the link function. Let’s go over what it does:

  1. First it adds a class of ng-binding to the element. This is added right away at the time that the compile/link is called.
  2. A watch is setup to watch the attribute that we passed in. Whenever this value changes (including the first time the watch is run), it sets the element’s text content to the new value. Keep in mind that this is just registering a watch, and that the watch will not execute until a digest cycle executes (usually as a result of a scope.$apply());

It is important to understand that after the link function is called the only difference in the dom is the additional class. So after the link function is complete it will look like:

<body ng-controller="MainCtrl" class="ng-scope hasGoogleVoiceExt">
  Hello <span ng-bind="name" class="ng-binding"></span>
</body>

Notice that it does not say “World” yet. This will only happen once a digest cycle runs, after which it will look like:

<body ng-controller="MainCtrl" class="ng-scope hasGoogleVoiceExt">
 Hello <span ng-bind="name" class="ng-binding">World</span>
</body>

When bootstrapping the angular app the normal way, angular will ensure that an initial $apply() is done after it compiles the page. When you are testing, you have to manually call $apply() in order to ensure that all the registered watches execute before you make any assertions on the dom.

Essentially, all that $digest() does is go over all registered watches and executes them in order, letting each do whatever it needs to do to the dom based on any new values in the scope. It does not re-compile or call your link function again.

Now let’s go over your question about changes to the dom in the compile function and what that means for the ng-repeat directory. So let’s go over the compile process and what it does.

Let’s lay out our example:

app.controller('MainCtrl', function($scope) {
  $scope.numbers = [1, 2, 3];
});

app.directive("addAligator", function(){
  return {
    compile: function(element){
      element.find('li').append(" Aligator");
    }
  }
})
<body ng-controller="MainCtrl">
  <div add-aligator>
    <ul>
      <li ng-repeat="number in numbers">{{number}}</li>
    </ul>
  </div>
</body>

So let’s look at what happens here. We start with the dom above above and angular calls $compile() on it.

Keep in mind, that the dom is already on the page, it is not a “template” like in other frameworks, but rather a living, breathing, piece of dom.

The compile process doesn’t “render” the template, it simply scans for directive, creates scopes as needed and execute’s the directives compile and link functions. These functions simply mutate and manipulate the dom however they want to.

So here are the steps:

  • Angular searches through the dom nodes from the top of the tree and looks for directives
  • When it finds a node with directives it orders them by priority and executes it’s compile funcitons in order
  • Each directive could modify the dom in it’s compile function if it wanted to.
  • Once all compile functions execute on the node, angular continues scanning for directive on the child nodes and the process repeats

So let’s go over this particular example. When angular encouters our <div add-aligator> node it notices there is an addAligator directive and calls our compile function:

 compile: function(element){
      element.find('li').append(" Aligator");
    }

Our compile function gets passed an html node as its first parameter. The element is the <div add-aligator> node our directive is on. We can use it to see the current state of the dom which at this point has not changed and looks like:

<div add-aligator>
  <ul>
    <li ng-repeat="number in numbers">{{number}}</li>
  </ul>
</div>

Notice that we can see the ng-repeat there, because it’s there as part of the dom, but it really hasn’t been compiled yet. Once our directive is finished compiling the dom will look like:

<div add-aligator>
  <ul>
    <li ng-repeat="number in numbers">{{number}} Aligator</li>
  </ul>
<div>

Angular will then proceed to compile the child nodes so it will go and compile the ng-repeat. When ng-repeat compiles it will see the current state of the DOM which includes the word “Aligator” appended to the <li>. It uses this as its template (it has no idea whether “Aligator” was in the original template or if it was added by a previous directive it only sees the current state of the dom at the time it’s compile function is called.

Because the ng-repeat directive is transclude: 'element' it means that angular will automatically remove the element from the dom (saving it’s dom and contents inside of what’s called a transclude funciton). It will replace it with a comment. Thus, after the transclude directive is done compiling the dom will look like:

<body ng-controller="MainCtrl">
  <div add-aligator>
    <ul>
        <!-- ngRepeat: number in numbers -->

    </ul>
  <div>
</body>

Notice that there are no longer any <li> elements anymore. The “template” for what was there is now saved inside of a transclude() function.

What happens once angular has finished compiling all the directives it will start linking them. This usually happens in reverse order, so the link function for the ng-repeat will execute first.

What does the link function for ng-repeat do? First of all it is passed the transclude function, which can stamp out new <li> from the template that was saved when it compiled. But it doesn’t do this right away. Rather it sets up $scope.$watch() statements based on the expression that was passed in, in order to watch the entire collection. When these watches trigger they will use the transclude() function to add or remove the appropriate <li> elements.

The key is that these watches don’t run immediately. So even after link function is executed the dom still looks like:

<body ng-controller="MainCtrl">
  <div add-aligator>
    <ul>
        <!-- ngRepeat: number in numbers -->

    </ul>
  </div>
</body>

Once angular has finished compiling and linking the entire application, it will run a digest cycle. At this point the watches registered by ng-repeat will finally trigger, adding the <li> based on the value observed from $scope.numbers. So our html will look like:


<body ng-controller="MainCtrl" class="ng-scope hasGoogleVoiceExt">
  <div add-aligator="">
      <ul>
      <!-- ngRepeat: number in numbers --><li ng-repeat="number in numbers" class="ng-scope ng-binding">1 Aligator</li>
      <!-- end ngRepeat: number in numbers -->
      <li ng-repeat="number in numbers" class="ng-scope ng-binding">2 Aligator</li>
      <!-- end ngRepeat: number in numbers -->
      <li ng-repeat="number in numbers" class="ng-scope ng-binding">3 Aligator</li>
      <!-- end ngRepeat: number in numbers -->
    </ul>
  </div>
</body>

Ignore the comment nodes as they will just confuse the current discussion. As you can see, each <li> has the word Aligator because this was in the template at the time the ng-repeat was compiled. So from now on whenever ng-repeat needs to create an additional <li> it will stamp it out with this modified template.

Our addAligator did not need a link function because it did not deal with anything in the scope. It’s sole purpose was to just modify the template before it was compiled. This is necessary, for your directive, and if understood properly it is an appropriate use.

That being said, 99% of directives probably will never need to use the compile function. Remember, the compile function is good for modifying the dom before subsequent directives compile and link, but this is a rare need (I don’t think any of the built-in directives actually use this and the compile function simply immediately returns a link function).

Keep in mind that directives which use transclude: true or transclude: element automatically modify the dom for you (removing the contents of the element in the former case, or removing the element itself int he latter). You can get access to the removed dom from within the link function through the passed in transclude parameter. This is also true for directives that include their own template: (although you would only be able to access the preivious dom if you also use transclude).

I know this has gotten a bit long, but hopefully this helps clear some things up. Let me know if you have any questions.

Here is the link to the aligator example:

http://plnkr.co/edit/VTbv3n5hHi0d8LNNxmnP?p=preview



Sander Elias

unread,
Dec 13, 2013, 2:23:43 AM12/13/13
to ang...@googlegroups.com
Hi Daniel,

What an excellent write-up. This should be added to the official documentation, as part of the directive documentation!
kudos!

Regards
Sander

Mike Alsup

unread,
Dec 16, 2013, 7:10:04 PM12/16/13
to ang...@googlegroups.com
Agreed.  That was an awesome response.  Thanks, Daniel!


--
You received this message because you are subscribed to the Google Groups "AngularJS" group.
To unsubscribe from this group and stop receiving emails from it, send an email to angular+u...@googlegroups.com.
To post to this group, send email to ang...@googlegroups.com.
Visit this group at http://groups.google.com/group/angular.
For more options, visit https://groups.google.com/groups/opt_out.

Reply all
Reply to author
Forward
0 new messages