Saturday, 17 November 2012

Transclude in AngularJS

Transclude - That's not a word you'll find in a dictionary :). Once you dive into Angular, creating custom directives is a daily chore and having good understanding of transclusion becomes imperative. To explain it in one sentence, transclusion consists of plucking out the content of a custom directive, processing it against right scope and then placing it at a marked position in the template of that directive. While the basic transclude using the built-in ngTransclude directive is easy enough, there isn't much clarity in some areas in terms of documentation or examples. These areas are -

  • Purpose and usage of transclude argument that a directive compile function receives.
  • Purpose and usage of $transclude injectable in a Controller.
  • Isolate scope and transclude.
  • Transcluding into an attribute.

I'll try to throw some light on these with the help of working fiddles.

Basic transclude

Let's start with the most basic transclude example. Let's say, we are creating a buttonBar directive that a user can use to add a group of buttons to a page. The directive takes care of alignment of the buttons. Just to note, I have used Twitter Bootstrap as CSS framework.

Here is the fiddle for it.


Take a look at the markup. The buttonBar directive wraps a couple of button elements. The button elements also have a class-based directive "primary" attached to them. Don't worry about it though, it simply attaches appropriate Twitter Bootstrap classes to them. The directive provides transclude: true attribute and it's template uses ng-transclude directive on a contained div. At run-time, Angular gets the content of the custom directive, processes it and sticks the resultant markup in the ng-transclude div in the template. And that's all there to it. Quite simple, isn't it?

Transclude at multiple locations

Lets say we would like to enhance our buttonBar directive to have two kinds of buttons - primary and secondary; with primary buttons right-aligned and secondary left-aligned. It would entail picking up the contents(buttons in our example) of the directive and adding them into two separate divs, one for primary buttons and the other for secondary buttons. Transclude into two different locations, if you will. The result should look something like this -

Well, that's not possible with the default mechanism. One approach to achieve this could be -

  1. Allowing the default transclude in an element in the template.
  2. Programmatically moving child elements(buttons) to appropriate div.
  3. Finally, removing the original transcluded element from DOM in compile or link function of the directive.

Here is a fiddle that demonstrates this approach.


While this approach gives us the intended result, allowing default transclude to happen in an element and then removing it from DOM manually is not really efficient. That's where the transclude argument to compile function and $transclude injectable into Controller come into picture.

Transclude argument to compile function in a directive

The Angular developer guide for directive gives following signature for the compile function of a directive -

function compile(tElement, tAttrs, transclude) { ... }

And here is what it says about the third argument -

transclude - A transclude linking function: function(scope, cloneLinkingFn).

Alright, let's use this function to achieve what we achieved in the last example without the need to transclude into an element and then removing it from DOM.

Here is the fiddle for it.


Please note there is no scope available in compile function and you'll have to pass the element that compile function received as the first argument to the transclude function. You might want to use this approach if you are already using the compile function (which is quite rare, at least for me) and don't need to work with scope. Otherwise, the next approach of injecting $transclude into Controller is the best bet.

Injecting $transclude in a Controller

The Angular developer guide for directive states the following for $transclude injectable for Controller -

$transclude - A transclude linking function pre-bound to the correct transclusion scope: function(cloneLinkingFn).

And here is how we can use it for our buttonBar directive.



Transclude and scope

The Angular developer guide for directive mentions that a directive isolated scope and transclude scope are siblings. Now, what does that mean? If you take a careful look at previous example, you'll notice that parentController creates a scope, the buttonBar directive declares an isolated scope under it and as mentioned in the Angular documentation, transclude creates yet another scope. I have added log statements at appropriate places in the directive to illustrate the relationship between these three scopes. Here is how the output looks like -


You can clearly see the directive isolate scope and transclude scopes are siblings.

Transcluding into an attribute

Well, you can't really do that but achieve something to that effect using the following trick mentioned on stackoverflow.

That covers all I wanted to regarding transclude in AngularJS. Hope this post proves useful to you. Happy ng-ing :).

Update (20th Feb 2013) - scope and transclude argument to compile function

The lack of scope in compile function and the signature of transclude argument to compile function that mentions scope as its first argument leaves a lot of "scope" for confusion :). One thing is certain, there is no scope in compile function. Here it is straight from horse's mouth - https://groups.google.com/d/msg/angular/QbZt4kw5cTY/h-ZIgPO0wrsJ.

So what's the first argument actually? It turns out it's just the jQuery-wrapped root element of the template for the buttonBar directive. This is how it looks like during debugging -

.

Note the absence of scope related behavior such as $$watchers or $$listeners on it.

