Writing A Structural Directive in Angular 2

Or, how I wrote a customized version of ngFor

Posted on Sunday Mar 6, 2016 by  Tero Parviainen ( @teropa)

When building Angular 2 applications, we spend most of our time writingcomponents. There are also other kinds of other kinds of directives we can define, but in my experience you end up needing to do that surprisingly rarely.

But recently I did end up in a situation where I needed a custom directive. I was using ngFor to render a collection of items, and I wanted to not keep track of items changing positions inside the collection. Instead I wanted a repeater directive that only adds and removes DOM elements but never actually moves them around to try to keep track of collection reorderings.

The main reason I needed this was CSS animations. When items change positions, ngFor detaches their elements from the DOM and reattaches them in their new positions. At that point the browser stops any ongoing CSS animations and transitions, which was a problem for me. Additionally, I didn't care about the order of items in the DOM.

So, this sounded like a case for a special repeater directive. I'd like to be able to use it like I do ngFor:

<div *forAnyOrder="#thing of allTheThings">
  {{ thing }}
</div>

Let's see if we can build such a directive.

Creating a Directive

The process of creating a directive is very similar to creating a component: We define a new class and decorate it. We need the @Directive decorator with a CSS selector that matches the forAnyOrder attribute:

@Directive({
  selector: '[forAnyOrder]'
})
export class ForAnyOrder {

}

The expression *forAnyOrder="#thing of allTheThings" in our example refers to the collection that we're going to be iterating over, "allTheThings". We should be able to get that collection into the directive as an input.

Directive inputs are bound just like component inputs, by specifying their attribute names. In our case, we want to bind the value of the forAnyOrderattribute:

@Directive({
  selector: '[forAnyOrder]',
  inputs: ['forAnyOrder']
})
export class ForAnyOrder {

}

Now we can try to use our new directive:

@Component({
  selector: 'my-component',
  template: `
    <div *forAnyOrder="#thing of allTheThings">
      {{thing}}
    </div>
  `,
  directives: [ForAnyOrder]
})
class MyComponent {
  allTheThings = [1, 2, 3];
}

But this results in an interesting error message:

Can't bind to 'forAnyOrderOf' since it isn't a known native property

Somehow Angular is trying to append the "of" from inside the expression into the directive input name!

Understanding How Template Directives Desugar

The key to understanding what's going on here is the * prefix in our directive application "*forAnyOrder". This applies the directive as a template directive. That is, the HTML element on which the directive is applied will be used as atemplate. In our case the HTML content <div>{{ thing }}</div> will be applied many times over, once for each item in the collection.

Template directives fulfill the same use case as element transclusion does in Angular 1. It's what directives like ngIfngFor, and ngSwitch are built on.

As the directives chapter in the Angular docs explains, our template directive application actually desugars into the following form behind the scenes:

<template forAnyOrder #thing [forAnyOrderOf]="allTheThings">
  <div>
    {{ thing }}
  </div>
</template>

<template> element gets generated for us. The local variable declaration#thing gets pulled out of the expression, and the directive application itself desugars into a straightforward [forAnyOrderOf]="allTheThings".

Written in this form, it is easier to see that the value of our input is actually just the collection that we're binding into it. Aren't we glad we don't need to write all this by hand though!

The main thing we need to understand about this is that the name of the directive input is not exactly what we wrote in the source template. It has the offrom inside the expression appended to it, so it becomes forAnyOrderOf. We need to take this into account in the input name.

@Directive({
  selector: '[forAnyOrder]',
  inputs: ['forAnyOrderOf']
})

Creating a Collection Differ

We now have a reference to the collection in our directive. What we'll need to figure out next is how to detect changes in it. When an item gets added to the collection, we need to know about it. The same thing goes for when items get removed from it.

We could write this diffing code by hand and it wouldn't necessarily be hugely difficult either. But turns out we don't have to. That's because Angular comes with some built-in services called differs that know how to keep track of changes in iterables as well as objects and maps. They're scantily documented at the moment, but let's see if we can figure out how to use them.

