Best practice for triggering jQuery/DOM transforms on ng-repeat'ed element

149 views
Skip to first unread message

Andrew Hughes

unread,
Aug 4, 2015, 1:28:03 PM8/4/15
to AngularJS
Hello all,

I'm new to Angular and would like to know if there's a best-practice for solving the following. I have it working as it is, but I'd like to know if there was a better way. I've included the relevant code below. 

project.html iterates over "items" as "item" objects, creating a series of "my-item-editor" directives. These directives include the item-editor.html template, which uses Quill to create a series of editors for the items. Creating the initial HTML is a snap. The challenge was figuring out when/how to run the Quill constructor. 

Initially I thought I would be able to do it in the directive's link callback. Unfortunately, I found that at this point expressions in the templates had not been resolved, so that the title element as still "<h2>{{::title}}</h2>" and not the actual title.

The solution I found online was to use $timeout. This works; however, it feels a bit like a hack. Surely there's a better, more direct way to trigger some code to execute once the expressions have been resolved to their actual values.

Also, if anybody has any other general, broader suggestions about how to go about achieving this, I'd appreciate the advice!

Thanks - Andrew



project.js

angular.module('clientApp')
.controller('ProjectCtrl', function ($scope) {
this.items = [
{
"_id": "1",
"title": "The first scene",
"text": "And then something awesome happened..."
},
{
"_id": "2",
"title": "The second scene",
"text": "Oh shit..." }
];

})
.directive('myItemEditor', ['$timeout', function ($timeout) {

function link(scope, element, attrs) {

scope.id = scope.item._id;
scope.text = scope.item.text;
scope.title = scope.item.title;

var editorId = '#item-editor-'+scope.item._id;
var toolbarId = '#item-toolbar-'+scope.item._id;

$timeout(function() {

var editorElement = $(editorId)[0];
var toolbarElement = $(toolbarId)[0];

createEditors(editorElement, toolbarElement);

},0);

}

return {
restrict: 'E',
templateUrl: 'views/item-editor.html',
link:link
};

}]);



project_custom.js

function createEditors(editorElem, toolbarElem) {
var quill = new Quill(editorElem);
quill.addModule('toolbar', { container: toolbarElem });
}


project.html

<div ng-repeat="item in project.items">
<my-item-editor></my-item-editor>
</div>


item-editor.html

<h2>{{::title}}</h2>

<!-- Create the toolbar container -->
<div class="quill-item-toolbar" ng-attr-id="item-toolbar-{{::id}}">
<button class="ql-bold">Bold</button>
<button class="ql-italic">Italic</button>
</div>

<!-- Create the editor container -->
<div class="quill-item-text" ng-attr-id="item-editor-{{::id}}" >
<div>{{::text}}</div>
</div>


Kevin Shay

unread,
Aug 4, 2015, 1:50:03 PM8/4/15
to ang...@googlegroups.com
Hi Andrew,

It sounds like what you're looking for is scope.$watch(): https://docs.angularjs.org/api/ng/type/$rootScope.Scope#$watch

Kevin

--
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/d/optout.

Sander Elias

unread,
Aug 4, 2015, 10:03:06 PM8/4/15
to AngularJS

Hi Kevin,

The way you put it up is Ok. The $timeout is not beautiful, but gets the job done. You need it, because, as you had noticed, angular works kind of async. As an alternative, you can take out the {{::text}} out of your template, and use the Quill.setContents in your link function to load the text into your editor.

Regards
Sander

Andrew Hughes

unread,
Aug 4, 2015, 10:39:52 PM8/4/15
to AngularJS
Thanks for the replies. 

Am I correct in assuming that the reason $timeout works is because it puts the closure at the end of the event loop queue so that all of the angular binding functions have fired before the $timeout function is executed?

Thanks,

Andrew

Sander Elias

