Angular9-高级教程-六-

71 阅读38分钟

Angular9 高级教程(六)

原文:Pro Angular 9

协议:CC BY-NC-SA 4.0

十五、创建属性指令

在这一章中,我将描述如何使用自定义指令来补充 Angular 的内置指令所提供的功能。本章的重点是属性指令,这是可以创建的最简单的类型,可以改变单个元素的外观或行为。在第十六章中,我解释了如何创建结构指令,用来改变 HTML 文档的布局。组件也是一种指令,我会在第十七章解释它们是如何工作的。

在这些章节中,我通过重新创建一些内置指令提供的特性来描述定制指令是如何工作的。这不是你在真实项目中通常会做的事情,但是它提供了一个有用的基线,可以用来解释这个过程。表 15-1 将属性指令放入上下文中。

表 15-1。

将属性指令放在上下文中

|

问题

|

回答

| | --- | --- | | 它们是什么? | 属性指令是能够修改它们所应用到的元素的行为或外观的类。第十二章中描述的样式和类绑定就是属性指令的例子。 | | 它们为什么有用? | 内置指令涵盖了 web 应用开发中最常见的任务,但并不能处理所有情况。自定义指令允许定义特定于应用的功能。 | | 它们是如何使用的? | 属性指令是已经应用了@Directive装饰器的类。它们在负责模板的组件的directives属性中启用,并使用 CSS 选择器应用。 | | 有什么陷阱或限制吗? | 创建自定义指令时的主要陷阱是编写代码来执行任务,这些任务可以使用指令功能(如输入和输出属性以及宿主元素绑定)来更好地处理。 | | 有其他选择吗? | Angular 支持另外两种类型的指令——结构指令和组件指令——它们可能更适合给定的任务。如果您希望避免编写自定义代码,有时可以组合内置指令来创建特定的效果,尽管结果可能很脆弱,并导致难以阅读和维护的复杂 HTML。 |

表 15-2 总结了本章内容。

表 15-2。

章节总结

|

问题

|

解决办法

|

列表

| | --- | --- | --- | | 创建属性指令 | 将@Directive应用于一个类 | 1–5 | | 访问主体元素属性值 | 将@Attribute装饰器应用于构造函数参数 | 6–9 | | 创建数据绑定输入属性 | 将@Input装饰器应用于一个类属性 | 10–11 | | 当数据绑定输入属性值更改时接收通知 | 实现ngOnChanges方法 | Twelve | | 定义事件 | 应用@Output装饰器 | 13, 14 | | 在宿主元素上创建属性绑定或事件绑定 | 应用@HostBinding@HostListener装饰器 | 15–19 | | 导出指令的功能以便在模板中使用 | 使用@Directive装饰器的exportAs属性 | 20, 21 |

准备示例项目

正如我在本书的这一部分所做的那样,我将继续使用上一章的示例项目。为了准备这一章,我已经重新定义了表单,以便它更新组件的newProduct属性,而不是第十四章中使用的基于模型的表单,如清单 15-1 所示。

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

<style>
  input.ng-dirty.ng-invalid { border: 2px solid #ff0000 }
  input.ng-dirty.ng-valid { border: 2px solid #6bc502 }
</style>

<div class="row m-2">
  <div class="col-6">
    <form class="m-2" novalidate (ngSubmit)="submitForm()">
      <div class="form-group">
          <label>Name</label>
          <input class="form-control" name="name" [(ngModel)]="newProduct.name" />
      </div>
      <div class="form-group">
        <label>Category</label>
        <input class="form-control" name="category"
            [(ngModel)]="newProduct.category" />
      </div>
      <div class="form-group">
        <label>Price</label>
        <input class="form-control" name="price" [(ngModel)]="newProduct.price" />
      </div>
      <button class="btn btn-primary" type="submit">Create</button>
    </form>
  </div>

  <div class="col-6">
    <table class="table table-sm table-bordered table-striped">
      <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
      <tr *ngFor="let item of getProducts(); let i = index">
        <td>{{i + 1}}</td>
        <td>{{item.name}}</td>
        <td>{{item.category}}</td>
        <td>{{item.price}}</td>
      </tr>
    </table>
  </div>
</div>

Listing 15-1.Preparing the Template in the template.html File in the src/app Folder

这个清单使用 Bootstrap 网格布局并排放置表单和表格。清单 15-2 移除了 JSON 输出jsonProduct属性,更新了组件的addProduct方法,以便向数据模型添加一个新对象,并简化了submitForm方法。

import { ApplicationRef, Component } from "@angular/core";
import { NgForm, FormGroup } from "@angular/forms";
import { Model } from "./repository.model";
import { Product } from "./product.model";
import { ProductFormGroup, ProductFormControl } from "./form.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();
    formGroup: ProductFormGroup = new ProductFormGroup();

    getProduct(key: number): Product {
        return this.model.getProduct(key);
    }

    getProducts(): Product[] {
        return this.model.getProducts();
    }

    newProduct: Product = new Product();

    addProduct(p: Product) {
        this.model.saveProduct(p);
    }

    formSubmitted: boolean = false;

    submitForm() {
        this.addProduct(this.newProduct);
    }
}

Listing 15-2.Modifying the Data Model in the component.ts File in the src/app Folder

要启动应用,导航到example项目文件夹并运行以下命令:

ng serve

打开一个新的浏览器窗口并导航至http://localhost:4200以查看图 15-1 中的表格。当您提交表单时,数据将被验证,或者显示错误消息,或者将一个新项目添加到数据模型并显示在表中。

img/421542_4_En_15_Fig1_HTML.jpg

图 15-1。

运行示例应用

创建简单的属性指令

最好的起点是开始创建一个指令,看看它们是如何工作的。我用清单 15-3 中所示的代码在src/app文件夹中添加了一个名为attr.directive.ts的文件。该文件的名称表明它包含指令。我将文件名的第一部分设置为attr,以表明这是一个属性指令的例子。

import { Directive, ElementRef } from "@angular/core";

@Directive({
    selector: "[pa-attr]",
})
export class PaAttrDirective {

    constructor(element: ElementRef) {
        element.nativeElement.classList.add("bg-success", "text-white");
    }
}

Listing 15-3.The Contents of the attr.directive.ts File in the src/app Folder

指令是已经应用了@Directive装饰器的类。装饰器需要selector属性,该属性用于指定如何将指令应用于元素,使用标准 CSS 样式选择器来表达。我使用的选择器是[pa-attr],它将匹配任何具有名为pa-attr的属性的元素,而不管元素类型或分配给该属性的值。

自定义指令被赋予一个独特的前缀,以便于识别。前缀可以是对您的应用有意义的任何内容。我为我的指令选择了前缀Pa,反映了这本书的标题,这个前缀用于由selector decorator 属性和属性类的名称指定的属性中。前缀的大小写进行了更改,以反映其用途,因此选择器属性名称使用了首字母小写字符(pa-attr),指令类名称使用了首字母大写字符(PaAttrDirective)。

Note

前缀Ng / ng保留用于内置 Angular 特征,不应使用。

指令构造函数定义了一个单独的ElementRef参数,Angular 在创建指令的新实例时提供这个参数,这个参数代表主机元素。ElementRef类定义了一个属性nativeElement,它返回浏览器使用的对象来表示域对象模型中的元素。该对象提供对操作元素及其内容的方法和属性的访问,包括classList属性,它可用于管理元素的类成员,如下所示:

...
element.nativeElement.classList.add("bg-success", "text-white");
...

总之,PaAttrDirective类是一个指令,它应用于具有pa-attr属性的元素,并将这些元素添加到bg-successtext-white类,引导 CSS 库使用它们为元素分配背景和文本颜色。

应用自定义指令

应用自定义指令有两个步骤。首先是更新模板,以便有一个或多个元素与指令使用的selector匹配。在示例指令的情况下,这意味着将pa-attr属性添加到元素中,如清单 15-4 所示。

...
<table class="table table-sm table-bordered table-striped">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
    <tr *ngFor="let item of getProducts(); let i = index" pa-attr>
        <td>{{i + 1}}</td>
        <td>{{item.name}}</td>
        <td>{{item.category}}</td>
        <td>{{item.price}}</td>
    </tr>
</table>
...

Listing 15-4.Adding a Directive Attribute in the template.html File in the src/app Folder

指令的选择器匹配任何具有属性的元素,不管是否为其分配了值或者该值是什么。应用指令的第二步是改变 Angular 模块的配置,如清单 15-5 所示。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule  } from "@angular/forms";
import { PaAttrDirective } from "./attr.directive";