There are different kinds of differs for different kinds of collections. We can find a suitable differ once we first get a reference to the collection we're supposed to be diffing. This we can do by introducing a setter for the input property:

@Directive({
  selector: '[forAnyOrder]',
  inputs: ['forAnyOrderOf']
})
export class ForAnyOrder {
  private collection:any;

  set forAnyOrderOf(coll:any) {
    this.collection = coll;

  }

}

The setter is invoked when Angular binds a value to the forAnyOrderOf input. We can use the value to find a differ for whathever type of collection it happens to be. Angular's IterableDiffers service has a find method that does this. It returns a factory for the correct type of differ:

import {
  Directive,
  IterableDiffers
} from 'angular2/core';

@Directive({
  selector: '[forAnyOrder]',
  inputs: ['forAnyOrderOf']
})
export class ForAnyOrder {
  private collection:any;

  constructor(private differs:IterableDiffers) {
  }

  set forAnyOrderOf(coll:any) {
    this.collection = coll;
    if (coll) {
      this.differs.find(coll);
    }
  }

}

We can then get the actual differ from that factory by asking for one, providing our directive's change detector to it as an argument. We'll then hold on to the differ for future use:

import {
  ChangeDetectorRef,
  Directive,
  IterableDiffer,
  IterableDiffers
} from 'angular2/core';

@Directive({
  selector: '[forAnyOrder]',
  inputs: ['forAnyOrderOf']
})
export class ForAnyOrder {
  private collection:any;
  private differ:IterableDiffer;

  constructor(private changeDetector:ChangeDetectorRef,
              private differs:IterableDiffers) {
  }

  set forAnyOrderOf(coll:any) {
    this.collection = coll;
    if (coll && !this.differ) {
      this.differ = this.differs.find(coll).create(this.changeDetector);
    }
  }

}

Implementing Change Detection

We can now use this differ to perform change detection. Just like in components, we can have our directive implement the ngDoCheck method to let Angular know we want to do our own change detection. Angular will call it whenever changes may have occurred:

import {
  ChangeDetectorRef,
  Directive,
  DoCheck,
  IterableDiffer,
  IterableDiffers
} from 'angular2/core';

@Directive({
  selector: '[forAnyOrder]',
  inputs: ['forAnyOrderOf']
})
export class ForAnyOrder implements DoCheck {
  private collection:any;
  private differ:IterableDiffer;

  constructor(private changeDetector:ChangeDetectorRef,
              private differs:IterableDiffers) {
  }

  set forAnyOrderOf(coll:any) {
    this.collection = coll;
    if (coll && !this.differ) {
      this.differ = this.differs.find(coll).create(this.changeDetector);
    }
  }

  ngDoCheck() {

  }

}

In ngDoCheck we can call the differ's diff method to check for changes. It will return an object of change records, for everything that has happened since the last time we called it. In that object there are two very useful methods calledforEachAddedItem and forEachRemovedItem. With those methods we can iterate over all the new and removed items, respectively:

ngDoCheck() {
  if (this.differ) {
    const changes = this.differ.diff(this.collection);
    if (changes) {
      changes.forEachAddedItem((change) => {
      });
      changes.forEachRemovedItem((change) => {
      });
    }
  }
}

Adding New Items to The DOM

For each added item, we should construct and attach a new view. Since this is a template directive, it has a template from which new views can be created. In our example application, the template just contains "<div>{{ things }}</div>" but it could really be a DOM structure of any complexity and any amount of Angular components and directives in it.

The template can be injected into our constructor as a TemplateRef object.

import {
  ChangeDetectorRef,
  Directive,
  DoCheck,
  IterableDiffer,
  IterableDiffers,
  TemplateRef
} from 'angular2/core';

@Directive({
  selector: '[forAnyOrder]',
  inputs: ['forAnyOrderOf']
})
export class ForAnyOrder implements DoCheck {
  private collection:any;
  private differ:IterableDiffer;

