结构型指令
结构型指令
本章将看看 Angular 如何用结构型指令
操纵 DOM 树,以及你该如何写自己的结构型指令
来完成同样的任务。
什么是结构型指令?
结构型指令的职责是 HTML 布局。 它们塑造或重塑 DOM 的结构,比如添加、移除或维护这些元素。
像其它指令一样,你可以把结构型指令应用到一个宿主元素
上。 然后它就可以对宿主元素
及其子元素做点什么。
结构型指令非常容易识别。 在这个例子中,星号(*)被放在指令的属性名之前。
src/app/app.component.html (ngif)
content_copy<div *ngIf="hero" class="name">{{hero.name}}</div>
没有方括号,没有圆括号,只是把 *ngIf
设置为一个字符串。
在这个例子中,你将学到星号(*)这个简写方法,而这个字符串是一个微语法,而不是通常的模板表达式。 Angular 会解开这个语法糖,变成一个 <ng-template> 标记,包裹着宿主元素及其子元素。 每个结构型指令都可以用这个模板做点不同的事情。
三个常用的内置结构型指令 —— NgIf、NgFor和NgSwitch...。 你在模板语法
一章中学过它,并且在 Angular 文档的例子中到处都在用它。下面是模板中的例子:
src/app/app.component.html (built-in)
content_copy<div *ngIf="hero" class="name">{{hero.name}}</div>
<ul>
<li *ngFor="let hero of heroes">{{hero.name}}</li>
</ul>
<div [ngSwitch]="hero?.emotion">
<app-happy-hero *ngSwitchCase="'happy'" [hero]="hero"></app-happy-hero>
<app-sad-hero *ngSwitchCase="'sad'" [hero]="hero"></app-sad-hero>
<app-confused-hero *ngSwitchCase="'app-confused'" [hero]="hero"></app-confused-hero>
<app-unknown-hero *ngSwitchDefault [hero]="hero"></app-unknown-hero>
</div>
本章不会重复讲如何使用
它们,而是解释它们的工作原理
以及如何写自己的结构型指令。
指令的拼写形式
在本章中,你将看到指令同时具有两种拼写形式大驼峰
UpperCamelCase
和小驼峰
lowerCamelCase,比如你已经看过的
NgIf
和
ngIf。 这里的原因在于,NgIf
引用的是指令的
类名,而
ngIf
引用的是指令的
属性名*。
指令的类名
拼写成大驼峰形式
(NgIf
),而它的属性名
则拼写成小驼峰形式
(ngIf
)。 本章会在谈论指令的属性和工作原理时引用指令的类名
,在描述如何在 HTML 模板中把该指令应用到元素时,引用指令的属性名
。
还有另外两种 Angular 指令,在本开发指南的其它地方有讲解:(1) 组件 (2) 属性型指令。
组件
可以在原生 HTML 元素中管理一小片区域的 HTML。从技术角度说,它就是一个带模板的指令。
属性型
指令会改变某个元素、组件或其它指令的外观或行为。 比如,内置的NgStyle
指令可以同时修改元素的多个样式。
你可以在一个宿主元素上应用多个属性型
指令,但只能应用一个结构型
指令。
NgIf 案例分析
NgIf
是一个很好的结构型指令案例:它接受一个布尔值,并据此让一整块 DOM 树出现或消失。
src/app/app.component.html (ngif-true)
content_copy<p *ngIf="true">
Expression is true and ngIf is true.
This paragraph is in the DOM.
</p>
<p *ngIf="false">
Expression is false and ngIf is false.
This paragraph is not in the DOM.
</p>
ngIf
指令并不是使用 CSS 来隐藏元素的。它会把这些元素从 DOM 中物理删除。 使用浏览器的开发者工具就可以确认这一点。
可以看到第一段文字出现在了 DOM 中,而第二段则没有,在第二段的位置上是一个关于“绑定”的注释(稍后有更多讲解)。
当条件为假时,NgIf
会从 DOM 中移除它的宿主元素,取消它监听过的那些 DOM 事件,从 Angular 变更检测中移除该组件,并销毁它。 这些组件和 DOM 节点可以被当做垃圾收集起来,并且释放它们占用的内存。
为什么移除而不是隐藏?
指令也可以通过把它的 display
风格设置为 none
而隐藏不需要的段落。
src/app/app.component.html (display-none)
content_copy<p [style.display]="'block'">
Expression sets display to "block".
This paragraph is visible.
</p>
<p [style.display]="'none'">
Expression sets display to "none".
This paragraph is hidden but still in the DOM.
</p>
当不可见时,这个元素仍然留在 DOM 中。
对于简单的段落,隐藏和移除之间的差异影响不大,但对于资源占用较多的组件是不一样的。 当隐藏掉一个元素时,组件的行为还在继续 —— 它仍然附加在它所属的 DOM 元素上, 它也仍在监听事件。Angular 会继续检查哪些能影响数据绑定的变更。 组件原本要做的那些事情仍在继续。
虽然不可见,组件及其各级子组件仍然占用着资源,而这些资源如果分配给别人可能会更有用。 在性能和内存方面的负担相当可观,响应度会降低,而用户却可能无法从中受益。
当然,从积极的一面看,重新显示这个元素会非常快。 组件以前的状态被保留着,并随时可以显示。 组件不用重新初始化 —— 该操作可能会比较昂贵。 这时候隐藏和显示就成了正确的选择。
但是,除非有非常强烈的理由来保留它们,否则你会更倾向于移除用户看不见的那些 DOM 元素,并且使用 NgIf
这样的结构型指令来收回用不到的资源。
同样的考量也适用于每一个结构型指令,无论是内置的还是自定义的。
你应该提醒自己慎重考虑添加元素、移除元素以及创建和销毁组件的后果。
星号(*)前缀
你可能注意到了指令名的星号(*)前缀,并且困惑于为什么需要它以及它是做什么的。
这里的 *ngIf
会在 hero
存在时显示英雄的名字。
src/app/app.component.html (asterisk)
content_copy<div *ngIf="hero" class="name">{{hero.name}}</div>
星号是一个用来简化更复杂语法的“语法糖”。 从内部实现来说,Angular 把 *ngIf 属性 翻译成一个 <ng-template> 元素 并用它来包裹宿主元素,代码如下:
src/app/app.component.html (ngif-template)
content_copy<ng-template [ngIf]="hero">
<div class="name">{{hero.name}}</div>
</ng-template>
- *ngIf 指令被移到了 <ng-template> 元素上。在那里它变成了一个属性绑定 [ngIf]。
第一种形态永远不会真的渲染出来。 只有最终产出的结果才会出现在 DOM 中。
Angular 会在真正渲染的时候填充 <ng-template> 的内容,并且把 <ng-template> 替换为一个供诊断用的注释。
NgFor
和NgSwitch...
指令也都遵循同样的模式。
*ngFor 内幕
Angular 会把 *ngFor 用同样的方式把星号()语法的 template属性转换成 <ng-template>元素*。
这里有一个 NgFor
的全特性应用,同时用了这三种写法:
src/app/app.component.html (inside-ngfor)
content_copy<div *ngFor="let hero of heroes; let i=index; let odd=odd; trackBy: trackById" [class.odd]="odd">
{{i}}) {{hero.name}}
</div>
<ng-template ngFor let-hero [ngForOf]="heroes" let-i="index" let-odd="odd" [ngForTrackBy]="trackById">
<div [class.odd]="odd">{{i}}) {{hero.name}}</div>
</ng-template>
它明显比 ngIf
复杂得多,确实如此。 NgFor
指令比本章展示过的 NgIf
具有更多的必选特性和可选特性。 至少 NgFor
会需要一个循环变量(let hero
)和一个列表(heroes
)。
你可以通过把一个字符串赋值给 ngFor
来启用这些特性,这个字符串使用 Angular 的微语法。
ngFor 字符串之外的每一样东西都会留在宿主元素(<div>)上,也就是说它移到了 <ng-template> 内部。 在这个例子中,[ngClass]="odd" 留在了 <div> 上。
微语法
Angular 微语法能让你通过简短的、友好的字符串来配置一个指令。 微语法解析器把这个字符串翻译成 <ng-template> 上的属性:
let
关键字声明一个模板输入变量,你会在模板中引用它。本例子中,这个输入变量就是hero
、i
和odd
。 解析器会把let hero
、let i
和let
odd
翻译成命名变量let-hero
、let-i
和let-odd
。
这些微语法机制在你写自己的结构型指令时也同样有效,参考NgIf
的源码和NgFor
的源码 可以学到更多。
模板输入变量
模板输入变量
是这样一种变量,你可以在单个实例的模板中
引用它的值。 这个例子中有好几个模板输入变量
:hero
、i
和 odd
。 它们都是用 let
作为前导关键字。
模板输入变量
和模板引用变量是不同的
,无论是在语义
上还是语法
上。
你使用 let
关键字(如 let hero
)在模板中声明一个模板输入
变量。 这个变量的范围被限制在所重复模板的单一实例
上。 事实上,你可以在其它结构型指令中使用同样的变量名。
而声明模板引用
变量使用的是给变量名加 #
前缀的方式(#var
)。 一个引用
变量引用
的是它所附着到的元素、组件或指令。它可以在整个模板
的任意位置
访问。
模板输入
变量和引用
变量具有各自独立的命名空间。let hero
中的 hero
和 #hero
中的 hero
并不是同一个变量。
每个宿主元素上只能有一个结构型指令
有时你会希望只有当特定的条件为真时才重复渲染一个 HTML 块。 你可能试过把 *ngFor
和 *ngIf
放在同一个宿主元素上,但 Angular 不允许。这是因为你在一个元素上只能放一个结构型
指令。
原因很简单。结构型指令可能会对宿主元素及其子元素做很复杂的事。当两个指令放在同一个元素上时,谁先谁后?NgIf
优先还是 NgFor
优先?NgIf
可以取消 NgFor
的效果吗? 如果要这样做,Angular 应该如何把这种能力泛化,以取消其它结构型指令的效果呢?
对这些问题,没有办法简单回答。而禁止多个结构型指令则可以简单地解决这个问题。 这种情况下有一个简单的解决方案:把 *ngIf
放在一个"容器"元素上,再包装进 *ngFor
元素。 这个元素可以使用ng-container
,以免引入一个新的 HTML 层级。
NgSwitch 内幕
Angular 的 NgSwitch
实际上是一组相互合作的指令:NgSwitch
、NgSwitchCase
和 NgSwitchDefault
。
例子如下:
src/app/app.component.html (ngswitch)
content_copy<div [ngSwitch]="hero?.emotion">
<app-happy-hero *ngSwitchCase="'happy'" [hero]="hero"></app-happy-hero>
<app-sad-hero *ngSwitchCase="'sad'" [hero]="hero"></app-sad-hero>
<app-confused-hero *ngSwitchCase="'app-confused'" [hero]="hero"></app-confused-hero>
<app-unknown-hero *ngSwitchDefault [hero]="hero"></app-unknown-hero>
</div>
一个值(hero.emotion
)被被赋值给了 NgSwitch
,以决定要显示哪一个分支。
NgSwitch
本身不是结构型指令,而是一个属性型
指令,它控制其它两个 switch 指令的行为。 这也就是为什么你要写成 [ngSwitch]
而不是 *ngSwitch
的原因。
NgSwitchCase
和 NgSwitchDefault
都是
结构型指令。 因此你要使用星号(*
)前缀来把它们附着到元素上。 NgSwitchCase
会在它的值匹配上选项值的时候显示它的宿主元素。 NgSwitchDefault
则会当没有兄弟 NgSwitchCase
匹配上时显示它的宿主元素。
指令所在的元素就是它的宿主元素。 <happy-hero> 是 *ngSwitchCase 的宿主元素。 <unknown-hero> 是 *ngSwitchDefault 的宿主元素。
像其它的结构型指令一样,NgSwitchCase 和 NgSwitchDefault 也可以解开语法糖,变成 <ng-template> 的形式。
src/app/app.component.html (ngswitch-template)
content_copy<div [ngSwitch]="hero?.emotion">
<ng-template [ngSwitchCase]="'happy'">
<app-happy-hero [hero]="hero"></app-happy-hero>
</ng-template>
<ng-template [ngSwitchCase]="'sad'">
<app-sad-hero [hero]="hero"></app-sad-hero>
</ng-template>
<ng-template [ngSwitchCase]="'confused'">
<app-confused-hero [hero]="hero"></app-confused-hero>
</ng-template >
<ng-template ngSwitchDefault>
<app-unknown-hero [hero]="hero"></app-unknown-hero>
</ng-template>
</div>
优先使用星号(*)语法
星号(*)语法比不带语法糖的形式更加清晰。 如果找不到单一的元素来应用该指令,可以使用<ng-container>作为该指令的容器。
虽然很少有理由在模板中使用结构型指令的属性形式和元素形式,但这些幕后知识仍然是很重要的,即:Angular 会创建 <ng-template>,还要了解它的工作原理。 当需要写自己的结构型指令时,你就要使用 <ng-template>。
<ng-template>指令
<ng-template>是一个 Angular 元素,用来渲染 HTML。 它永远不会直接显示出来。 事实上,在渲染视图之前,Angular 会把 <ng-template> 及其内容替换为一个注释。
如果没有使用结构型指令,而仅仅把一些别的元素包装进 <ng-template>中,那些元素就是不可见的。 在下面的这个短语"Hip! Hip! Hooray!"中,中间的这个 "Hip!"(欢呼声) 就是如此。
src/app/app.component.html (template-tag)
content_copy<p>Hip!</p>
<ng-template>
<p>Hip!</p>
</ng-template>
<p>Hooray!</p>
Angular 抹掉了中间的那个 "Hip!" ,让欢呼声显得不再那么热烈了。
结构型指令会让 <ng-template> 正常工作,在你写自己的结构型指令时就会看到这一点。
使用<ng-container>把一些兄弟元素归为一组
通常都要有一个根元素作为结构型指令的数组。 列表元素(<li>)就是一个典型的供 NgFor 使用的宿主元素。
src/app/app.component.html (ngfor-li)
content_copy<li *ngFor="let hero of heroes">{{hero.name}}</li>
当没有这样一个单一的宿主元素时,你就可以把这些内容包裹在一个原生的 HTML 容器元素中,比如 <div>,并且把结构型指令附加到这个"包裹"上。
src/app/app.component.html (ngif)
content_copy<div *ngIf="hero" class="name">{{hero.name}}</div>
但引入另一个容器元素(通常是 <span> 或 <div>)来把一些元素归到一个单一的根元素下,通常也会带来问题。注意,是"通常"而不是"总会"。
这种用于分组的元素可能会破坏模板的外观表现,因为 CSS 的样式既不曾期待也不会接受这种新的元素布局。 比如,假设你有下列分段布局。
src/app/app.component.html (ngif-span)
content_copy<p>
I turned the corner
<span *ngIf="hero">
and saw {{hero.name}}. I waved
</span>
and continued on my way.
</p>
而你的 CSS 样式规则是应用于 <p> 元素下的 <span> 的。
src/app/app.component.css (p-span)
content_copyp span { color: red; font-size: 70%; }
这样渲染出来的段落就会非常奇怪。
本来为其它地方准备的 p span
样式,被意外的应用到了这里。
另一个问题是:有些 HTML 元素需要所有的直属下级都具有特定的类型。 比如,<select> 元素要求直属下级必须为 <option>,那就没办法把这些选项包装进 <div> 或 <span> 中。
如果这样做:
src/app/app.component.html (select-span)
content_copy<div>
Pick your favorite hero
(<label><input type="checkbox" checked (change)="showSad = !showSad">show sad</label>)
</div>
<select [(ngModel)]="hero">
<span *ngFor="let h of heroes">
<span *ngIf="showSad || h.emotion !== 'sad'">
<option [ngValue]="h">{{h.name}} {{h.emotion}})</option>
</span>
</span>
</select>
下拉列表就是空的。
浏览器不会显示 <span> 中的 <option>。
<ng-container> 的救赎
Angular 的 <ng-container> 是一个分组元素,但它不会污染样式或元素布局,因为 Angular 压根不会把它放进 DOM 中。
下面是重新实现的条件化段落,这次使用 <ng-container>。
src/app/app.component.html (ngif-ngcontainer)
content_copy<p>
I turned the corner
<ng-container *ngIf="hero">
and saw {{hero.name}}. I waved
</ng-container>
and continued on my way.
</p>
这次就渲染对了。
现在用 <ng-container> 来根据条件排除选择框中的某个 <option>。
src/app/app.component.html (select-ngcontainer)
content_copy<div>
Pick your favorite hero
(<label><input type="checkbox" checked (change)="showSad = !showSad">show sad</label>)
</div>
<select [(ngModel)]="hero">
<ng-container *ngFor="let h of heroes">
<ng-container *ngIf="showSad || h.emotion !== 'sad'">
<option [ngValue]="h">{{h.name}} {{h.emotion}})</option>
</ng-container>
</ng-container>
</select>
下拉框也工作正常。
<ng-container> 是一个由 Angular 解析器负责识别处理的语法元素。 它不是一个指令、组件、类或接口,更像是 JavaScript 中 if 块中的花括号。
content_copyif (someCondition) {
statement1;
statement2;
statement3;
}
没有这些花括号,JavaScript 只会执行第一句,而你原本的意图是把其中的所有语句都视为一体来根据条件执行。 而 <ng-container> 满足了 Angular 模板中类似的需求。
写一个结构型指令
在本节中,你会写一个名叫 UnlessDirective
的结构型指令,它是 NgIf
的反义词。 NgIf
在条件为 true
的时候显示模板内容,而 UnlessDirective
则会在条件为 false
时显示模板内容。
src/app/app.component.html (appUnless-1)
content_copy<p *appUnless="condition">Show this sentence unless the condition is true.</p>
创建指令很像创建组件。
- 导入
Directive
装饰器(而不再是Component
)。
这里是起点:
src/app/unless.directive.ts (skeleton)
content_copyimport { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
@Directive{ selector: '[appUnless]'})
export class UnlessDirective {
}
指令的选择器
通常是把指令的属性名括在方括号中,如 [appUnless]
。 这个方括号定义出了一个 CSS 属性选择器
。
该指令的属性名
应该拼写成小驼峰
形式,并且带有一个前缀。 但是,这个前缀不能用 ng
,因为它只属于 Ang
ular 本身。 请选择一些简短的,适合你自己或公司的前缀。 在这个例子中,前缀是 my
。
指令的类名
用 Directive
结尾,参见风格指南。 但 Angular 自己的指令例外。
TemplateRef 和 ViewContainerRef
像这个例子一样的简单结构型指令会从 Angular 生成的 <ng-template> 元素中创建一个内嵌的视图,并把这个视图插入到一个视图容器中,紧挨着本指令原来的宿主元素 <p>(译注:注意不是子节点,而是兄弟节点)。
你可以使用TemplateRef取得 <ng-template> 的内容,并通过ViewContainerRef来访问这个视图容器。
你可以把它们都注入到指令的构造函数中,作为该类的私有属性。
src/app/unless.directive.ts (ctor)
content_copyconstructor(
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef) { }
appUnless 属性
该指令的使用者会把一个 true/false 条件绑定到 [appUnless]
属性上。 也就是说,该指令需要一个带有 @Input
的 appUnless
属性。
要了解关于 @Input
的更多知识,参见模板语法
一章。
src/app/unless.directive.ts (set)
content_copy@Input() set appUnless(condition: boolean) {
if (!condition && !this.hasView) {
this.viewContainer.createEmbeddedView(this.templateRef
this.hasView = true;
} else if (condition && this.hasView) {
this.viewContainer.clear(
this.hasView = false;
}
}
一旦该值的条件发生了变化,Angular 就会去设置 appUnless
属性。因为不能用 appUnless
属性,所以你要为它定义一个设置器(setter)。
- 如果条件为假,并且以前尚未创建过该视图,就告诉
视图容器(ViewContainer)
根据模板创建一个内嵌视图
。
没有人会读取 appUnless
属性,因此它不需要定义 getter。
完整的指令代码如下:
src/app/unless.directive.ts (excerpt)
content_copyimport { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
/**
* Add the template content to the DOM unless the condition is true.
*/
@Directive{ selector: '[appUnless]'})
export class UnlessDirective {
private hasView = false;
constructor(
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef) { }
@Input() set appUnless(condition: boolean) {
if (!condition && !this.hasView) {
this.viewContainer.createEmbeddedView(this.templateRef
this.hasView = true;
} else if (condition && this.hasView) {
this.viewContainer.clear(
this.hasView = false;
}
}
}
把这个指令添加到 AppModule 的 declarations
数组中。
然后创建一些 HTML 来试用一下。
src/app/app.component.html (appUnless)
content_copy<p *appUnless="condition" class="unless a">
(A) This paragraph is displayed because the condition is false.
</p>
<p *appUnless="!condition" class="unless b">
(B) Although the condition is true,
this paragraph is displayed because appUnless is set to false.
</p>
当 condition
为 false
时,顶部的段落就会显示出来,而底部的段落消失了。 当 condition
为 true
时,顶部的段落被移除了,而底部的段落显示了出来。
小结
本章相关的代码如下:
app.component.ts
app.component.html
app.component.css
app.module.ts
hero.ts
hero-switch.components.ts
unless.directive.ts
content_copyimport { Component } from '@angular/core'; import { Hero, heroes } from './hero'; @Component{ selector: 'app-root', templateUrl: './app.component.html', styleUrls: [ './app.component.css' ]})export class AppComponent { heroes = heroes; hero = this.heroes[0]; condition = false; logs: string[] = []; showSad = true; status = 'ready'; trackById(index: number, hero: Hero): number { return hero.id; }}
你学到了
- 结构型指令可以操纵 HTML 的元素布局。