Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at cf.Objective() 2011 (Minneapolis, MN) with: Karsten Pearce
Ben Nadel at cf.Objective() 2011 (Minneapolis, MN) with: Karsten Pearce@vexeddeveloper )

Creating A Bind-Once Structural Directive In Angular 7.1.4

By Ben Nadel on

Over the weekend, I posted my review of Vue.js Up And Running by Callum Macrae. This was my first look at Vue.js; but, coming from an Angular background, many of the concepts discussed in the book felt very familiar. In fact, Vue's "v-once" directive, for one-time binding, is something that I miss from the Angular.js days. As such, I thought it would be a fun experiment to build a "bind-once" structural directive for Angular 7.1.4.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

The idea behind the old "::value" one-time binding syntax in AngularJS was that a template expression would be evaluated until it resulted in a non-empty value; then, it wouldn't be evaluated again for the rest of the template's life-span. Using one-time binding was an optimization step that would reduce the number of watchers on a "heavy" page, thereby reducing the degree of processing that had to take place during any given change-detection digest.

In the latest Angular, such a concept is probably unnecessary. Angular already has a very efficient change-detection algorithm. And, in conjunction with OnPush change-detection strategies, intelligent input-bindings, and pure pipes, something like "bind-once" is more a fun idea than it is a necessity. That said, there's always room for some optimization.

In particular, the use of the ngFor directive springs to mind. Since ngFor needs to detect arbitrary changes in a collection, it doesn't use an OnPush change-detection strategy. Instead, it uses a "Differ" to scan the collection on each change-detection digest. As such, it could be a potential optimization to wrap an ngFor loop in a "bind-once" directive.

To start experimenting with one-time bindings, let's create an Angular Directive that will log the execution of a change-detection digest. When a Directive is attached to the change-detection tree, Angular will attempt to invoke the Directive's ngDoCheck() life-cycle method, if it exists, during each relevant digest. Therefore, in order to hook into this life-cycle event, we can create an attribute directive that simply logs from within the ngDoCheck() method:

  • // Import the core angular services.
  • import { Directive } from "@angular/core";
  • import { DoCheck } from "@angular/core";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • @Directive({
  • selector: "[logDoCheck]",
  • inputs:[ "logDoCheck" ]
  • })
  • export class LogDoCheckDirective implements DoCheck {
  •  
  • public logDoCheck!: string;
  •  
  • // I get called whenever a change-detection digest has been triggered in the
  • // current view context.
  • public ngDoCheck() : void {
  •  
  • console.warn( "[", this.logDoCheck, "]: ngDoCheck() invoked." );
  •  
  • }
  •  
  • }

With the [logDoCheck] attribute directive, we can see which parts of our component tree are wired into the change-detection tree.

Next, let's look at what a [bindOnce] structural directive could look like. In the following code, we have a directive that injects a template into the view container and then immediately detaches itself from the change-detection tree. Once detached, it then uses the .detectChanges() method on the embedded view to explicitly step back into the change-detection life-cycle as needed:

  • // Import the core angular services.
  • import { Directive } from "@angular/core";
  • import { EmbeddedViewRef } from "@angular/core";
  • import { OnChanges } from "@angular/core";
  • import { SimpleChanges } from "@angular/core";
  • import { TemplateRef } from "@angular/core";
  • import { ViewContainerRef } from "@angular/core";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • @Directive({
  • selector: "[bindOnce]",
  • inputs:[ "bindOnce" ]
  • })
  • export class BindOnceDirective implements OnChanges {
  •  
  • public bindOnce!: any;
  •  
  • private embeddedViewRef: EmbeddedViewRef<void>;
  •  
  • // I initialize the bind-once directive.
  • constructor(
  • templateRef: TemplateRef<void>,
  • viewContainerRef: ViewContainerRef
  • ) {
  •  
  • this.embeddedViewRef = viewContainerRef.createEmbeddedView( templateRef );
  • // Since we want manual control over when the content of the view is checked,
  • // let's immediately detach the view. This removes it from the change-detection
  • // tree. Now, it will only be checked when we either re-attach it to the change-
  • // detection tree or we explicitly call .detectChanges() (see ngOnChanges()).
  • this.embeddedViewRef.detach();
  •  
  • }
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  • // I get called when any of the input bindings are updated.
  • public ngOnChanges( changes: SimpleChanges ) : void {
  •  
  • // NOTE: Since this Directive uses an ATTRIBUTE-BASED SELECTOR, we know that the
  • // ngOnChanges() life-cycle method will be called AT LEAST ONCE. As such, we can
  • // be confident that the embedded view will be marked for changes at least once.
  • // --
  • // We also want to check the view for changes any time the input-binding is
  • // changed. This gives the calling context a chance to drive changes based on a
  • // single expression even when change-detection is limited.
  • this.embeddedViewRef.detectChanges();
  •  
  • }
  •  
  • }

Since this structural directive uses an attribute selector, [bindOnce], we know that the ngOnChanges() life-cycle method will execute at least once for the initial value of the "bindOnce" input. As such, it makes sense to put the .detectChanges() call in the ngOnChanges() method. And, by doing so, it gives the calling context the ability to pass an expression into the "bindOnce" input that can change over time. Then, as the input-binding changes, the .detectChanges() method will be called again and the directive will briefly step back into the change-detection tree.

In other words, this [bindOnce] directive allows for the single-evaluation of of a DOM fragment:

<div *bindOnce> ... </div>