@NgModule({
    imports: [BrowserModule, FormsModule, ReactiveFormsModule],
    declarations: [ProductComponent, PaAttrDirective],
    bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 15-5.Configuring the Component in the app.module.ts File in the src/app Folder

NgModule装饰器的declarations属性声明了应用将使用的指令和组件。如果指令和组件之间的关系和区别目前看起来很混乱,不要担心;这将在第十七章中变得清晰。

一旦这两个步骤都完成了,那么应用于模板中的tr元素的pa-attr属性将触发自定义指令,该指令使用 DOM API 将元素添加到bg-successtext-white类中。由于tr元素是ngFor指令使用的微模板的一部分,表中的所有行都会受到影响,如图 15-2 所示。(您可能需要重新启动 Angular 开发工具才能看到变化。)

img/421542_4_En_15_Fig2_HTML.jpg

图 15-2。

应用自定义指令

在指令中访问应用数据

上一节中的例子展示了一个指令的基本结构,但是它没有做任何仅仅通过使用绑定在tr元素上的class属性不能执行的事情。当指令可以与宿主元素和应用的其余部分交互时,它们就变得很有用。

读取主体元素属性

使指令更有用的最简单方法是使用应用于主机元素的属性来配置它,这允许为指令的每个实例提供自己的配置信息,并相应地调整其行为。

举例来说,清单 15-6 将该指令应用于模板表中的一些td元素,并添加了一个属性,该属性指定了主机元素应该添加到的类。该指令的选择器意味着它将匹配任何具有pa-attr属性的元素,不管标签类型如何,并且将像在tr元素上一样在td元素上工作。

...
<table class="table table-sm table-bordered table-striped">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
    <tr *ngFor="let item of getProducts(); let i = index" pa-attr>
        <td>{{i + 1}}</td>
        <td>{{item.name}}</td>
        <td pa-attr pa-attr-class="bg-warning">{{item.category}}</td>
        <td pa-attr pa-attr-class="bg-info">{{item.price}}</td>
    </tr>
</table>
...

Listing 15-6.Adding Attributes in the template.html File in the src/app Folder

属性pa-attr已经应用于两个td元素,还有一个名为pa-attr-class的新属性,用于指定指令应该将主机元素添加到的类。清单 15-7 显示了获取pa-attr-class属性的值并使用它来改变元素的指令所需的改变。

import { Directive, ElementRef, Attribute } from "@angular/core";

@Directive({
  selector: "[pa-attr]",
})
export class PaAttrDirective {

  constructor(element: ElementRef, @Attribute("pa-attr-class") bgClass: string) {
    element.nativeElement.classList.add(bgClass || "bg-success", "text-white");
  }
}

Listing 15-7.Reading an Attribute in the attr.directive.ts File in the src/app Folder

为了接收pa-attr-class属性的值,我添加了一个名为bgClass的新构造函数参数,其中已经应用了@Attribute装饰器。这个装饰器是在@angular/core模块中定义的,它指定了属性的名称,当创建一个新的 directive 类实例时,应该使用这个属性为构造函数参数提供一个值。Angular 为每个匹配选择器的元素创建一个新的装饰器实例,并使用该元素的属性为已经用@Attribute装饰过的指令构造函数参数提供值。

在构造函数中,属性的值被传递给classList.add方法,默认值允许将指令应用于具有pa-attr属性但没有pa-attr-class属性的元素。

结果是添加元素的类现在可以使用属性来指定,产生如图 15-3 所示的结果。

img/421542_4_En_15_Fig3_HTML.jpg

图 15-3。

使用主机元素属性配置指令

使用单个主体元素属性

使用一个属性来应用一个指令,而使用另一个属性来配置它是多余的,让一个属性完成双重任务更有意义,如清单 15-8 所示。

import { Directive, ElementRef, Attribute } from "@angular/core";

@Directive({
    selector: "[pa-attr]",
})
export class PaAttrDirective {

    constructor(element: ElementRef, @Attribute("pa-attr") bgClass: string) {
        element.nativeElement.classList.add(bgClass || "bg-success", "text-white");
    }
}

Listing 15-8.Reusing an Attribute in the attr.directive.ts File in the src/app Folder

@Attribute装饰器现在将pa-attr属性指定为bgClass参数值的来源。在清单 15-9 中,我已经更新了模板以反映两用属性。

...
<table class="table table-sm table-bordered table-striped">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
    <tr *ngFor="let item of getProducts(); let i = index" pa-attr>
        <td>{{i + 1}}</td>
        <td>{{item.name}}</td>
        <td pa-attr="bg-warning">{{item.category}}</td>
        <td pa-attr="bg-info">{{item.price}}</td>
    </tr>
</table>
...

Listing 15-9.Applying a Directive in the template.html File in the src/app Folder

这个示例产生的结果没有明显的变化,但是它简化了在 HTML 模板中应用指令的方式。

创建数据绑定输入属性

@Attribute读取属性的主要限制是值是静态的。Angular 指令的真正威力来自于对表达式的支持,这些表达式可以更新以反映应用状态的变化,并可以通过更改宿主元素来做出响应。

指令使用数据绑定输入属性接收表达式,也称为输入属性,或者简称为输入。清单 15-10 改变了应用的模板,使得应用于trtd元素的pa-attr属性包含表达式,而不仅仅是静态类名。

...
<table class="table table-sm table-bordered table-striped">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
    <tr *ngFor="let item of getProducts(); let i = index"
            [pa-attr]="getProducts().length < 6 ? 'bg-success' : 'bg-warning'">
        <td>{{i + 1}}</td>
        <td>{{item.name}}</td>
        <td [pa-attr]="item.category == 'Soccer' ? 'bg-info' : null">
            {{item.category}}
        </td>
        <td [pa-attr]="'bg-info'">{{item.price}}</td>
    </tr>
</table>
...

Listing 15-10.Using Expressions in the template.html File in the src/app Folder

清单中有三个表达式。第一个应用于tr元素,使用组件的getProducts方法返回的对象数量来选择一个类。

...
<tr *ngFor="let item of getProducts(); let i = index"
    [pa-attr]="getProducts().length < 6 ? 'bg-success' : 'bg-warning'">
...

第二个表达式应用于Category列的td元素,它为Product对象指定了bg-info类,这些对象的Category属性为所有其他值返回Soccernull

...
<td [pa-attr]="item.category == 'Soccer' ? 'bg-info' : null">
...

第三个也是最后一个表达式返回一个固定的字符串值,我用单引号将它括起来,因为这是一个表达式而不是静态属性值。

...
<td [pa-attr]="'bg-info'">{{item.price}}</td>
...

请注意,属性名是用方括号括起来的。这是因为在指令中接收表达式的方法是创建一个数据绑定,就像第 13 和 14 章中描述的内置指令一样。

Tip

忘记使用方括号是一个常见的错误。没有它们,Angular 只会将表达式的原始文本传递给指令,而不会对其进行计算。如果在应用自定义指令时遇到错误,这是首先要检查的。

实现数据绑定的另一面意味着在 directive 类中创建一个输入属性,并告诉 Angular 如何管理它的值,如清单 15-11 所示。

import { Directive, ElementRef, Attribute, Input } from "@angular/core";

@Directive({
    selector: "[pa-attr]"
})
export class PaAttrDirective {

    constructor(private element: ElementRef) {}

    @Input("pa-attr")
    bgClass: string;

    ngOnInit() {
        this.element.nativeElement.classList.add(this.bgClass || "bg-success",
            "text-white");
    }
}

Listing 15-11.Defining an Input Property in the attr.directive.ts File in the src/app Folder

输入属性是通过将@Input decorator 应用于属性并使用它来指定包含表达式的属性的名称来定义的。这个清单定义了一个输入属性,它告诉 Angular 将指令的bgClass属性的值设置为包含在pa-attr属性中的表达式的值。

Tip

如果属性的名称对应于主机元素上属性的名称,您不需要向@Input decorator 提供参数。因此,如果您将@Input()应用到一个名为myVal的属性,那么 Angular 将在主机元素上寻找一个myVal属性。

在本例中,构造函数的角色发生了变化。当 Angular 创建一个指令类的新实例时,构造函数被调用来创建一个新的指令对象,然后才是输入属性集的值。这意味着构造函数无法访问输入属性值,因为 Angular 不会设置它的值,直到构造函数完成并生成新的指令对象。为了解决这个问题,指令可以实现生命周期钩子方法,Angular 使用这些方法在指令被创建后和应用运行时为指令提供有用的信息,如表 15-3 所述。

表 15-3。

指令生命周期挂钩方法

|

名字

|

描述

| | --- | --- | | ngOnInit | 在 Angular 为指令声明的所有输入属性设置了初始值之后,将调用此方法。 | | ngOnChanges | 当输入属性的值已经改变时,并且就在调用ngOnInit方法之前,调用该方法。 | | ngDoCheck | 当 Angular 运行它的变化检测过程时调用这个方法,以便指令有机会更新任何与输入属性没有直接关联的状态。 | | ngAfterContentInit | 当指令的内容已经初始化时,调用此方法。有关使用该方法的示例,请参见第十六章中的“接收查询更改通知”一节。 | | ngAfterContentChecked | 在作为更改检测过程的一部分检查了指令的内容之后,将调用此方法。 | | ngOnDestroy | 在 Angular 销毁指令之前立即调用此方法。 |

为了在主机元素上设置类,清单 15-11 中的指令实现了ngOnInit方法,该方法在 Angular 设置了bgClass属性的值之后被调用。仍然需要构造函数来接收提供对主机元素访问的ElementRef对象,该对象被分配给一个名为element的属性。

结果是 Angular 将为每个tr元素创建一个指令对象,评估在pa-attr属性中指定的表达式,使用结果来设置输入属性的值,然后调用ngOnInit方法,这允许指令响应新的输入属性值。

要查看效果,请使用该表单向示例应用添加一个新产品。由于模型中最初有五个项目,tr元素的表达式将选择bg-success类。添加新项时,Angular 会创建 directive 类的另一个实例,并对表达式求值以设置 input 属性的值;由于现在模型中有六个项目,表达式将选择bg-warning类,该类为新行提供不同的背景颜色,如图 15-4 所示。

img/421542_4_En_15_Fig4_HTML.jpg

图 15-4。

在自定义指令中使用输入属性

响应输入属性更改

在前一个例子中发生了一些奇怪的事情:添加一个新项目影响了新元素的外观,但没有影响现有的元素。在幕后,Angular 已经为它创建的每个指令更新了bgClass属性的值——表列中的每个td元素一个——但是指令没有注意到,因为更改属性值不会自动导致指令响应。

为了处理变更,指令必须实现ngOnChanges方法,以便在输入属性的值发生变更时接收通知,如清单 15-12 所示。

import { Directive, ElementRef, Attribute, Input,
         SimpleChange } from "@angular/core";

@Directive({
    selector: "[pa-attr]"
})
export class PaAttrDirective {

    constructor(private element: ElementRef) {}

    @Input("pa-attr")
    bgClass: string;

    ngOnChanges(changes: {[property: string]: SimpleChange }) {
        let change = changes["bgClass"];
        let classList = this.element.nativeElement.classList;
        if (!change.isFirstChange() && classList.contains(change.previousValue)) {
            classList.remove(change.previousValue);
        }
        if (!classList.contains(change.currentValue)) {
            classList.add(change.currentValue);
        }
    }
}

Listing 15-12.Receiving Change Notifications in the attr.directive.ts File in the src/app Folder

在调用ngOnInit方法之前,调用一次ngOnChanges方法,然后每当指令的输入属性发生变化时,再次调用该方法。ngOnChanges参数是一个对象,其属性名引用每个改变的输入属性,其值是在@angular/core模块中定义的SimpleChange对象。TypeScript 将这种数据结构表示如下:

...
ngOnChanges(changes: {[property: string]: SimpleChange }) {
...

SimpleChange类定义了表 15-4 中所示的成员。

表 15-4。

SimpleChange 类的属性和方法

|

名字

|

描述

| | --- | --- | | previousValue | 此属性返回输入属性的上一个值。 | | currentValue | 此属性返回输入属性的当前值。 | | isFirstChange() | 如果这是对发生在ngOnInit方法之前的ngOnChanges方法的调用,则该方法返回true。 |

理解向ngOnChanges方法呈现更改的最简单的方法是将对象序列化为 JSON,然后查看它。

...
{
    "target": {
        "previousValue":"bg-success",
        "currentValue":"bg-warning"
    }
}
...

这去掉了isFirstChange方法,但是它确实有助于展示 argument 对象中的每个属性被用来指示输入属性的变化的方式。

当响应输入属性值的更改时,指令必须确保撤销先前更新的效果。在示例指令的情况下,这意味着从previousValue类中移除元素并将其添加到currentValue类中。

使用isFirstChange方法很重要,这样你就不会撤销一个实际上还没有应用的值,因为第一次给输入属性赋值时调用了ngOnChanges方法。

处理这些更改通知的结果是,当 Angular 重新计算表达式并更新输入属性时,指令会做出响应。现在,当你向应用中添加一个新产品时,所有tr元素的背景颜色都会更新,如图 15-5 所示。

img/421542_4_En_15_Fig5_HTML.jpg

图 15-5。

响应输入属性更改

创建自定义事件

输出属性是一个 Angular 特性,它允许指令将自定义事件添加到它们的主机元素中,通过它可以将重要变化的细节发送到应用的其余部分。使用@Output装饰器定义输出属性,该装饰器在@angular/core模块中定义,如清单 15-13 所示。

import { Directive, ElementRef, Attribute, Input,
         SimpleChange, Output, EventEmitter } from "@angular/core";
import { Product } from "./product.model";

@Directive({
    selector: "[pa-attr]"
})
export class PaAttrDirective {

    constructor(private element: ElementRef) {
        this.element.nativeElement.addEventListener("click", e => {
            if (this.product != null) {
                this.click.emit(this.product.category);
            }
        });
    }

    @Input("pa-attr")
    bgClass: string;

    @Input("pa-product")
    product: Product;

    @Output("pa-category")
    click = new EventEmitter<string>();

    ngOnChanges(changes: {[property: string]: SimpleChange }) {
        let change = changes["bgClass"];
        let classList = this.element.nativeElement.classList;
        if (!change.isFirstChange() && classList.contains(change.previousValue)) {
            classList.remove(change.previousValue);
        }
        if (!classList.contains(change.currentValue)) {
            classList.add(change.currentValue);
        }
    }
}

Listing 15-13.Defining an Output Property in the attr.directive.ts File in the src/app Folder

EventEmitter类为 Angular 指令提供了事件机制。清单创建了一个EventEmitter对象,并将其赋给一个名为click的变量,如下所示:

...
@Output("pa-category")
click = new EventEmitter<string>();
...

类型参数表明当事件被触发时,事件的监听器将接收一个字符串。指令可以向它们的事件监听器提供任何类型的对象,但是常见的选择是stringnumber值、数据模型对象和 JavaScript Event对象。

当鼠标按钮在主机元素上单击时,清单中的定制事件被触发,该事件向其侦听器提供使用ngFor指令创建表格行的Product对象的category。其效果是,该指令响应宿主元素上的 DOM 事件,并生成自己的自定义事件作为响应。DOM 事件的侦听器是使用浏览器的标准addEventListener方法在指令类构造函数中设置的,如下所示:

...
constructor(private element: ElementRef) {
    this.element.nativeElement.addEventListener("click", e => {
        if (this.product != null) {
            this.click.emit(this.product.category);
        }
    });
}
...

该指令定义了一个输入属性来接收Product对象,该对象的类别将在事件中发送。(该指令能够在构造函数中引用输入属性值的值,因为 Angular 将在调用分配用于处理 DOM 事件的函数之前设置属性值。)

清单中最重要的语句是使用EventEmitter对象发送事件的语句,这是使用EventEmitter.emit方法完成的,在表 15-5 中有描述,以供快速参考。emit方法的参数是您希望事件侦听器接收的值,在本例中是category属性的值。

表 15-5。

EventEmitter 方法

|

名字

|

描述

| | --- | --- | | emit(value) | 该方法触发与EventEmitter相关联的定制事件,向侦听器提供作为方法参数接收的对象或值。 |

将一切联系在一起的是@Output装饰器,它在指令类EventEmitter属性和将用于绑定到模板中事件的名称之间创建一个映射,如下所示:

...
@Output("pa-category")
click = new EventEmitter<string>();
...

装饰器的参数指定了将在应用于主机元素的事件绑定中使用的属性名。如果 TypeScript 属性名称也是自定义事件的名称,则可以省略该参数。我在清单中指定了pa-category,这允许我在指令类中将事件称为click,但是需要一个更有意义的外部名称。

绑定到自定义事件

Angular 通过使用与内置事件相同的绑定语法,很容易绑定到模板中的自定义事件,这在第十四章中有描述。清单 15-14 将pa-product属性添加到模板中的tr元素,为指令提供其Product对象,并为pa-category事件添加一个绑定。

...
<table class="table table-sm table-bordered table-striped">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
    <tr *ngFor="let item of getProducts(); let i = index"
            [pa-attr]="getProducts().length < 6 ? 'bg-success' : 'bg-warning'"
            [pa-product]="item" (pa-category)="newProduct.category = $event">
        <td>{{i + 1}}</td>
        <td>{{item.name}}</td>
        <td [pa-attr]="item.category == 'Soccer' ? 'bg-info' : null">
            {{item.category}}
        </td>
        <td [pa-attr]="'bg-info'">{{item.price}}</td>
    </tr>
</table>
...

Listing 15-14.Binding to a Custom Event in the template.html File in the src/app Folder

术语$event用于访问指令传递给EventEmitter.emit方法的值。这意味着在本例中,$event将是一个包含产品类别的string值。从事件中接收的值用于设置类别input元素的值,这意味着单击表格中的一行将在表单中显示产品的类别,如图 15-6 所示。

img/421542_4_En_15_Fig6_HTML.jpg

图 15-6。

使用输出属性定义和接收自定义事件

创建宿主元素绑定

示例指令依赖浏览器的 DOM API 来操作其主机元素,既添加和删除类成员,又接收click事件。在 Angular 应用中使用 DOM API 是一项有用的技术,但这意味着您的指令只能在 web 浏览器中运行的应用中使用。Angular 旨在在一系列不同的执行环境中运行,并不是所有的执行环境都能提供 DOM API。

即使您确定某个指令可以访问 DOM,也可以使用标准的 angle 指令特性(属性和事件绑定)以更优雅的方式获得相同的结果。可以在 host 元素上使用类绑定,而不是使用 DOM 来添加和删除类。可以使用事件绑定来处理鼠标点击,而不是使用addEventListener方法。

在幕后,当在 web 浏览器中使用该指令时,Angular 使用 DOM API 实现这些特性——或者当该指令在不同的环境中使用时,使用一些等效的机制。

主机元素上的绑定是使用两个装饰器定义的,@HostBinding@HostListener,它们都是在@angular/core模块中定义的,如清单 15-15 所示。

import { Directive, ElementRef, Attribute, Input,
         SimpleChange, Output, EventEmitter, HostListener, HostBinding }
            from "@angular/core";
 import { Product } from "./product.model";

@Directive({
    selector: "[pa-attr]"
})
export class PaAttrDirective {

    @Input("pa-attr")
    @HostBinding("class")
    bgClass: string;

    @Input("pa-product")
    product: Product;

    @Output("pa-category")
    click = new EventEmitter<string>();

    @HostListener("click")
    triggerCustomEvent() {
        if (this.product != null) {
            this.click.emit(this.product.category);
        }
    }
}

Listing 15-15.Creating Host Bindings in the attr.directive.ts File in the src/app Folder

@HostBinding decorator 用于在主机元素上设置一个属性绑定,并应用于一个指令属性。清单在主机元素的class属性和装饰者的bgClass属性之间建立了一个绑定。

Tip

如果想要管理元素的内容,可以使用@HostBinding装饰器绑定到textContent属性。例子见第十九章。

@HostListener装饰器用于在主机元素上设置事件绑定,并应用于一个方法。该清单为click事件创建了一个事件绑定,当鼠标按钮被按下并释放时,该事件将调用triggerCustomEvent方法。顾名思义,triggerCustomEvent方法使用EventEmitter.emit方法通过输出属性调度定制事件。

使用主机元素绑定意味着可以删除指令构造函数,因为不再需要通过ElementRef对象访问 HTML 元素。相反,Angular 负责设置事件侦听器,并通过属性绑定设置元素的类成员。

虽然指令代码要简单得多,但是指令的效果是一样的:单击一个表格行设置一个input元素的值,使用表单添加一个新项目会触发不属于Soccer类别的产品的表格单元格的背景颜色的变化。

在宿主元素上创建双向绑定

Angular 为创建支持双向绑定的指令提供了特殊的支持,因此它们可以与ngModel使用的香蕉盒样式一起使用,并且可以双向绑定到模型属性。

双向绑定功能依赖于命名约定。为了演示它是如何工作的,清单 15-16 向template.html文件添加了一些新元素和绑定。

...
<div class="col-6">

  <div class="form-group bg-info text-white p-2">
    <label>Name:</label>
    <input class="bg-primary text-white" [paModel]="newProduct.name"
        (paModelChange)="newProduct.name = $event" />
  </div>

  <table class="table table-sm table-bordered table-striped">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
    <tr *ngFor="let item of getProducts(); let i = index"
            [pa-attr]="getProducts().length < 6 ? 'bg-success' : 'bg-warning'"
            [pa-product]="item" (pa-category)="newProduct.category = $event">
        <td>{{i + 1}}</td>
        <td>{{item.name}}</td>
        <td [pa-attr]="item.category == 'Soccer' ? 'bg-info' : null">
            {{item.category}}
        </td>
        <td [pa-attr]="'bg-info'">{{item.price}}</td>
    </tr>
  </table>
</div>
...

Listing 15-16.Applying a Directive in the template.html File in the src/app Folder

我将创建一个支持两个单向绑定的指令。当newProduct.name属性的值改变时,目标为paModel的绑定将被更新,这提供了从应用到指令的数据流,并将用于更新input元素的内容。定制事件paModelChange将在用户更改 name input 元素的内容时被触发,并将从指令向应用的其余部分提供数据流。

为了实现这个指令,我在src/app文件夹中添加了一个名为twoway.directive.ts的文件,并用它来定义清单 15-17 中所示的指令。

import { Input, Output, EventEmitter, Directive,
         HostBinding, HostListener, SimpleChange } from "@angular/core";

@Directive({
    selector: "input[paModel]"
})
export class PaModel {

    @Input("paModel")
    modelProperty: string;

    @HostBinding("value")
    fieldValue: string = "";

    ngOnChanges(changes: { [property: string]: SimpleChange }) {
        let change = changes["modelProperty"];
        if (change.currentValue != this.fieldValue) {
            this.fieldValue = changes["modelProperty"].currentValue || "";
        }
    }

    @Output("paModelChange")
    update = new EventEmitter<string>();

    @HostListener("input", ["$event.target.value"])
    updateValue(newValue: string) {
        this.fieldValue = newValue;
        this.update.emit(newValue);
    }
}

Listing 15-17.The Contents of the twoway.directive.ts File in the src/app Folder

该指令使用了之前描述过的功能。该指令的selector属性指定它将匹配具有paModel属性的input元素。内置的ngModel双向指令支持一系列的表单元素,并且知道每个元素使用哪些事件和属性,但是我想保持这个例子简单,所以我将只支持input元素,它定义了一个value属性来获取和设置元素内容。

使用输入属性和ngOnChanges方法实现了paModel绑定,该方法通过在input元素的value属性上绑定主机来更新输入元素的内容,从而响应表达式值的变化。

使用一个主机监听器在input事件上实现paModelChange事件,然后通过输出属性发送一个更新。注意,事件调用的方法能够通过给@HostListener装饰器指定一个额外的参数来接收事件对象,如下所示:

...
@HostListener("input", ["$event.target.value"])
updateValue(newValue: string) {
...

@HostListener装饰器的第一个参数指定了将由监听器处理的事件的名称。第二个参数是一个数组,用于为修饰方法提供参数。在这个例子中,input事件将由监听器处理,当updateValue方法被调用时,它的newValue参数将被设置为Event对象的target.value属性,使用$event引用该属性。

为了启用该指令,我将它添加到 Angular 模块中,如清单 15-18 所示。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule  } from "@angular/forms";
import { PaAttrDirective } from "./attr.directive";
import { PaModel } from "./twoway.directive";

@NgModule({
    imports: [BrowserModule, FormsModule, ReactiveFormsModule],
    declarations: [ProductComponent, PaAttrDirective, PaModel],
    bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 15-18.Registering the Directive in the app.module.ts File in the src/app Folder

当您保存更改并重新加载浏览器时,您将看到一个新的input元素,它响应对模型属性的更改,并在其宿主元素的内容发生更改时更新模型属性。绑定中的表达式指定了 HTML 文档左侧表单中Name字段使用的相同模型属性,这为测试它们之间的关系提供了一种便捷的方式,如图 15-7 所示。

img/421542_4_En_15_Fig7_HTML.jpg

图 15-7。

测试双向数据流

Tip

对于此示例,您可能需要停止 Angular 开发工具,重新启动它们,并重新加载浏览器。Angular 开发工具并不总是正确地处理变更。

最后一步是简化绑定并应用香蕉盒样式的括号,如清单 15-19 所示。

...
<div class="col-6">

    <div class="form-group bg-info text-white p-2">
      <label>Name:</label>
      <input class="bg-primary text-white" [(paModel)]="newProduct.name" />
    </div>

    <table class="table table-sm table-bordered table-striped">
        <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
        <tr *ngFor="let item of getProducts(); let i = index"
                [pa-attr]="getProducts().length < 6 ? 'bg-success' : 'bg-warning'"
                [pa-product]="item" (pa-category)="newProduct.category=$event">
            <td>{{i + 1}}</td>
            <td>{{item.name}}</td>
            <td [pa-attr]="item.category == 'Soccer' ? 'bg-info' : null">
                {{item.category}}
            </td>
            <td [pa-attr]="'bg-info'">{{item.price}}</td>
        </tr>
    </table>
</div>
...

Listing 15-19.Simplifying the Bindings in the template.html File in the src/app Folder

当 Angular 遇到[()]括号时,它扩展绑定以匹配清单 15-16 中使用的格式,目标是paModel输入属性并设置paModelChange事件。只要一个指令将这些暴露给 Angular,就可以使用香蕉盒括号将其作为目标,从而产生一个更简单的模板语法。

导出在模板变量中使用的指令

在前面的章节中,我使用模板变量来访问内置指令提供的功能,比如ngForm。作为一个例子,下面是第十四章中的一个元素:

...
<form novalidate #form="ngForm" (ngSubmit)="submitForm(form)">
...

form模板变量赋值ngForm,然后用它来访问 HTML 表单的验证信息。这是一个说明指令如何提供对其属性和方法的访问的示例,以便在数据绑定和表达式中使用它们。

清单 15-20 修改了上一节中的指令,以便它提供是否扩展了其主机元素中的文本的细节。

import { Input, Output, EventEmitter, Directive,
    HostBinding, HostListener, SimpleChange } from "@angular/core";

@Directive({
    selector: "input[paModel]",
    exportAs: "paModel"
})
export class PaModel {

    direction: string = "None";

    @Input("paModel")
    modelProperty: string;

    @HostBinding("value")
    fieldValue: string = "";

    ngOnChanges(changes: { [property: string]: SimpleChange }) {
        let change = changes["modelProperty"];
        if (change.currentValue != this.fieldValue) {
            this.fieldValue = changes["modelProperty"].currentValue || "";
            this.direction = "Model";
        }
    }

    @Output("paModelChange")
    update = new EventEmitter<string>();

    @HostListener("input", ["$event.target.value"])
    updateValue(newValue: string) {
        this.fieldValue = newValue;
        this.update.emit(newValue);
        this.direction = "Element";
    }
}

Listing 15-20.Exporting a Directive in the twoway.directive.ts File in the src/app Folder

@Directive装饰器的exportAs属性指定了一个名称,该名称将用于引用模板变量中的指令。这个例子使用paModel作为exportAs属性的值,并且您应该尝试使用能够清楚地表明哪个指令提供了该功能的名称。

清单向指令添加了一个名为direction的属性,用于指示数据何时从模型流向元素,或者从元素流向模型。

当您使用exportAs decorator 时,您提供了对该指令定义的所有方法和属性的访问,这些方法和属性将在模板表达式和数据绑定中使用。一些开发人员给不在指令之外使用的方法和属性的名字加上下划线(_字符)或者使用private关键字。这是对其他开发人员的一个提示,有些方法和属性不应该使用,但 Angular 并没有强制执行。清单 15-21 为指令的导出功能创建一个模板变量,并在样式绑定中使用它。

...
<div class="col-6">

    <div class="form-group bg-info text-white p-2">
        <label>Name:</label>
        <input class="bg-primary text-white" [(paModel)]="newProduct.name"
            #paModel="paModel" />
        <div class="bg-primary text-white">Direction: {{paModel.direction}}</div>
    </div>

    <table class="table table-sm table-bordered table-striped">
        <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
        <tr *ngFor="let item of getProducts(); let i = index"
                [pa-attr]="getProducts().length < 6 ? 'bg-success' : 'bg-warning'"
                [pa-product]="item" (pa-category)="newProduct.category = $event">
            <td>{{i + 1}}</td>
            <td>{{item.name}}</td>
            <td [pa-attr]="item.category == 'Soccer' ? 'bg-info' : null">
                {{item.category}}
            </td>
            <td [pa-attr]="'bg-info'">{{item.price}}</td>
        </tr>
    </table>
</div>
...

Listing 15-21.Using Exported Directive Functionality in the template.html File in the src/app Folder

模板变量称为paModel,它的值是指令的exportAs属性中使用的名称。

...
#paModel="paModel"
...

Tip

您不必为变量和指令使用相同的名称,但这有助于明确功能的来源。

一旦定义了模板变量,就可以在插值绑定中使用它,或者将其作为绑定表达式的一部分。我选择了一个字符串插值绑定,它的表达式使用指令的direction属性的值。

...
<div class="bg-primary text-white">Direction: {{paModel.direction}}</div>
...

结果是您可以看到在绑定到newProduct.name模型属性的两个input元素中键入文本的效果。当你输入一个使用ngModel指令的,那么字符串插值绑定会显示Model。当你键入使用paModel指令的元素时,字符串插值绑定会显示Element,如图 15-8 所示。

img/421542_4_En_15_Fig8_HTML.jpg

图 15-8。

从指令中导出功能

摘要

在本章中,我描述了如何定义和使用属性指令,包括输入和输出属性以及主机绑定的使用。在下一章中,我将解释结构化指令是如何工作的,以及如何用它们来改变 HTML 文档的布局或结构。

十六、创建结构化指令

结构指令通过添加和删除元素来改变 HTML 文档的布局。它们建立在第十五章中描述的可用于属性指令的核心特性之上,并附加了对微模板的支持,微模板是组件使用的模板中定义的内容的小片段。您可以识别出什么时候使用了结构化指令,因为它的名称前面会有一个星号,比如*ngIf*ngFor。在这一章中,我将解释如何定义和应用结构化指令,它们如何工作,以及它们如何响应数据模型中的变化。表 16-1 将结构指令放在上下文中。

表 16-1。

将结构指令放在上下文中

|

问题

|

回答

| | --- | --- | | 它们是什么? | 结构化指令使用微型模板向 HTML 文档添加内容。 | | 它们为什么有用? | 结构化指令允许根据表达式的结果有条件地添加内容,或者为数据源(如数组)中的每个对象重复相同的内容。 | | 它们是如何使用的? | 结构化指令应用于一个ng-template元素,该元素包含构成其微模板的内容和绑定。template 类使用 Angular 提供的对象来控制内容的包含或重复内容。 | | 有什么陷阱或限制吗? | 除非小心处理,否则结构化指令会对 HTML 文档进行大量不必要的修改,这会破坏 web 应用的性能。正如本章后面的“处理集合级数据更改”一节中所解释的,仅在需要时进行更改是很重要的。 | | 还有其他选择吗? | 您可以将内置指令用于常见任务,但是编写定制的结构化指令可以为您的应用定制行为。 |

表 16-2 总结了本章内容。

表 16-2。

章节总结

|

问题

|

解决办法

|

列表

| | --- | --- | --- | | 创建结构指令 | 将@Directive装饰器应用于接收视图容器和模板构造器参数的类 | 1–6 | | 创建迭代结构指令 | 在结构指令类中定义一个ForOf输入属性,并迭代它的值 | 7–12 | | 在结构化指令中处理数据更改 | 使用差异检测ngDoCheck方法中的变化 | 13–19 | | 查询已应用结构指令的宿主元素的内容 | 使用@ContentChild@ContentChildren装饰器 | 20–26 |

准备示例项目

在这一章中,我继续使用我在第十一章中创建的示例项目,并且一直使用至今。为了准备本章,我简化了模板,去掉了表单,只留下了表格,如清单 16-1 所示。(我将在本章的后面添加表单。)

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

<div class="m-2">
  <table class="table table-sm table-bordered table-striped">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
    <tbody class="text-white">
      <tr *ngFor="let item of getProducts(); let i = index"
          [pa-attr]="getProducts().length < 6 ? 'bg-success' : 'bg-warning'"
          [pa-product]="item" (pa-category)="newProduct.category=$event">
        <td>{{i + 1}}</td>
        <td>{{item.name}}</td>
        <td [pa-attr]="item.category == 'Soccer' ? 'bg-info' : null">
          {{item.category}}
        </td>
        <td [pa-attr]="'bg-info'">{{item.price}}</td>
      </tr>
    </tbody>
  </table>
</div>

Listing 16-1.Simplifying the Template in the template.html File in the src/app Folder

example文件夹中运行以下命令,启动开发工具:

ng serve

打开一个新的浏览器窗口并导航到http://localhost:4200以查看如图 16-1 所示的内容。

img/421542_4_En_16_Fig1_HTML.jpg

图 16-1。

运行示例应用

创建简单的结构指令

从结构化指令开始的一个好地方是重新创建由ngIf指令提供的功能,这相对简单,易于理解,并且为解释结构化指令如何工作提供了一个良好的基础。我首先对模板进行修改,然后反向编写支持它的代码。清单 16-2 显示了模板的变化。

<div class="m-2">

    <div class="checkbox">
        <label>
            <input type="checkbox" [(ngModel)]="showTable" />
            Show Table
        </label>
    </div>

    <ng-template [paIf]="showTable">
        <table class="table table-sm table-bordered table-striped">
            <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
            <tr *ngFor="let item of getProducts(); let i = index"
                [pa-attr]="getProducts().length < 6 ? 'bg-success' : 'bg-warning'"
                [pa-product]="item" (pa-category)="newProduct.category=$event">
                <td>{{i + 1}}</td>
                <td>{{item.name}}</td>
                <td [pa-attr]="item.category == 'Soccer' ? 'bg-info' : null">
                    {{item.category}}
                </td>
                <td [pa-attr]="'bg-info'">{{item.price}}</td>
            </tr>
        </table>
    </ng-template>
</div>

Listing 16-2.Applying a Structural Directive in the template.html File in the src/app Folder

这个清单使用完整的模板语法,其中指令应用于一个ng-template元素,该元素包含指令将使用的内容。在这种情况下,ng-template元素包含了table元素及其所有内容,包括绑定、指令和表达式。(还有一个简洁的语法,我在本章后面会用到。)

ng-template元素有一个标准的单向数据绑定,目标是一个名为paIf的指令,如下所示:

...
<ng-template [paIf]="showTable">
...

这个绑定的表达式使用了一个名为showTable的属性值。这与模板中另一个新绑定中使用的属性相同,该属性已应用于复选框,如下所示:

...
<input type="checkbox" checked="true" [(ngModel)]="showTable" />
...

本节的目标是创建一个结构指令,当showTable属性为true时,它将把ng-template元素的内容添加到 HTML 文档中,这将在复选框被选中时发生,当showTable属性为false时,它将删除ng-template元素的内容,这将在复选框未被选中时发生。清单 16-3 向组件添加了showTable属性。

import { ApplicationRef, Component } from "@angular/core";
import { NgForm, FormGroup } from "@angular/forms";
import { Model } from "./repository.model";
import { Product } from "./product.model";
import { ProductFormGroup, ProductFormControl } from "./form.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();
    formGroup: ProductFormGroup = new ProductFormGroup();
    showTable: boolean = false;

    getProduct(key: number): Product {
        return this.model.getProduct(key);
    }

    getProducts(): Product[] {
        return this.model.getProducts();
    }

    newProduct: Product = new Product();

    addProduct(p: Product) {
        this.model.saveProduct(p);
    }

    formSubmitted: boolean = false;

    submitForm() {
        this.addProduct(this.newProduct);
    }
}

Listing 16-3.Adding a Property in the component.ts File in the src/app Folder

实现结构指令类

从模板中可以知道指令应该做什么。为了实现这个指令,我在src/app文件夹中添加了一个名为structure.directive.ts的文件,并添加了清单 16-4 中所示的代码。

import {
    Directive, SimpleChange, ViewContainerRef, TemplateRef, Input
} from "@angular/core";

@Directive({
    selector: "[paIf]"
})
export class PaStructureDirective {

    constructor(private container: ViewContainerRef,
        private template: TemplateRef<Object>) { }

    @Input("paIf")
    expressionResult: boolean;

    ngOnChanges(changes: { [property: string]: SimpleChange }) {
        let change = changes["expressionResult"];
        if (!change.isFirstChange() && !change.currentValue) {
            this.container.clear();
        } else if (change.currentValue) {
            this.container.createEmbeddedView(this.template);
        }
    }
}

Listing 16-4.The Contents of the structure.directive.ts File in the src/app Folder

@Directive装饰器的selector属性用于匹配具有paIf属性的主机元素;这对应于我在清单 16-1 中添加的模板。

有一个名为expressionResult的输入属性,指令使用它从模板接收表达式的结果。该指令实现了ngOnChanges方法来接收变更通知,因此它可以响应数据模型中的变更。

这是一个结构化指令的第一个迹象来自构造函数,它要求 Angular 使用一些新类型提供参数。

...
constructor(private container: ViewContainerRef,
    private template: TemplateRef<Object>) {}
...

ViewContainerRef对象用于管理视图容器的内容,它是 HTML 文档中出现ng-template元素的部分,也是指令负责的部分。

顾名思义,视图容器负责管理一组视图。视图是 HTML 元素的一个区域,包含指令、绑定和表达式,它们是使用ViewContainerRef类提供的方法和属性创建和管理的,其中最有用的在表 16-3 中描述。

表 16-3。

有用的 ViewContainerRef 方法和属性

|

名字

|

描述

| | --- | --- | | element | 该属性返回一个代表容器元素的ElementRef对象。 | | createEmbeddedView(template) | 此方法使用模板来创建新视图。详情见表后文字。该方法还接受上下文数据的可选参数(如“创建迭代结构指令”一节所述)和一个指定视图插入位置的索引位置。结果是一个ViewRef对象,可以与该表中的其他方法一起使用。 | | clear() | 该方法从容器中移除所有视图。 | | length | 该属性返回容器中视图的数量。 | | get(index) | 该方法返回代表指定索引处视图的ViewRef对象。 | | indexOf(view) | 该方法返回指定的ViewRef对象的索引。 | | insert(view, index) | 此方法在指定索引处插入一个视图。 | | remove(Index) | 此方法移除并销毁指定索引处的视图。 | | detach(index) | 该方法将视图从指定的索引中分离出来,而不破坏它,这样就可以用insert方法重新定位它。 |

需要表 16-3 中的两个方法来重新创建ngIf指令的功能:createEmbeddedView向用户显示ng-template元素的内容,clear再次删除它。

createEmbeddedView方法将视图添加到视图容器中。这个方法的参数是一个TemplateRef对象,它代表了ng-template元素的内容。

该指令接收TemplateRef对象作为其构造函数参数之一,Angular 将在创建该指令类的新实例时自动为其提供一个值。

综上所述,当 Angular 处理template.html文件时,它发现了ng-template元素及其绑定,并确定它需要创建一个PaStructureDirective类的新实例。Angular 检查了PaStructureDirective构造函数,可以看到它需要为其提供ViewContainerRefTemplateRef对象。

...
constructor(private container: ViewContainerRef,
    private template: TemplateRef<Object>) {}
...

ViewContainerRef对象表示 HTML 文档中被ng-template元素占据的位置,而TemplateRef对象表示ng-template元素的内容。Angular 将这些对象传递给构造函数,并创建指令类的新实例。

Angular 然后开始处理表达式和数据绑定。如第十五章所述,Angular 在初始化期间(就在ngOnInit方法被调用之前)调用ngOnChanges方法,并且每当指令表达式的值改变时再次调用。

PaStructureDirective类对ngOnChanges方法的实现使用接收到的SimpleChange对象,根据表达式的当前值显示或隐藏ng-template元素的内容。当表达式为true时,指令通过将ng-template元素的内容添加到容器视图中来显示它们。

...
this.container.createEmbeddedView(this.template);
...

当表达式的结果是false时,该指令清除视图容器,这将从 HTML 文档中移除元素。

...
this.container.clear();
...

该指令不了解ng-template元素的内容,只负责管理它的可见性。

启用结构指令

该指令必须在 Angular 模块中启用才能使用,如清单 16-5 所示。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule  } from "@angular/forms";
import { PaAttrDirective } from "./attr.directive";
import { PaModel } from "./twoway.directive";
import { PaStructureDirective } from "./structure.directive";

@NgModule({
    imports: [BrowserModule, FormsModule, ReactiveFormsModule],
    declarations: [ProductComponent, PaAttrDirective, PaModel, PaStructureDirective],
    bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 16-5.Enabling the Directive in the app.module.ts File in the src/app Folder

结构指令以与属性指令相同的方式启用,并在模块的declarations数组中指定。

一旦你保存了修改,浏览器会重新加载 HTML 文档,你可以看到新指令的效果:table元素,也就是ng-template元素的内容,只有在复选框被选中时才会显示,如图 16-2 所示。(如果您在选中该框时没有看到更改或表格没有显示,请重新启动 Angular development tools,然后重新加载浏览器窗口。)

img/421542_4_En_16_Fig2_HTML.jpg

图 16-2。

创建结构指令

Note

元素的内容正在被销毁和重新创建,而不是简单的隐藏和显示。如果您想显示或隐藏内容而不从 HTML 文档中删除它,那么您可以使用样式绑定来设置displayvisibility属性。

使用简明结构指令语法

元素的使用有助于说明视图容器在结构化指令中的作用。简洁的语法去掉了ng-template元素,并将指令及其表达式应用于它所包含的最外层元素,如清单 16-6 所示。

Tip

简明结构指令语法旨在更易于使用和阅读,但这只是您使用哪种语法的偏好问题。

<div class="m-2">
  <div class="checkbox">
    <label>
      <input type="checkbox" [(ngModel)]="showTable" />
      Show Table
    </label>
  </div>

  <table *paIf="showTable"
         class="table table-sm table-bordered table-striped">
    <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
    <tbody class="text-white">
      <tr *ngFor="let item of getProducts(); let i = index"
          [pa-attr]="getProducts().length < 6 ? 'bg-success' : 'bg-warning'"
          [pa-product]="item" (pa-category)="newProduct.category=$event">
        <td>{{i + 1}}</td>
        <td>{{item.name}}</td>
        <td [pa-attr]="item.category == 'Soccer' ? 'bg-info' : null">
          {{item.category}}
        </td>
        <td [pa-attr]="'bg-info'">{{item.price}}</td>
      </tr>
    </tbody>
  </table>
</div>

Listing 16-6.Using the Concise Structural Directive Syntax in the template.html File in the src/app Folder

ng-template元素已被移除,该指令已被应用于table元素,如下所示:

...
<table *paIf="showTable" class="table table-sm table-bordered table-striped">
...

该指令的名称以星号(*字符)为前缀,告诉 Angular 这是一个使用简明语法的结构化指令。当 Angular 解析template.html文件时,它会发现指令和星号,并处理这些元素,就像文档中有一个ng-template元素一样。不需要对指令类进行任何更改来支持简洁的语法。

创建迭代结构指令

Angular 为需要迭代数据源的指令提供了特殊的支持。演示这一点的最佳方式是重新创建另一个内置指令:ngFor

为了准备新的指令,我已经从template.html文件中移除了ngFor指令,插入了一个ng-template元素,并应用了一个新的指令属性和表达式,如清单 16-7 所示。

<div class="m-2">
    <div class="checkbox">
      <label>
        <input type="checkbox" [(ngModel)]="showTable" />
        Show Table
      </label>
    </div>

    <table *paIf="showTable"
            class="table table-sm table-bordered table-striped">
        <thead>
            <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
        </thead>
        <tbody>
            <ng-template [paForOf]="getProducts()" let-item>
                <tr><td colspan="4">{{item.name}}</td></tr>
            </ng-template>
        </tbody>
    </table>
</div>

Listing 16-7.Preparing for a New Structural Directive in the template.html File in the src/app Folder

迭代结构指令的完整语法有点奇怪。在清单中,ng-template元素有两个用于应用指令的属性。第一个是标准绑定,其表达式获得指令所需的数据,绑定到名为paForOf的属性。

...
<ng-template [paForOf]="getProducts()" let-item>
...

这个属性的名称很重要。当使用一个ng-template元素时,数据源属性的名称必须以Of结尾,以支持简洁的语法,我将很快介绍这一点。

第二个属性用于定义隐式值,当指令遍历数据源时,它允许在ng-template元素中引用当前处理的对象。与其他模板变量不同,隐式变量没有赋值,它的目的只是定义变量名。

...
<ng-template [paForOf]="getProducts()" let-item>
...

在这个例子中,我使用了let-item来告诉 Angular,我希望将隐式值赋给一个名为item的变量,然后在字符串插值绑定中使用该变量来显示当前数据项的name属性。

...
<td colspan="4">{{item.name}}</td>
...

查看ng-template元素,您可以看到新指令的目的是遍历组件的getProducts方法,并为每个方法生成一个显示name属性的表行。为了实现这个功能,我在src/app文件夹中创建了一个名为iterator.directive.ts的文件,并定义了清单 16-8 中所示的指令。

import { Directive, ViewContainerRef, TemplateRef,
             Input, SimpleChange } from "@angular/core";

@Directive({
    selector: "[paForOf]"
})
export class PaIteratorDirective {

    constructor(private container: ViewContainerRef,
        private template: TemplateRef<Object>) {}

    @Input("paForOf")
    dataSource: any;

    ngOnInit() {
        this.container.clear();
        for (let i = 0; i < this.dataSource.length; i++) {
            this.container.createEmbeddedView(this.template,
                 new PaIteratorContext(this.dataSource[i]));
        }
    }
}

class PaIteratorContext {
    constructor(public $implicit: any) {}
}

Listing 16-8.The Contents of the iterator.directive.ts File in the src/app Folder

@Directive装饰器中的selector属性匹配具有paForOf属性的元素,该属性也是dataSource输入属性的数据源,并提供将被迭代的对象的源。

一旦设置了输入属性的值,就会调用ngOnInit方法,该指令使用clear方法清空视图容器,并使用createEmbeddedView方法为每个对象添加一个新视图。

当调用createEmbeddedView方法时,该指令提供两个参数:通过构造函数接收的TemplateRef对象和一个上下文对象。TemplateRef对象提供要插入到容器中的内容,上下文对象提供隐式值的数据,隐式值是使用名为$implicit的属性指定的。这个对象及其$implicit属性被分配给item模板变量,并在字符串插值绑定中被引用。为了以类型安全的方式为模板提供上下文对象,我定义了一个名为PaIteratorContext的类,它唯一的属性名为$implicit

ngOnInit方法揭示了使用视图容器的一些重要方面。首先,视图容器可以由多个视图填充,在本例中,数据源中的每个对象有一个视图。ViewContainerRef类提供了管理这些创建好的视图所需的功能,您将在接下来的章节中看到。

第二,一个模板可以被重用来创建多个视图。在这个例子中,ng-template元素的内容将用于为数据源中的每个对象创建相同的trtd元素。td元素包含一个数据绑定,在创建每个视图时由 Angular 处理,并用于根据其数据对象定制内容。

第三,指令不了解它所处理的数据,也不了解正在生成的内容。Angular 负责为指令提供它需要的来自应用其余部分的上下文,通过输入属性提供数据源,通过TemplateRef对象为每个视图提供内容。

启用该指令需要添加一个 Angular 模块,如清单 16-9 所示。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule  } from "@angular/forms";
import { PaAttrDirective } from "./attr.directive";
import { PaModel } from "./twoway.directive";
import { PaStructureDirective } from "./structure.directive";
import { PaIteratorDirective } from "./iterator.directive";

@NgModule({
    imports: [BrowserModule, FormsModule, ReactiveFormsModule],
    declarations: [ProductComponent, PaAttrDirective, PaModel,
        PaStructureDirective, PaIteratorDirective],
    bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 16-9.Adding a Custom Directive in the app.module.ts File in the src/app Folder

结果是该指令遍历其数据源中的对象,并使用ng-template元素的内容为每个对象创建一个视图,为表格提供行,如图 16-3 所示。您需要选中该框来显示该表。(如果您没有看到更改,请启动 Angular 开发工具并重新加载浏览器窗口。)

img/421542_4_En_16_Fig3_HTML.jpg

图 16-3。

创建迭代结构指令

提供额外的上下文数据

结构化指令可以为模板提供额外的值,这些值将被分配给模板变量并在绑定中使用。例如,ngFor指令提供了oddevenfirstlast值。上下文值是通过定义$implicit属性的同一个对象提供的,在清单 16-10 中,我重新创建了与ngFor提供的值相同的一组值。

import { Directive, ViewContainerRef, TemplateRef,
             Input, SimpleChange } from "@angular/core";

@Directive({
    selector: "[paForOf]"
})
export class PaIteratorDirective {

    constructor(private container: ViewContainerRef,
        private template: TemplateRef<Object>) {}

    @Input("paForOf")
    dataSource: any;

    ngOnInit() {
        this.container.clear();
        for (let i = 0; i < this.dataSource.length; i++) {
            this.container.createEmbeddedView(this.template,
                 new PaIteratorContext(this.dataSource[i],
                     i, this.dataSource.length));
        }
    }
}

class PaIteratorContext {
    odd: boolean; even: boolean;
    first: boolean; last: boolean;

    constructor(public $implicit: any,
            public index: number, total: number ) {

        this.odd = index % 2 == 1;
        this.even = !this.odd;
        this.first = index == 0;
        this.last = index == total - 1;
    }
}

Listing 16-10.Providing Context Data in the iterator.directive.ts File in the src/app Folder

这个清单在PaIteratorContext类中定义了额外的属性,并扩展了它的构造函数,以便它接收额外的参数,这些参数用于设置属性值。

这些增加的效果是,上下文对象属性可以用来创建模板变量,然后可以在绑定表达式中引用这些变量,如清单 16-11 所示。

<div class="m-2">
    <div class="checkbox">
      <label>
        <input type="checkbox" [(ngModel)]="showTable" />
        Show Table
      </label>
    </div>

    <table *paIf="showTable"
            class="table table-sm table-bordered table-striped">
        <thead>
            <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
        </thead>
        <tbody>
            <ng-template [paForOf]="getProducts()" let-item let-i="index"
                    let-odd="odd" let-even="even">
                <tr [class.bg-info]="odd" [class.bg-warning]="even">
                    <td>{{i + 1}}</td>
                    <td>{{item.name}}</td>
                    <td>{{item.category}}</td>
                    <td>{{item.price}}</td>
                </tr>
            </ng-template>
        </tbody>
    </table>
</div>

Listing 16-11.Using Structural Directive Context Data in the template.html File in the src/app Folder

模板变量是使用let-<name>属性语法创建的,并被赋予一个上下文数据值。在这个清单中,我使用了oddeven上下文值来创建同名的模板变量,然后将它们合并到tr元素上的类绑定中,从而得到条带化的表格行,如图 16-4 所示。该清单还添加了表格单元格来显示所有的Product属性。

img/421542_4_En_16_Fig4_HTML.jpg

图 16-4。

使用指令上下文数据

使用简明结构语法

迭代结构指令支持简洁的语法并省略了ng-template元素,如清单 16-12 所示。

<div class="m-2">
    <div class="checkbox">
      <label>
        <input type="checkbox" [(ngModel)]="showTable" />
        Show Table
      </label>
    </div>

    <table *paIf="showTable"
            class="table table-sm table-bordered table-striped">
        <thead>
            <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
        </thead>
        <tbody>
            <tr *paFor="let item of getProducts(); let i = index; let odd = odd;
                    let even = even" [class.bg-info]="odd" [class.bg-warning]="even">
                <td>{{i + 1}}</td>
                <td>{{item.name}}</td>
                <td>{{item.category}}</td>
                <td>{{item.price}}</td>
            </tr>
        </tbody>
    </table>
</div>

Listing 16-12.Using the Concise Syntax in the template.html File in the src/app Folder

这是一个比属性指令所要求的更大的变化。最大的变化是用于应用指令的属性。当使用完整语法时,使用选择器指定的属性将指令应用于ng-template元素,如下所示:

...
<ng-template [paForOf]="getProducts()" let-item let-i="index" let-odd="odd"
    let-even="even">
...

使用简明语法时,属性的Of部分被省略,名称以星号为前缀,括号被省略。

...
<tr *paFor="let item of getProducts(); let i = index; let odd = odd;
            let even = even" [class.bg-info]="odd" [class.bg-warning]="even">
...

另一个变化是将所有的上下文值合并到指令的表达式中,替换单个的let-属性。主数据值成为初始表达式的一部分,附加的上下文值用分号分隔。

不需要对指令进行任何修改来支持简明语法,它的选择器和输入属性仍然指定一个名为paForOf的属性。Angular 负责扩展简洁的语法,该指令不知道也不关心是否使用了一个ng-template元素。

处理属性级数据更改

迭代结构指令所使用的数据源可能会发生两种变化。第一种情况发生在单个对象的属性改变时。这会对包含在ng-template元素中的数据绑定产生连锁效应,或者直接通过隐式值的变化,或者间接地通过指令提供的额外上下文值。Angular 自动处理这些变化,在依赖它们的绑定中反映上下文数据的任何变化。

为了演示,在清单 16-13 中,我在 context 类的构造函数中添加了一个对标准 JavaScript setInterval函数的调用。传递给setInterval的函数改变了oddeven属性,并改变了用作隐式值的Product对象的price属性的值。

...
class PaIteratorContext {
    odd: boolean; even: boolean;
    first: boolean; last: boolean;

    constructor(public $implicit: any,
            public index: number, total: number ) {

        this.odd = index % 2 == 1;
        this.even = !this.odd;
        this.first = index == 0;
        this.last = index == total - 1;

        setInterval(() => {
            this.odd = !this.odd; this.even = !this.even;
            this.$implicit.price++;
        }, 2000);
    }
}
...

Listing 16-13.Modifying Individual Objects in the iterator.directive.ts File in the src/app Folder

每两秒钟,oddeven属性的值就会反转一次,并且price的值会递增。保存更改后,您会看到表格行的颜色发生变化,价格缓慢上升,如图 16-5 所示。

img/421542_4_En_16_Fig5_HTML.jpg

图 16-5。

针对单个数据源对象的自动更改检测

处理集合级别的数据更改

第二种类型的更改发生在添加、移除或替换集合中的对象时。Angular 不会自动检测这种变化,这意味着迭代指令的ngOnChanges方法不会被调用。

接收关于集合级更改的通知是通过实现ngDoCheck方法来完成的,只要在应用中检测到数据更改,就会调用该方法,而不管更改发生在哪里或者是哪种类型的更改。ngDoCheck方法允许一个指令响应变化,即使它们没有被 Angular 自动检测到。然而,实现ngDoCheck方法需要谨慎,因为它代表了一个会破坏 web 应用性能的陷阱。为了演示这个问题,清单 16-14 实现了ngDoCheck方法,以便当有变化时,指令更新它显示的内容。

import { Directive, ViewContainerRef, TemplateRef,
             Input, SimpleChange } from "@angular/core";

@Directive({
    selector: "[paForOf]"
})
export class PaIteratorDirective {

    constructor(private container: ViewContainerRef,
        private template: TemplateRef<Object>) {}

    @Input("paForOf")
    dataSource: any;

    ngOnInit() {
        this.updateContent();
    }

    ngDoCheck() {
        console.log("ngDoCheck Called");
        this.updateContent();
    }

    private updateContent() {
        this.container.clear();
        for (let i = 0; i < this.dataSource.length; i++) {
            this.container.createEmbeddedView(this.template,
                 new PaIteratorContext(this.dataSource[i],
                     i, this.dataSource.length));
        }
    }
}

class PaIteratorContext {
    odd: boolean; even: boolean;
    first: boolean; last: boolean;

    constructor(public $implicit: any,
            public index: number, total: number ) {

        this.odd = index % 2 == 1;
        this.even = !this.odd;
        this.first = index == 0;
        this.last = index == total - 1;

        // setInterval(() => {
        //     this.odd = !this.odd; this.even = !this.even;
        //     this.$implicit.price++;
        // }, 2000);
    }
}

Listing 16-14.Implementing the ngDoCheck Methods in the iterator.directive.ts File in the src/app Folder

ngOnInitngDoCheck方法都调用一个新的updateContent方法,该方法清除视图容器的内容并为数据源中的每个对象生成新的模板内容。我还注释掉了对PaIteratorContext类中setInterval函数的调用。

为了理解集合级更改和ngDoCheck方法的问题,我需要将表单恢复到组件的模板,如清单 16-15 所示。

<div class="row m-2">
    <div class="col-4">
        <form class="m-2" novalidate (ngSubmit)="submitForm()">
            <div class="form-group">
                <label>Name</label>
                <input class="form-control" name="name"
                    [(ngModel)]="newProduct.name" />
            </div>
            <div class="form-group">
                <label>Category</label>
                <input class="form-control" name="category"
                    [(ngModel)]="newProduct.category" />
            </div>
            <div class="form-group">
                <label>Price</label>
                <input class="form-control" name="price"
                    [(ngModel)]="newProduct.price" />
            </div>
            <button class="btn btn-primary" type="submit">Create</button>
        </form>
    </div>
    <div class="col-8">
        <div class="checkbox">
        <label>
            <input type="checkbox" [(ngModel)]="showTable" />
            Show Table
        </label>
        </div>

        <table *paIf="showTable"
                class="table table-sm table-bordered table-striped">
            <thead>
                <tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
            </thead>
            <tbody>
                <tr *paFor="let item of getProducts(); let i = index; let odd = odd;
                        let even = even" [class.bg-info]="odd"
                        [class.bg-warning]="even">
                    <td>{{i + 1}}</td>
                    <td>{{item.name}}</td>
                    <td>{{item.category}}</td>
                    <td>{{item.price}}</td>
                </tr>
            </tbody>
        </table>
    </div>
</div>

Listing 16-15.Restoring the HTML Form in the template.html File in the src/app Folder

当您保存对模板的更改时,HTML 表单将显示在产品表的旁边,如图 16-6 所示。(您必须选中该框才能显示该表。)

img/421542_4_En_16_Fig6_HTML.jpg

图 16-6。

恢复模板中的表

ngDoCheck方法的问题在于,每当 Angular 检测到应用中的任何地方发生变化时,它就会被调用——而这些变化发生的频率比您预期的要高。

为了演示变化发生的频率,我在清单 16-14 中的指令的ngDoCheck方法中添加了对console.log方法的调用,这样每次调用ngDoCheck方法时,浏览器的 JavaScript 控制台都会显示一条消息。使用 HTML 表单创建一个新产品,并查看有多少条消息被写到浏览器的 JavaScript 控制台,每条消息代表 Angular 检测到的一个变化,并导致对ngDoCheck方法的调用。

每当 input 元素获得焦点、每次触发按键事件、每次执行验证检查等等时,都会显示一条新消息。一个快速测试是在 Running 类别中添加一个价格为 100 美元的跑鞋产品,在我的系统上生成了 27 条消息,尽管确切的数量会根据您在元素之间导航的方式、您是否需要纠正输入错误等等而有所不同。

对于这 27 次中的每一次,结构指令销毁并重新创建其内容,这意味着用新的指令和绑定对象产生新的trtd元素。

在示例应用中只有几行数据,但这些都是开销很大的操作,而且真正的应用可能会因为内容被反复破坏和重新创建而陷入停顿。这个问题最糟糕的部分是,除了一个更改之外,所有的更改都是不必要的,因为在新的Product对象被添加到数据模型之前,表中的内容不需要更新。对于所有其他更改,该指令销毁了其内容并创建了一个完全相同的替换。

幸运的是,Angular 提供了一些工具来更有效地管理更新,并只在需要时更新内容。对于应用中的所有更改,仍将调用ngDoCheck方法,但是指令可以检查其数据,以查看是否发生了需要新内容的任何更改,如清单 16-16 所示。

import { Directive, ViewContainerRef, TemplateRef,
             Input, SimpleChange, IterableDiffer, IterableDiffers,
             ChangeDetectorRef, CollectionChangeRecord, DefaultIterableDiffer
} from "@angular/core";

@Directive({
    selector: "[paForOf]"
})
export class PaIteratorDirective {
    private differ: DefaultIterableDiffer<any>;

    constructor(private container: ViewContainerRef,
        private template: TemplateRef<Object>,
        private differs: IterableDiffers,
        private changeDetector: ChangeDetectorRef) {
    }

    @Input("paForOf")
    dataSource: any;

    ngOnInit() {
        this.differ =
           <DefaultIterableDiffer<any>> this.differs.find(this.dataSource).create();
    }

    ngDoCheck() {
        let changes = this.differ.diff(this.dataSource);
        if (changes != null) {
            console.log("ngDoCheck called, changes detected");
            changes.forEachAddedItem(addition => {
                this.container.createEmbeddedView(this.template,
                     new PaIteratorContext(addition.item,
                         addition.currentIndex, changes.length));
            });
        }
    }
}

class PaIteratorContext {
    odd: boolean; even: boolean;
    first: boolean; last: boolean;

    constructor(public $implicit: any,
            public index: number, total: number ) {

        this.odd = index % 2 == 1;
        this.even = !this.odd;
        this.first = index == 0;
        this.last = index == total - 1;
    }
}

Listing 16-16.Minimizing Content Changes in the iterator.directive.ts File in the src/app Folder

这样做的目的是计算出集合中是否有对象被添加、删除或移动。这意味着每次调用ngDoCheck方法时,该指令都必须做一些工作,以避免在没有集合更改要处理时不必要的和昂贵的 DOM 操作。

该过程从构造函数开始,构造函数接收两个新的参数,当创建 directive 类的新实例时,Angular 将提供这两个参数的值。IterableDiffersChangeDetectorRef对象用于在ngOnInit方法中设置数据源集合的变更检测,如下所示:

...
ngOnInit() {
    this.differ =
        <DefaultIterableDiffer<any>> this.differs.find(this.dataSource).create();
}
...

Angular 包含内置类,称为differents,可以检测不同类型对象的变化。IterableDiffers.find方法接受一个对象并返回一个能够为该对象创建不同类的IterableDifferFactory对象。IterableDifferFactory类定义了一个create方法,该方法返回一个DefaultIterableDiffer对象,该对象将使用构造函数中接收到的ChangeDetectorRef对象执行实际的变更检测。

这个咒语的重要部分是DefaultIterableDiffer对象,它被赋予了一个名为differ的属性,以便在调用ngDoCheck方法时可以使用。

...
ngDoCheck() {
    let changes = this.differ.diff(this.dataSource);
    if (changes != null) {
        console.log("ngDoCheck called, changes detected");
        changes.forEachAddedItem(addition => {
            this.container.createEmbeddedView(this.template,
                new PaIteratorContext(addition.item,
                    addition.currentIndex, changes.length));
        });
    }
}
...

DefaultIterableDiffer.diff方法接受一个对象进行比较,并返回一个更改列表,如果没有更改,则返回null。检查null结果可以让指令在应用的其他地方调用ngDoCheck方法进行更改时避免不必要的工作。diff方法返回的对象提供了表 16-4 中描述的属性和方法来处理变更。

表 16-4。

默认可变差异。差异结果方法和属性

|

名字

|

描述

| | --- | --- | | collection | 此属性返回已检查更改的对象集合。 | | length | 此属性返回集合中对象的数量。 | | forEachItem(func) | 此方法为集合中的每个对象调用指定的函数。 | | forEachPreviousItem(func) | 此方法为集合的早期版本中的每个对象调用指定的函数。 | | forEachAddedItem(func) | 此方法为集合中的每个新对象调用指定的函数。 | | forEachMovedItem(func) | 这个方法为每个位置已经改变的对象调用指定的函数。 | | forEachRemovedItem(func) | 此方法为从集合中移除的每个对象调用指定的函数。 | | forEachIdentityChange(func) | 此方法为每个标识已更改的对象调用指定的函数。 |

传递给表 16-4 中描述的方法的函数将接收一个CollectionChangeRecord对象,该对象使用表 16-5 中显示的属性描述一个项目以及它是如何改变的。

表 16-5。

CollectionChangeRecord 属性

|

名字

|

描述

| | --- | --- | | item | 此属性返回数据项。 | | trackById | 如果使用了trackBy函数,该属性返回标识值。 | | currentIndex | 此属性返回集合中项的当前索引。 | | previousIndex | 此属性返回集合中该项的上一个索引。 |

清单 16-16 中的代码只需要处理数据源中的新对象,因为这是应用的其余部分可以执行的唯一更改。如果diff方法的结果不是null,那么我使用forEachAddedItem方法为每个被检测到的新对象调用一个粗箭头函数。该函数为每个新对象调用一次,并使用表 16-5 中的属性在视图容器中创建新视图。

清单 16-16 中的变化包括一个新的控制台消息,只有当指令检测到数据变化时,该消息才会被写入浏览器的 JavaScript 控制台。如果您重复添加新产品的过程,您将会看到只有在应用首次启动和单击 Create 按钮时才会显示该消息。ngDoCheck方法仍在被调用,指令每次都要检查数据变化,所以仍有不必要的工作在进行。但是这些操作比销毁然后重新创建 HTML 元素要便宜和耗时得多。

跟踪视图

当您处理新数据项的创建时,处理变化检测是简单的。其他操作——比如处理删除或修改——更复杂,需要指令跟踪哪个视图与哪个数据对象相关联。

为了演示,我将添加对从数据模型中删除一个Product对象的支持。首先,清单 16-17 向组件添加了一个方法,使用产品的键来删除产品。这不是一个要求,因为模板可以通过组件的model属性访问存储库,但是当所有数据都以相同的方式访问和使用时,它可以帮助应用更容易理解。

import { ApplicationRef, Component } from "@angular/core";
import { NgForm, FormGroup } from "@angular/forms";
import { Model } from "./repository.model";
import { Product } from "./product.model";
import { ProductFormGroup, ProductFormControl } from "./form.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();
    formGroup: ProductFormGroup = new ProductFormGroup();
    showTable: boolean = false;

    getProduct(key: number): Product {
        return this.model.getProduct(key);
    }

    getProducts(): Product[] {
        return this.model.getProducts();
    }

    newProduct: Product = new Product();

    addProduct(p: Product) {
        this.model.saveProduct(p);
    }

    deleteProduct(key: number) {
        this.model.deleteProduct(key);
    }

    formSubmitted: boolean = false;

    submitForm() {
        this.addProduct(this.newProduct);
    }
}

Listing 16-17.Adding a Delete Method in the component.ts File in the src/app Folder

清单 16-18 更新了模板,使结构化指令生成的内容包含一列button元素,这些元素将删除与包含它的行相关联的数据对象。

...
<table *paIf="showTable"
        class="table table-sm table-bordered table-striped">
    <thead>
        <tr><th></th><th>Name</th><th>Category</th><th>Price</th><th></th></tr>
    </thead>
    <tbody>
        <tr *paFor="let item of getProducts(); let i = index; let odd = odd;
                let even = even" [class.bg-info]="odd"
                [class.bg-warning]="even">
            <td style="vertical-align:middle">{{i + 1}}</td>
            <td style="vertical-align:middle">{{item.name}}</td>
            <td style="vertical-align:middle">{{item.category}}</td>
            <td style="vertical-align:middle">{{item.price}}</td>
            <td class="text-center">
                <button class="btn btn-danger btn-sm"
                        (click)="deleteProduct(item.id)">
                    Delete
                </button>
            </td>
        </tr>
    </tbody>
</table>
...

Listing 16-18.Adding a Delete Button in the template.html File in the src/app Folder

button元素有调用组件的deleteProduct方法的click事件绑定。我还在现有的td元素上设置了 CSS 样式属性vertical-align的值,以便表格中的文本与按钮文本对齐。最后一步是处理结构化指令中的数据变化,这样当一个对象从数据源中移除时它就会响应,如清单 16-19 所示。

import {
    Directive, ViewContainerRef, TemplateRef,
    Input, SimpleChange, IterableDiffer, IterableDiffers,
    ChangeDetectorRef, CollectionChangeRecord, DefaultIterableDiffer, ViewRef
} from "@angular/core";

@Directive({
    selector: "[paForOf]"
})
export class PaIteratorDirective {
    private differ: DefaultIterableDiffer<any>;
    private views: Map<any, PaIteratorContext> = new Map<any, PaIteratorContext>();

    constructor(private container: ViewContainerRef,
        private template: TemplateRef<Object>,
        private differs: IterableDiffers,
        private changeDetector: ChangeDetectorRef) {
    }

    @Input("paForOf")
    dataSource: any;

    ngOnInit() {
        this.differ =
            <DefaultIterableDiffer<any>>this.differs.find(this.dataSource).create();
    }

    ngDoCheck() {
        let changes = this.differ.diff(this.dataSource);
        if (changes != null) {
            changes.forEachAddedItem(addition => {
                let context = new PaIteratorContext(addition.item,
                    addition.currentIndex, changes.length);
                context.view = this.container.createEmbeddedView(this.template,
                    context);
                this.views.set(addition.trackById, context);
            });
            let removals = false;
            changes.forEachRemovedItem(removal => {
                removals = true;
                let context = this.views.get(removal.trackById);
                if (context != null) {
                    this.container.remove(this.container.indexOf(context.view));
                    this.views.delete(removal.trackById);
                }
            });
            if (removals) {
                let index = 0;
                this.views.forEach(context =>
                    context.setData(index++, this.views.size));
            }
        }
    }
}

class PaIteratorContext {
    index: number;
    odd: boolean; even: boolean;
    first: boolean; last: boolean;
    view: ViewRef;

    constructor(public $implicit: any,
            public position: number, total: number ) {
        this.setData(position, total);
    }

    setData(index: number, total: number) {
        this.index = index;
        this.odd = index % 2 == 1;
        this.even = !this.odd;
        this.first = index == 0;
        this.last = index == total - 1;
    }
}

Listing 16-19.Responding to a Removed Item in the iterator.directive.ts File in the src/app Folder

处理移除的对象需要两个任务。第一项任务是通过删除与由forEachRemovedItem方法提供的条目相对应的视图来更新视图集。这意味着跟踪数据对象和表示它们的视图之间的映射,我通过向PaIteratorContext类添加一个ViewRef属性并使用一个Map来收集它们,通过CollectionChangeRecord.trackById属性的值进行索引。

当处理集合更改时,该指令通过从Map中检索相应的PaIteratorContext对象、获取其ViewRef对象并将其传递给ViewContainerRef.remove元素来处理每个被移除的对象,以从视图容器中移除与该对象相关联的内容。

第二个任务是更新那些保留的对象的上下文数据,以便依赖于视图在视图容器中的位置的绑定被正确地更新。该指令为留在Map中的每个上下文对象调用PaIteratorContext.setData方法,以更新视图在容器中的位置,并更新正在使用的视图总数。如果没有这些改变,由上下文对象提供的属性将不能准确地反映数据模型,这意味着行的背景颜色将不会有条纹,删除按钮将不会指向正确的对象。

这些更改的效果是每个表格行都包含一个删除按钮,该按钮从数据模型中删除相应的对象,从而触发表格的更新,如图 16-7 所示。

img/421542_4_En_16_Fig7_HTML.jpg

图 16-7。

从数据模型中删除对象

查询主体元素内容

指令可以查询它们的主机元素的内容来访问它包含的指令,称为内容子元素,这允许指令协调它们自己一起工作。

Tip

指令也可以通过共享服务一起工作,我在第十九章对此进行了描述。

为了演示如何查询内容,我在src/app文件夹中添加了一个名为cellColor.directive.ts的文件,并用它来定义清单 16-20 中所示的指令。

import { Directive, HostBinding } from "@angular/core";

@Directive({
    selector: "td"
})
export class PaCellColor {

    @HostBinding("class")
    bgClass: string = "";

    setColor(dark: Boolean) {
        this.bgClass = dark ? "bg-dark" : "";
    }
}

Listing 16-20.The Contents of the cellColor.directive.ts File in the src/app Folder

PaCellColor类定义了一个简单的属性指令,它对td元素进行操作,并绑定到主机元素的class属性。setColor方法接受一个布尔参数,当值为true时,将class属性设置为bg-dark,这是深色背景的引导类。

在本例中,PaCellColor类将是嵌入到主机元素内容中的指令。目标是编写另一个指令,该指令将查询其主机元素以定位嵌入的指令并调用其setColor方法。为此,我在src/app文件夹中添加了一个名为cellColorSwitcher.directive.ts的文件,并用它来定义清单 16-21 中所示的指令。

import { Directive, Input, Output, EventEmitter,
         SimpleChange, ContentChild } from "@angular/core";
import { PaCellColor } from "./cellColor.directive";

@Directive({
    selector: "table"
})
export class PaCellColorSwitcher {

    @Input("paCellDarkColor")
    modelProperty: Boolean;

    @ContentChild(PaCellColor)
    contentChild: PaCellColor;

    ngOnChanges(changes: { [property: string]: SimpleChange }) {
        if (this.contentChild != null) {
            this.contentChild.setColor(changes["modelProperty"].currentValue);
        }
    }
}

Listing 16-21.The Contents of the cellColorSwitcher.directive.ts File in the src/app Folder

PaCellColorSwitcher类定义了一个对table元素进行操作的指令,并定义了一个名为paCellDarkColor的输入属性。这个指令的重要部分是contentChild属性。

...
@ContentChild(PaCellColor)
contentChild: PaCellColor;
...

@ContentChild decorator 告诉 Angular,该指令需要查询主机元素的内容,并将查询的第一个结果赋给该属性。@ContentChild director 的参数是一个或多个指令类。在这种情况下,@ContentChild装饰器的参数是PaCellColor,它告诉 Angular 定位包含在主机元素内容中的第一个PaCellColor对象,并将其分配给被装饰的属性。

Tip

您还可以使用模板变量名进行查询,这样@ContentChild("myVariable")将会找到已经分配给myVariable的第一个指令。

查询结果为PaCellColorSwitcher指令提供了对子组件的访问,并允许它调用setColor方法来响应对输入属性的更改。

Tip

如果您想在结果中包含孩子的后代,那么您可以配置查询,就像这样:@ContentChild(PaCellColor, { descendants: true})

在清单 16-22 中,我修改了模板中的复选框,因此它使用ngModel指令来设置一个绑定到PaCellColorSwitcher指令的输入属性的变量。

...
<div class="col-8">

    <div class="checkbox">
        <label>
            <input type="checkbox" [(ngModel)]="darkColor" />
            Dark Cell Color
        </label>
    </div>

    <table class="table table-sm table-bordered table-striped"
            [paCellDarkColor]="darkColor">
        <thead>
            <tr><th></th><th>Name</th><th>Category</th><th>Price</th><th></th></tr>
        </thead>
        <tbody>
            <tr *paFor="let item of getProducts(); let i = index; let odd = odd;
                    let even = even" [class.bg-info]="odd"
                    [class.bg-warning]="even">
                    <td style="vertical-align:middle">{{i + 1}}</td>
                    <td style="vertical-align:middle">{{item.name}}</td>
                    <td style="vertical-align:middle">{{item.category}}</td>
                    <td style="vertical-align:middle">{{item.price}}</td>
                    <td class="text-center">
                        <button class="btn btn-danger btn-sm"
                                (click)="deleteProduct(item.id)">
                            Delete
                        </button>
                    </td>
            </tr>
        </tbody>
    </table>
</div>
...

Listing 16-22.Applying the Directives in the template.html File in the src/app Folder

清单 16-23 向组件添加了darkColor属性。

import { ApplicationRef, Component } from "@angular/core";
import { NgForm, FormGroup } from "@angular/forms";
import { Model } from "./repository.model";
import { Product } from "./product.model";
import { ProductFormGroup, ProductFormControl } from "./form.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();
    formGroup: ProductFormGroup = new ProductFormGroup();
    showTable: boolean = false;
    darkColor: boolean = false;

    getProduct(key: number): Product {
        return this.model.getProduct(key);
    }

    getProducts(): Product[] {
        return this.model.getProducts();
    }

    newProduct: Product = new Product();

    addProduct(p: Product) {
        this.model.saveProduct(p);
    }

    deleteProduct(key: number) {
        this.model.deleteProduct(key);
    }

    formSubmitted: boolean = false;

    submitForm() {
        this.addProduct(this.newProduct);
    }
}

Listing 16-23.Defining a Property in the component.ts File in the src/app Folder

最后一步是用 Angular 模块的declarations属性注册新指令,如清单 16-24 所示。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ProductComponent } from "./component";
import { FormsModule, ReactiveFormsModule  } from "@angular/forms";
import { PaAttrDirective } from "./attr.directive";
import { PaModel } from "./twoway.directive";
import { PaStructureDirective } from "./structure.directive";
import { PaIteratorDirective } from "./iterator.directive";
import { PaCellColor } from "./cellColor.directive";
import { PaCellColorSwitcher } from "./cellColorSwitcher.directive";

@NgModule({
    imports: [BrowserModule, FormsModule, ReactiveFormsModule],
    declarations: [ProductComponent, PaAttrDirective, PaModel,
        PaStructureDirective, PaIteratorDirective,
        PaCellColor, PaCellColorSwitcher],
    bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 16-24.Registering New Directives in the app.module.ts File in the src/app Folder

保存更改时,您会在表格上方看到一个新的复选框。当您选中该框时,ngModel指令将导致PaCellColorSwitcher指令的输入属性被更新,这将调用使用@ContentChild装饰器找到的PaCellColor指令对象的setColor方法。视觉效果很小,因为只有第一个PaCellColor指令受到影响,它是表格左上角显示数字1的单元格,如图 16-8 所示。(如果没有看到颜色变化,那么重新启动 Angular development tools,重新加载浏览器。)

img/421542_4_En_16_Fig8_HTML.jpg

图 16-8。

对内容子项进行操作

查询多个子内容

@ContentChild decorator 找到匹配参数的第一个指令对象,并将其分配给被修饰的属性。如果你想接收所有匹配参数的指令对象,那么你可以使用@ContentChildren装饰器,如清单 16-25 所示。

import { Directive, Input, Output, EventEmitter,
         SimpleChange, ContentChildren, QueryList } from "@angular/core";
import { PaCellColor } from "./cellColor.directive";

@Directive({
    selector: "table"
})
export class PaCellColorSwitcher {

    @Input("paCellDarkColor")
    modelProperty: Boolean;

    @ContentChildren(PaCellColor, {descendants: true})
    contentChildren: QueryList<PaCellColor>;

    ngOnChanges(changes: { [property: string]: SimpleChange }) {
        this.updateContentChildren(changes["modelProperty"].currentValue);
    }

    private updateContentChildren(dark: Boolean) {
        if (this.contentChildren != null && dark != undefined) {
            this.contentChildren.forEach((child, index) => {
                child.setColor(index % 2 ? dark : !dark);
            });
        }
    }
}

Listing 16-25.Querying Multiple Children in the cellColorSwitcher.directive.ts File in the src/app Folder

当您使用@ContentChildren装饰器时,查询的结果通过QueryList提供,它使用表 16-6 中描述的方法和属性提供对指令对象的访问。descendants配置属性用于选择后代元素,如果没有该值,则只选择直接子元素。

表 16-6。

查询列表成员

|

名字

|

描述

| | --- | --- | | length | 此属性返回匹配的指令对象的数量。 | | first | 此属性返回第一个匹配的指令对象。 | | last | 此属性返回最后匹配的指令对象。 | | map(function) | 这个方法为每个匹配的指令对象调用一个函数来创建一个新的数组,相当于Array.map方法。 | | filter(function) | 该方法为每个匹配的指令对象调用一个函数来创建一个数组,该数组包含函数返回 true 的对象,相当于Array.filter方法。 | | reduce(function) | 这个方法为每个匹配的指令对象调用一个函数来创建一个值,相当于Array.reduce方法。 | | forEach(function) | 这个方法为每个匹配的指令对象调用一个函数,相当于Array.forEach方法。 | | some(function) | 该方法为每个匹配的指令对象调用一个函数,如果函数至少返回一次true,则返回true,相当于Array.some方法。 | | changes | 该属性用于监视更改的结果,如即将到来的“接收查询更改通知”一节中所述。 |

在清单中,该指令通过调用updateContentChildren方法来响应输入属性值的变化,该方法又在QueryList上使用forEach方法,并在每第二个匹配查询的指令上调用setColor方法。图 16-9 显示复选框被选中时的效果。

img/421542_4_En_16_Fig9_HTML.jpg

图 16-9。

操作多个子内容

接收查询更改通知

内容查询的结果是实时的,这意味着它们会自动更新以反映主体元素内容中的添加、更改或删除。当查询结果发生变化时,接收通知需要使用Observable接口,该接口由自动添加到项目中的 Reactive Extensions 包提供。我会在第二十三章更详细地解释Observable对象是如何工作的,但是现在,知道 Angular 在内部使用它们来管理变化就足够了。

在清单 16-26 中,我已经更新了PaCellColorSwitcher指令,这样当QueryList中的子内容集发生变化时,它就会收到通知。

import { Directive, Input, Output, EventEmitter,
         SimpleChange, ContentChildren, QueryList } from "@angular/core";
import { PaCellColor } from "./cellColor.directive";

@Directive({
    selector: "table"
})
export class PaCellColorSwitcher {

    @Input("paCellDarkColor")
    modelProperty: Boolean;

    @ContentChildren(PaCellColor, {descendants: true})
    contentChildren: QueryList<PaCellColor>;

    ngOnChanges(changes: { [property: string]: SimpleChange }) {
        this.updateContentChildren(changes["modelProperty"].currentValue);
    }

    ngAfterContentInit() {
        this.contentChildren.changes.subscribe(() => {
            setTimeout(() => this.updateContentChildren(this.modelProperty), 0);
        });
    }

    private updateContentChildren(dark: Boolean) {
        if (this.contentChildren != null && dark != undefined) {
            this.contentChildren.forEach((child, index) => {
                child.setColor(index % 2 ? dark : !dark);
            });
        }
    }
}

Listing 16-26.Receiving Notifications in the cellColorSwitcher.directive.ts File in the src/app Folder

在调用ngAfterContentInit生命周期方法之前,不会设置内容子查询属性的值,所以我使用这个方法来设置更改通知。QueryList类定义了一个返回反应扩展Observable对象的changes方法,该对象定义了一个subscribe方法。这个方法接受一个函数,当QueryList的内容发生变化时,这个函数被调用,这意味着与@ContentChildren装饰器的参数匹配的指令集合发生了一些变化。我传递给subscribe方法的函数调用updateContentChildren方法来设置颜色,但是它是在对setTimeout函数的调用中完成的,这会延迟方法调用的执行,直到subscribe回调函数完成之后。如果不调用setTimeout,Angular 将会报告一个错误,因为该指令试图在现有内容更新被完全处理之前开始一个新的内容更新。这些变化的结果是深色会自动应用于使用 HTML 表单时创建的新表格单元格,如图 16-10 所示。

img/421542_4_En_16_Fig10_HTML.jpg

图 16-10。

对内容查询更改通知采取行动

摘要

在本章中,我通过重新创建内置的ngIfngFor指令的功能,解释了结构化指令是如何工作的。我解释了视图容器和模板的使用,描述了应用结构化指令的完整而简洁的语法,并向您展示了如何创建一个迭代数据对象集合的指令,以及指令如何查询它们的主机元素的内容。在下一章,我将介绍组件并解释它们与指令的不同。