结构型指令
本章将看看 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>
本章不会重复讲如何使用它们,而是解释它们的工作原理以及如何写自己的结构型指令。
指令的拼写形式
还有另外两种 Angular 指令,在本开发指南的其它地方有讲解:(1) 组件 (2) 属性型指令。
组件可以在原生 HTML 元素中管理一小片区域的 HTML。从技术角度说,它就是一个带模板的指令。
你可以在一个宿主元素上应用多个属性型指令,但只能应用一个结构型指令。
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>
第一种形态永远不会真的渲染出来。 只有最终产出的结果才会出现在 DOM 中。

Angular 会在真正渲染的时候填充 <ng-template>
的内容,并且把 <ng-template>
替换为一个供诊断用的注释。
NgFor
和NgSwitch...
指令也都遵循同样的模式。
*
ngFor
内幕
这里有一个 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>
微语法
Angular 微语法能让你通过简短的、友好的字符串来配置一个指令。 微语法解析器把这个字符串翻译成 <ng-template>
上的属性:
let
关键字声明一个模板输入变量,你会在模板中引用它。本例子中,这个输入变量就是hero
、i
和odd
。 解析器会把let hero
、let i
和let
odd
翻译成命名变量let-hero
、let-i
和let-odd
。- 微语法解析器接收
of
和trackby
,把它们首字母大写(of
->Of
,trackBy
->TrackBy
), 并且给它们加上指令的属性名(ngFor
)前缀,最终生成的名字是ngForOf
和ngForTrackBy
。 还有两个NgFor
的输入属性,指令据此了解到列表是heroes
,而 track-by 函数是trackById
。 NgFor
指令在列表上循环,每个循环中都会设置和重置它自己的上下文对象上的属性。 这些属性包括index
和odd
以及一个特殊的属性名$implicit
(隐式变量)。let-i
和let-odd
变量是通过let i=index
和let
odd
=
odd
来定义的。 Angular 把它们设置为上下文对象中的index
和odd
属性的当前值。- 这里并没有指定
let-hero
的上下文属性。它的来源是隐式的。 Angular 将let-hero
设置为此上下文中$implicit
属性的值, 它是由NgFor
用当前迭代中的英雄初始化的。 - API 参考手册中描述了
NgFor
指令的其它属性和上下文属性。 NgFor
是由NgForOf
指令来实现的。请参阅NgForOf API reference来了解NgForOf
指令的更多属性及其上下文属性。
模板输入变量
模板输入变量是这样一种变量,你可以在单个实例的模板中引用它的值。 这个例子中有好几个模板输入变量:hero
、i
和 odd
。 它们都是用 let
作为前导关键字。
模板输入变量和模板引用变量是不同的,无论是在语义上还是语法上。
你使用 let
关键字(如 let hero
)在模板中声明一个模板输入变量。 这个变量的范围被限制在所重复模板的单一实例上。 事实上,你可以在其它结构型指令中使用同样的变量名。
而声明模板引用变量使用的是给变量名加 #
前缀的方式(#var
)。 一个引用变量引用的是它所附着到的元素、组件或指令。它可以在整个模板的任意位置访问。
模板输入变量和引用变量具有各自独立的命名空间。let hero
中的 hero
和 #hero
中的 hero
并不是同一个变量。
每个宿主元素上只能有一个结构型指令
对这些问题,没有办法简单回答。而禁止多个结构型指令则可以简单地解决这个问题。 这种情况下有一个简单的解决方案:把 *
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
,以决定要显示哪一个分支。
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 模板中类似的需求。
写一个结构型指令
src/app/app.component.html (appUnless-1)
content_copy<p *appUnless="condition">Show this sentence unless the condition is true.</p>
创建指令很像创建组件。
- 导入
Directive
装饰器(而不再是Component
)。 - 导入符号
Input
、TemplateRef
和ViewContainerRef
,你在任何结构型指令中都会需要它们。 - 给指令类添加装饰器。
- 设置 CSS 属性选择器 ,以便在模板中标识出这个指令该应用于哪个元素。
这里是起点:
src/app/unless.directive.ts (skeleton)
content_copyimport { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
@Directive({ selector: '[appUnless]'})
export class UnlessDirective {
}
指令的选择器通常是把指令的属性名括在方括号中,如 [appUnless]
。 这个方括号定义出了一个 CSS 属性选择器。
该指令的属性名应该拼写成小驼峰形式,并且带有一个前缀。 但是,这个前缀不能用 ng
,因为它只属于 Angular 本身。 请选择一些简短的,适合你自己或公司的前缀。 在这个例子中,前缀是 my
。
TemplateRef 和 ViewContainerRef
你可以使用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
属性。
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 的元素布局。
- 当没有合适的容器元素时,可以使用
<ng-container>
对元素进行分组。 - Angular 会把星号(*)语法解开成
<ng-template>
。 - 内置指令
NgIf
、NgFor
和NgSwitch
的工作原理。 - 微语法如何展开成
<ng-template>
。 - 写了一个自定义结构型指令 ——
UnlessDirective
。
本文档系腾讯云开发者社区成员共同维护,如有问题请联系 cloudcommunity@tencent.com