  constructor(private changeDetector:ChangeDetectorRef,
              private differs:IterableDiffers,
              private template:TemplateRef) {
  }

  // ...
}

Another service, called a view container can then be used to instantiate and attach instances of the template. We can inject it too, as a ViewContainerRefobject:

import {
  ChangeDetectorRef,
  Directive,
  DoCheck,
  IterableDiffer,
  IterableDiffers,
  TemplateRef,
  ViewContainerRef
} from 'angular2/core';

@Directive({
  selector: '[forAnyOrder]',
  inputs: ['forAnyOrderOf']
})
export class ForAnyOrder implements DoCheck {
  private collection:any;
  private differ:IterableDiffer;

  constructor(private changeDetector:ChangeDetectorRef,
              private differs:IterableDiffers,
              private template:TemplateRef,
              private viewContainer:ViewContainerRef) {
  }

  // ...

}

Now, for each added item, we use the view container and the template together to construct and attach a new view:

ngDoCheck() {
  if (this.differ) {
    const changes = this.differ.diff(this.collection);
    if (changes) {
      changes.forEachAddedItem((change) => {
        this.viewContainer.createEmbeddedView(this.template);
      });
      changes.forEachRemovedItem((change) => {
      });
    }
  }
}

One crucial thing still missing from this is the link between the view and the collection item that should be bound to that view. (In our example, that would correspond to the local variable declaration "#thing" in the generated <template>element that we saw earlier). There's a special local variable called $implicitthat connects these together. We should introduce it into the new view using the collection item as the value.

ngDoCheck() {
  if (this.differ) {
    const changes = this.differ.diff(this.collection);
    if (changes) {
      changes.forEachAddedItem((change) => {
        const view = this.viewContainer.createEmbeddedView(this.template);
        view.setLocal('\$implicit', change.item);
      });
      changes.forEachRemovedItem((change) => {
      });
    }
  }
}

Now we have a fully constructed view with all the data it needs. If you try the code, you'll see that it successfully renders the collection!

Removing Items from The DOM

Our remaining task is to remove views for items that are no longer in the collection, which should happen inside the forEachRemovedItem iterator.

We'll need to somehow keep track of the views we've created, so that we know which ones to remove later. We can introduce a Map for them into our directive class:

import {
  ChangeDetectorRef,
  Directive,
  DoCheck,
  IterableDiffer,
  IterableDiffers,
  TemplateRef,
  ViewContainerRef,
  ViewRef
} from 'angular2/core';

@Directive({
  selector: '[forAnyOrder]',
  inputs: ['forAnyOrderOf']
})
export class ForAnyOrder implements DoCheck {
  private collection:any;
  private differ:IterableDiffer;
  private viewMap:Map<any,ViewRef> = new Map<any,ViewRef>();

  // ...
}

Then, as we create a new view, we'll also store it in the map. The map key is the collection item that the view was created for:

changes.forEachAddedItem((change) => {
  const view = this.viewContainer.createEmbeddedView(this.template);
  view.setLocal('\$implicit', change.item);
  this.viewMap.set(change.item, view);
});

When we then need to remove an item, we can do so in four steps:

  1. Get the view from our map.
  2. Get the view's current index in the view container
  3. Ask the view container to remove the view in that index.
  4. Remove the view from our map. We no longer need it and we don't want to leak memory.
changes.forEachRemovedItem((change) => {
  const view = this.viewMap.get(change.item);
  const viewIndex = this.viewContainer.indexOf(view);
  this.viewContainer.remove(viewIndex);
  this.viewMap.delete(change.item);
});

And there we have a fully functional forAnyOrder repeater directive!

Kind of. The directive not fully fully functional in the sense that it doesn't support some of the additional things that ngFor does, such as "track by" expressions. I haven't needed any of that, but if you do, implementing support for additional features should be possible by studying the implementation of ngFor.

原文链接: https://teropa.info/blog/2016/03/06/writing-an-angular-2-template-directive.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值