Here is updated version of third fiddle from top with development version of AngularJS instead of minified version. You can put a debug point as shown in the snapshot above and see for yourself. Hope this clarifies it further.

Update (16th March 2013) - All fiddles updated to latest AngularJS release - 1.1.3

In contrast to what I have mentioned in the earlier update and behavior in earlier releases, the transcludeFn passed to compile function now indeed expects scope as first argument in latest AngularJS release (1.0.5/1.1.3). Therefore the call to this function needs to be made from link function that is returned from compile function as it has access to scope. The fiddle for section Transclude argument to compile function in a directive as been modified accordingly.

34 comments:

  1. This is one of the harder concepts to wrap my head around in AngularJS (still is) but your article definitely helped in explaining it a lot better than whatever I've read so far.

    Look forward to reading more posts related to AngularJS!

    ReplyDelete
    Replies
    1. Thanks Ali. I'm glad it was of some help to you.

      Delete
  2. Hi omka, I had your article in my pocket list since a month but just now I've read it and was really...really helpfull...thanks so much...now I've a little doubt about the compile function...

    the compile function receive 3 arguments, the first is tElement..now..this tElement is the element in your template (div class="span4...)??...not the elements in the dom no?...

    my second question is about the linking function...in the documentation shows than the first arg is the scope ..but you pass as first argument the element again and this confuses me a bit...you could explain me a bit more about this piece of code??...

    thanks Omkar and have a good day!

    ReplyDelete
    Replies
    1. I'm glad to hear you found it helpful :).
      Regarding your questions -
      1. Yes, that's right. That's the template element.
      2. Very keen observation. Actually, Angular documentation is a bit misleading as far as transcludeFn is concerned. The transcludeFn that is passed to compile function (my 3rd fiddle) and $transclude injection happening in the controller (my 4th fiddle) are two different things. The previous has the signature with element as first argument and clone function as second argument while the later has signature with scope as first optional argument and clone function as second argument. This has been explained on the following thread on StackOverFlow - http://stackoverflow.com/questions/13183005/what-exactly-do-you-do-with-the-transclude-function-and-the-clone-linking-functi

      Delete
    2. thanks so much Omkar...that stackoverflow was very insightful..thanks so much and I hope future articles about angularjs :D ...thanks and have a good day...

      Delete
  3. I've been struggling to understand transclude for the past couple of days. You article is the light at the end of the tunnel. Thank you for sharing your knowledge of the subject. Looking forward to read more of your articles.

    ReplyDelete
    Replies
    1. Happy to hear it helped you. My team is developing a huge application based on Angular. Whenever I hit the wall during development, I just dug around a bit and merely sharing my observations on this blog :).

      Delete
  4. In directive, what to use controller for and what to use link function for?
    It seems that what I can do in a directive controller, I can do in the link function, and vice versa.
    Please give some lighting on this point,

    ReplyDelete
    Replies
    1. It's more of a separation of concerns. Directive controllers are meant to be used to set initial state of your model i.e. scope and provide behaviour, for example, what happens when a button in your view is clicked. But it's not a place to have DOM manipulation code which is required quite often in the directives. link function is the best place to have it.

      Delete
    2. To me, it doesn't make sense that the right place to make DOM manipulation is in the link function. From what I've read DOM manipulation should be done in the compile function. The link function is for binding the scope model data to the DOM ie setting up watches and so on.

      Fedirs solution below seems more correct to me.

      Delete
    3. DOM manipulation can safely be done in the compile function or the post link function (the `link` attribute on a directive, the return function of the `compile` attribute or the `post` attribute returned by the compile function). It can also be done in the transclude function during the post link phase. compile will not have scope and post link will have scope.

      Non-scope specific DOM manipulation should be in the compile function and scope specific (event handling or transclusion - which deals with the actual compiled clone).

      Delete
    4. Thanks for clearing that out!

      Delete
  5. Thank you for sharing you have learned, Omkar. This article is a great complementary material to the Angularjs official documentation on the topic of Transclude. It has answered most of my confusion/questions about the topic.

    There's one part that I wish you could elaborate on, though. You mentioned there's no scope available in compile function and we should pass the element or the first argument of compile function to the transclude function. But according to the signature of the transclude function, function(scope, cloneLinkingFn), it expects scope as its first argument. This is confusing me a bit.

    ReplyDelete
    Replies
    1. I'm glad to know it was helpful to you.

      I have added an update section to the bottom of the post with additional information regarding transclude argument to the compile functon. Hope it clarifies your doubts.

      Delete
  6. Hi, how do I attach directive during 'transclusion'? E.g.

    controller: function($attrs, $scope, $element, $transclude) {
    return $transclude(function(clone) {
    var input = clone.filter(':input');
    input.attr('ng-model', 'foo');
    return $element.append(clone);
    });
    }

    Will result in --input ng-model='bar'-- but bar is not created in the scope. Anyway great blog, your content should be put in the official AngularJS website!

    ReplyDelete
    Replies
    1. For dynamically adding directives through another directive, you'll have to inject $compile service into that directive and compile the content. This thread on AngularJS mailing list might be helpful to you - Dynamically adding a directive from within a custom directive

      I'm glad to know you found the blog useful. Regarding putting this content in official AngularJS documentation, I won't mind if they do :).

      Delete
  7. Omkar, your example code for "Injecting $transclude in a Controller" breaks with the latest AngularJS (1.0.5/1.1.3). I switched back to 1.1.0 and the code just ran fine.

    I stepped through the code and found that the content of the "clone" argument differs in the latest AngularJS. More specifically, clone.scope gave me "undefined".

    Please take a look and perhaps add an update to your helpful article. Thanks.

    ReplyDelete
    Replies
    1. Thanks for letting me know and the helpful debugging pointer. I have updated all fiddles and tweaked them to work with latest AngularJS(1.1.3) release.

      Delete
  8. This article I was waiting for. My initial idea was to take a look in the angular source on the ng-repeat. But this docs seems to make my day! The official docs realy sucs...

    ReplyDelete
  9. very helpfull, thanks

    ReplyDelete
    Replies
    1. I'm glad it was useful to you.

      Delete
  10. Thanks for this! very Helpful! You should add this post to the Angular docs...


    ReplyDelete
    Replies
    1. I'm glad you found it helpful :)

      Delete
  11. Very helpful article, thanks. It clears up many things about transclusion for me.
    I sped some time playing with transclude and I've found very interesting thing - you can pass promise instead of scope in the compile function. IMO using transcludeFn in compile function is more efficient than in controller because it takes place 1 time per directive founded in a view(only one 1 time before first render of directive instance, of course I meant using transcludeFn not in link function but before), in case of controllers it is executed before each directive instance initialization(I mean the same directive, re-initialization can take place when using tabs/switch or any routing with view change).
    Code looks like:


    testapp.directive('buttonBar', ['$q', function($q) {
    return {
    restrict: 'EA',
    template: '<div class="span4 well clearfix"><div class="primary-block pull-right"></div><div class="secondary-block"></div></div>',
    replace: true,
    transclude: true,
    compile: function(elem, attrs, transcludeFn) {
    var scopeResolver = $q.defer();
    // using transcludeFn with promise before link
    transcludeFn(scopeResolver.promise, function(clone) {
    var primaryBlock = elem.find('div.primary-block');
    var secondaryBlock = elem.find('div.secondary-block');
    var transcludedButtons = clone.filter(':button');
    angular.forEach(transcludedButtons, function(e) {
    if (angular.element(e).hasClass('primary')) {
    primaryBlock.append(e);
    } else if (angular.element(e).hasClass('secondary')) {
    secondaryBlock.append(e);
    }
    });
    });
    // returning link function
    return function (scope, element, attrs) {
    scopeResolver.resolve(scope);
    };
    }
    };
    }]);
    }

    ReplyDelete
  12. Thanks for the detailed explanation. Now all that's left is practice :)

    ReplyDelete
  13. Thanks man, great job
    some useful comments, too

    ReplyDelete
  14. This is some high quality writing - takes the edge off a very complex topic

    ReplyDelete
  15. You used "it's" incorrectly. I don't know if it's just me, but I find that incredibly distracting. Its proper usage is described here: http://www.its-not-its.info/

    ReplyDelete
    Replies
    1. Thanks for the link. I'm not a native English speaker so please excuse my lack of knowledge in certain areas. Your link will help me improve :).

      Delete
  16. Thanks a million! That really helped.
    What would have made it much easier for me to understand:
    The $transclude(scope, cloneLinkingFn) function is used to add a copy of the transcluded DOM to the document.
    When it is called, angular creates clones of the transcluded elements, assigns them to the given "scope" and calls the "clone LinkingFn" with the cloned elements.
    That "cloneLinkingFn" function should add the clone to the document at a place it can determine at runtime.

    What you could also mention is that using the $transclude passed into the link function is also an option (as done in ngRepeat).

    ReplyDelete
    Replies
    1. I'm glad you found it helpful and thanks for your insightful observations. I'm sure your observations would provide more clarity for other readers.

      Delete