It allows for evaluation of a DOM fragment until a given value is non-empty:

<div *bindOnce="( !! value )"> ... </div>

And, it allows the ongoing evaluation a DOM fragment any time the reference to a given value changes:

<div *bindOnce="value"> ... </div>

Now, let's see how this can be applied in practice. As I mentioned before, one use-case that jumps to mind is the ngFor loop. So, let's see how an Angular application behaves when we have two ngFor loops: one "control case" and one inside a [bindOnce] directive. And, to make this more interesting, let's let the user apply changes to the collection behind these ngFor loops:

  • // Import the core angular services.
  • import { Component } from "@angular/core";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • @Component({
  • selector: "my-app",
  • host: {
  • "(document:click)": "handleClick()"
  • },
  • styleUrls: [ "./app.component.less" ],
  • template:
  • `
  • <form (submit)="addFriend()">
  • <input type="text" name="name" [(ngModel)]="name" placeholder="Name..." />
  • <input type="submit" value="Add Friend" /><br />
  •  
  • <label>
  • <input type="checkbox" name="useImmutable" [(ngModel)]="useImmutable" />
  • Use immutable <code>friends</code> data.
  • </label>
  • </form>
  •  
  •  
  • <h2>
  • {{ friends.length }} Friends Using Normal Binding:
  • </h2>
  •  
  • <!-- TEST ONE: Normal Change Detection. -->
  • <ul logDoCheck="List-A">
  • <li *ngFor="let friend of friends">
  • {{ friend }}
  • </li>
  • </ul>
  •  
  •  
  • <h2>
  • {{ friends.length }} Friends Using Bind-Once:
  • </h2>
  •  
  • <!-- TEST TWO: Bind-Once Change Detection. -->
  • <ul *bindOnce="friends" logDoCheck="List-B">
  • <li *ngFor="let friend of friends">
  • {{ friend }}
  • </li>
  • </ul>
  • `
  • })
  • export class AppComponent {
  •  
  • public friends: String[];
  • public name: string;
  • public useImmutable: boolean;
  •  
  • // I initialize the app component.
  • constructor() {
  •  
  • this.friends = [ "Kim", "Seema", "Tricia" ];
  • this.name = "";
  • this.useImmutable = false;
  •  
  • }
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  • // I add a new friend to the collection.
  • public addFriend() : void {
  •  
  • if ( this.name ) {
  •  
  • // The [bindOnce] directive has the ability to listen for changes to a value;
  • // however, it will only listen for changes to the top-level value reference.
  • // As such, it won't see any change if we just .push() a value onto the
  • // array - it will only see the change if we create a new friends array.
  • if ( this.useImmutable ) {
  •  
  • // [bindOnce]="friends" WILL SEE this change.
  • this.friends = this.friends.concat( this.name );
  •  
  • } else {
  •  
  • // [bindOnce]="friends" will NOT SEE this change.
  • this.friends.push( this.name );
  •  
  • }
  •  
  • this.name = "";
  •  
  • }
  •  
  • }
  •  
  •  
  • // I handle the document-level click.
  • // --
  • // NOTE: This serves no purpose other than to show when a change-detection is
  • // triggered based on a host-binding.
  • public handleClick() : void {
  •  
  • console.log( "Click ( will trigger change detection )" );
  •  
  • }
  •  
  • }

As you can see, we have two ngFor loops. And, inside each of the ngFor loops, we have the [logDoCheck] directive. This will allow us to see when each of the ngFor loops interacts with the change-detection tree.

Then, we have an array of friends to which the user can add new friends. The act of adding a friend can be performed via mutation: the new name is pushed onto the collection; or, it can be performed via concatenation: a new collection of friends is created for each addition. The former leaves the top-level "friends" reference in place, the latter creates a new top-level "friends" reference on each change. These two approaches will affect whether or not the change is propagated to our *bindOnce="friends" structural directive.

Now, if we run this Angular app in the browser and try to perform a few "mutation" oriented changes, we get the following output:


 
 
 

 
 Using the bindOnce structural directive in Angular 7.1.4 allows a DOM fragment to be removed from the change-detection tree. 
 
 
 

As you can see, when we change the friends collection by mutation, the top-level reference to "friends" remains stable. As such, our [bindOnce] input binding never sees the change and the embedded ngFor loop only hooks into the initial change-detection digest.

Now, if we run this Angular app again, but this time create a new "friends" collection on each change (ie, using the "immutable" checkbox), we get the following output:


 
 
 

 
 The bindOnce structural directive in Angular 7.1.4 allows you to watch chnages to a top-level value reference. 
 
 
 

As you can see, when the immutable checkbox is checked, each new friends results in a new friends array. The new top-level reference is picked up by our [bindOnce] input binding and the DOM fragment re-enters the change-detection tree for a single digest. This allows for the rendering of the updated friends array; but, still side-steps most of the change-detection life-cycles in the application.

Again, this "bind-once" concept is probably not something that you'll need in the vast majority of Angular applications. Angular is just really fast right out of the box. But, I think this kind of experiment is a testament to how powerful and flexible the Angular template syntax is.



Reader Comments

Hi,

Great post, very informative. Actually we have used this approach for a custom i18n service. So texts are only rendered once and only rerendererd on locale change.

Reply to this Comment

@Pedro,

Ah, very cool -- and that makes perfect sense too; if it's not being dynamically re-rendered through the live of the view, no need to be checking those bindings!

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
NEW: Some basic markdown formatting is now supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.