unread,
Aug 5, 2015, 3:42:55 AM8/5/15
to AngularJS
Hi Andrew,
Am I correct in assuming that the reason $timeout works is because it puts the closure at the end of the event loop queue so that all of the angular binding functions have fired before the $timeout function is executed?
That is indeed the case. It makes sure there is at least 1 $digest loop finished. 

Regards
Sander

Kevin Shay

unread,
Aug 5, 2015, 10:32:22 AM8/5/15
to ang...@googlegroups.com
If $timeout(..., 0) works in your case, then I agree that it's OK to use, but I still think $watch() is the more idiomatic way of doing this. Presumably in your finished application the data won't be hardcoded into the controller; if it's coming from an asynchronous HTTP request, then you'll have to set the timeout delay to some hopefully-long-enough-but-not-so-long-it-slows-down-the-UI number of milliseconds. With $watch(), Angular will take care of notifying your code when the data is loaded and the variables are populated.

Basically, I think the rule of thumb is that using $timeout with a 0 delay to wait for the digest cycle to finish is fine, whereas using it with a fudge-factor value to wait for something asynchronous should be considered a hack, albeit one that's sometimes necessary when dealing with libraries external to Angular.

Kevin

--

Stewart Mckinney

unread,
Aug 5, 2015, 11:23:21 AM8/5/15
to ang...@googlegroups.com
The reason why this works is because ng-repeat uses $watchCollection internally to render its elements - this happens after your ( pre )link function. ( It effectively inserts a $compile step after linking ).

So if you wait for a $digest, you ensure that $watchCollection fires, which means you get your DOM.

There are some other ways to get around this. In this past I have done really simple things like using $last with ng-repeat to render a conditional element that simply fires an event on ng-init ( not the cleanest but the fastest if you don't want a custom directive ) or use a custom directive to fire an event in it's link function. You can also just access the parent controller via require and fire some init function in link, or just be very straightforward and move the third-party initialization into the custom directive's link function ( typically requiring it wrapped in an angular service ), avoiding the need for events, timeout, or require.


I don't think that using $watch is a great idea. In general I try to stay away from using it directly - template bindings will often give me all I need. Using a feature which is meant to be used as an observer function in conjunction with model changes once for initialization purposes is asking for trouble in my opinion. I would worry, for instance, about that $watch firing twice and a third party JS library not being idempotent.


There was some talk about having an "afterDigestLoop" event for Angular ( I forget the name of it , but there is a github issue open ), that would fire after $digest completely finishes that would be useful in a case like this.


Andrew Hughes

unread,
Aug 5, 2015, 11:59:09 AM8/5/15
to ang...@googlegroups.com
Stewart. Thanks for the detailed response. One thing, though. 

"...or just be very straightforward and move the third-party initialization into the custom directive's link function.." -- I believe this is exactly what I tried and it didn't work because the bound expressions hadn't been resolved (or whatever the term is) yet.

Yeah, I'm surprised there isn't an "afterDigestLoop" event. Seems like something that would come up a lot.

Thanks,

Andrew



You received this message because you are subscribed to a topic in the Google Groups "AngularJS" group.
To unsubscribe from this topic, visit https://groups.google.com/d/topic/angular/x_BTRTKa2fo/unsubscribe.
To unsubscribe from this group and all its topics, send an email to angular+u...@googlegroups.com.

Kevin Shay

unread,
Aug 5, 2015, 12:33:34 PM8/5/15
to ang...@googlegroups.com
Agreed that template bindings are preferable to directly watching where possible, but that's not always the case. For one-time initialization you can (and should) defend against the watcher getting called again by removing it:

var unwatch = scope.$watch('key', function (val) {
    if (!val) {
        return;
    }
    unwatch();
    ...
});

Sander Elias

unread,
Aug 5, 2015, 1:36:13 PM8/5/15
to AngularJS
Hi Andrew,

Yeah, I'm surprised there isn't an "afterDigestLoop" event. Seems like something that would come up a lot.
O there is. I keep forgetting its name (and existence, it is something that almost never comes up in my programs) , so I looked it up ;) Its documented as part of $rootscope.Scope. Look up $evalAsync in there.
But the $timeout is working just fine. 

The reason it never comes up in my programs is that in cases like this, I use ngModel to write and read to the custom elements. I hook those up with small helper directives.
that would look something like this:
<div class="quill-item-text" hook-up-quill ng-model='text'>
</div>

in the `hook-up-quill` directive you can use elmement[0] to attach the editor and ngModelController to bind to the text var in the scope.

Hope this helps you a bit,
Regards
Sander





Stewart Mckinney

unread,
Aug 5, 2015, 2:56:09 PM8/5/15
to ang...@googlegroups.com
On Wed, Aug 5, 2015 at 11:58 AM, Andrew Hughes <andrewcar...@gmail.com> wrote:
Stewart. Thanks for the detailed response. One thing, though. 

"...or just be very straightforward and move the third-party initialization into the custom directive's link function.." -- I believe this is exactly what I tried and it didn't work because the bound expressions hadn't been resolved (or whatever the term is) yet.

Yeah, I'm surprised there isn't an "afterDigestLoop" event. Seems like something that would come up a lot.

Thanks,

Andrew


I meant in the ng-repeat itself. Something like this:


Keep in mind too that the DOM will be changing every time the collection changes, so you will have to destroy/recreate stuff every time that happens.

Stewart Mckinney

unread,
Aug 5, 2015, 2:56:56 PM8/5/15
to ang...@googlegroups.com
Ya, you can do it that way, I just like to keep $watch for observer functions. You can do something similar for one-time init events using $on.

Sander Elias

unread,
Aug 5, 2015, 10:07:30 PM8/5/15
to AngularJS
Hi Steward,


Keep in mind too that the DOM will be changing every time the collection changes, so you will have to destroy/recreate stuff every time that happens.

your `coolCustomDirectiveThatFiresAfterNgRepeatIsDone` will work, but why would you like to do something like that? I know the DOM changes, but the underlying data is readily available in your models. The only reason I see for a thing like that, is that you have to do DOM manipulation on the created result. That might be needed, but wouldn't it be a better solution to fix whatever there is inside the repeat, so this isn't needed at all? Now you are adding ate least the row-count of extra watchers to your view.
To take your example, if for instance you want to sum the oranges, just use a function to sum the data.
I updated your plunk a bit to showcase this.

Can you show me an actual use of a  `coolCustomDirectiveThatFiresAfterNgRepeatIsDone` construct that isn't patching up misbehavior of repeated elements or doing calculations on data that already exists in your controller? 

Regards
Sander


Sander Elias

unread,
Aug 5, 2015, 10:20:00 PM8/5/15
to AngularJS
Hi Kevin,

The OP's question was not on watching data, it was on initializing a 3rth party non-angular component that needs the data to be readily available in the DOM. No $watch is going to help with that.
It is a best practice to limit the number of $watches to an absolute minimum, and your $watch-once construct might help there. For myself, I consider them possible code-smell's. In 80% of the cases there is a faster/better alternative possible.

Regards
Sander

Stewart Mckinney

unread,
Aug 6, 2015, 10:28:51 AM8/6/15
to ang...@googlegroups.com
I provided the directive as an example of a way to notify the controller ( which is using a 3rd party library ) that the ng-repeat has completed rendering the elements of it's collection. I don't know why OP wanted that or what exactly it is doing, but that's why I posted it.

It can be useful if the 3rd party JS is written in jQuery and thus has no access to your scope or data. I think he is using a 3rd party lib that fits that exact description. Otherwise I would agree with what you say, generally speaking you could address whatever you needed
via directives/templating and good controller design.

 Now you are adding ate least the row-count of extra watchers to your view.

That's a good point, but this was a quick example that I made in 5 minutes. You could also examine $scope.$last in $link, which would not add watchers and complicate your digest cycle unnecessarily.


Reply all
Reply to author
Forward
0 new messages