前面我们已经说过,Angular 的指令分为组件指令、结构指令和属性指令。我们已经详细介绍过 Angular 为我们内置的三种结构指令:ngIf
、ngFor
以及ngSwitch
。但是,现实世界千变万化,区区几种内置指令不可能满足所有的需求。所以,Angular 也提供了自定义指令的方法。本章我们将介绍如何自定义指令。
自定义属性指令
虽然 Angular 提供了ngClass
指令,将 CSS class 添加到元素。但由于种种原因——可能就是看它不爽——我们想要自定义一个myClass
指令。这是一个属性指令,目的是给指令绑定的元素添加 CSS class。
下面我们创建一个文件 my-class.directive.ts。按照 Angular 的命名约定,指令使用 .directive 作为区分。为方便起见,我们可以直接使用 Angular CLI 命令直接创建指令,具体命令是:
ng generate directive my-class
简写作
ng g d my-class
Angular CLI 会帮助我们生成一个自定义指令的文件模板,通常包含两个文件:my-class.directive.ts 和 my-class.directive.spec.ts。我们将 my-class.directive.ts 的文件内容修改如下:
import { Directive, ElementRef, Input, OnInit } from '@angular/core'; @Directive({ selector: '[myClass]' }) export class MyClassDirective implements OnInit { @Input() myClass?: string; constructor( private readonly el: ElementRef ) { } ngOnInit(): void { if (this.myClass) { this.el.nativeElement.classList.add(this.myClass); } } }
下面我们介绍这些代码的具体含义。
第一行是import
语句,将所需要的类引入文件,以便后文使用。
修饰符@Directive
表示,该类是一个指令。与此类似的还有之前我们介绍过的@Component
。@Directive
最重要的属性是selector
,定义了该指令如何被使用。这里,我们将selector
修改为[myClass]
。这是一个类似 CSS 选择器的语法,表示将myClass
作为元素的属性;也就是说,如果 HTML 元素带有myClass
属性,则会应用该指令所定义的行为。
@Input()
创建了一个属性绑定的输入值,表示我们的指令可以有一个输入,也就是用户需要应用的 CSS class 的名字。注意这里的一个小技巧,@Input()
的属性名与指令选择器是相同的。后面我们将看到这样做的好处。
指令会附加在一个元素上面,我们称这个元素是这个指令的父元素。指令一般会改变父元素的属性,这就需要我们在指令中获取父元素的引用。构造函数注入了ElementRef
类型,就是为了达到这一目的。ElementRef
是 DOM 元素的包装类,在 Angular 中访问 DOM 元素,一般都需要通过ElementRef
实现。我们通过ElementRef
的nativeElement
属性获取其包装的底层 DOM 元素,然后通过nativeElement
的classList
属性,给这个 DOM 元素添加 CSS class。
注意,Angular 中有很多引用(reference),用于访问其它的类型。比如这里提到的ElementRef
,以及后面我们将见到的ViewContainerRef
或者TemplateRef
。我们可以将这些类型看作一种“复杂的指针”,通过这些“指针”访问到某些不容易直接访问的类型,比如页面中定义的元素等。这些类型的名字往往以Ref
结尾。
接下来,我们在AppComponent
里面看如何使用这个指令:
import { Component, OnInit } from '@angular/core'; @Component({ selector: 'app-my-class', templateUrl: './my-class.component.html', styleUrls: ['./my-class.component.css'] }) export class MyClassComponent implements OnInit { constructor() { } ngOnInit(): void { } }
.blue { background-color: lightblue; }
<button [myClass]="'blue'">Click Me</button>

这里,我们首先在 CSS 文件中添加一个class blue
,然后利用my-class
指令,将这个 CSS class 添加到一个按钮。在浏览器运行页面我们可以看到,blue
被添加到了按钮的class
里面,说明我们的指令已经能够正常工作。事实上,Angular 提供的ngClass
指令就是使用的类似的实现机制,只不过ngClass
指令更为复杂。感兴趣的话可以在这里直接阅读ngClass
的实现代码。
最后我们来看,为什么@Input()
修饰的输入属性要与指令选择器起相同的名字。注意 HTML 中的写法,如果二者名字不一致,例如将输入属性更名为className
,那么我们的 HTML 代码必须修改如下:
<button myClass [className]="'blue'">Click Me</button>
当二者名称相同时,由于指令选择器是[myClass]
,在设置输入属性时,也就自动带有了myClass
这个属性,因此只需要设置这个输入属性,指令和属性同时满足。如果二者不同,则必须分开设置。因此,将二者命名一致,目的是为了方便自定义指令的使用。
自定义结构指令
接下来我们来看一下如何自定义结构指令。前面我们介绍过,结构指令用于改变 DOM 结构。结构指令的创建与属性指令类似,并没有什么特别之处。我们依旧可以使用 Angular CLI 提供的模板来创建一个指令,然后修改其文件内容。
现在,我们模仿ngIf
,来创建一个我们自己的mgIf
指令。我们将 my-if.directive.ts 内容修改如下:
import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core'; @Directive({ selector: '[myIf]' }) export class MyIfDirective { private ifValue = false; constructor( private readonly viewContainer: ViewContainerRef, private readonly templateRef: TemplateRef<any>) { } @Input() set myIf(condition: boolean) { this.ifValue = condition; this.updateView(); } private updateView(): void { if (this.ifValue) { this.viewContainer.createEmbeddedView(this.templateRef); } else { this.viewContainer.clear(); } } }
看起来,我们的自定义结构指令与前面的属性指令的确没有太大的不同。
回忆一下ngIf
,我们的myIf
也会有一个boolean
类型的输入属性,用于判断是否显示元素。这里,我们使用一个变量ifValue
保存条件结果。当ifValue
为true
时,将显示myIf
作用到的元素,否则则不显示。
构造函数注入两个参数,分别为ViewContainerRef
和TemplateRef<any>
类型。顾名思义,ViewContainerRef
是一种引用类,凡是可以嵌入其它元素的元素,都是一个 view container,也就是视图容器。TemplateRef
同样是引用类,用于访问页面中定义的模板。
输入属性@Input()
的名字还是同指令名相同,原因前面已经介绍过。只不过这里的输入属性使用的是set
函数。TypeScript 的set
函数与 C# 基本一致,使用关键字set
修饰,参数只能有一个,不允许有返回值。set
函数的使用与普通变量一样,单从语法上看没有任何区别。只不过普通变量仅是简单赋值,而set
函数则会执行该函数调用。那么这里,当输入属性myIf
赋值后,ifValue
将保存条件结果,然后调用updateView()
函数。此处之所以需要使用set
函数,是因为我们希望只要条件值一改变,就应该更新视图,也就是重新调用updateView()
。
updateView()
函数是整个指令的核心。当ifValue
值为true
时,使用ViewContainerRef
的createEmbeddedView()
函数,将模板插入到容器;如果为false
,则清空容器内容。
指令的使用方法如下:
import { Component, OnInit } from '@angular/core'; @Component({ selector: 'app-my-if', templateUrl: './my-if.component.html', styleUrls: ['./my-if.component.css'] }) export class MyIfComponent implements OnInit { title = 'Custom Directives in Angular'; show = true; constructor() { } ngOnInit(): void { } }
<h1> {{title}} </h1> Show Me <input type="checkbox" [(ngModel)]="show"> <div *myIf="show"> Using the myIf directive </div> <div *ngIf="show"> Using the ngIf directive </div>
可以看到,myIf
的使用以及运行结果与ngIf
几乎完全一致。
下面我们可以回答一个问题:结构指令前面的*
究竟是什么意思?
如果我们把myIf
或者ngIf
前面的*
去掉,运行程序会得到下面的错误:

当我们了解到如何自定义结构指令之后才会知道这个错误的含义。这个错误是说,没有TemplateRef
的提供者,也就是无法注入TemplateRef
。由于我们需要修改 DOM 结构,所以必须要使用TermplateRef
访问指令所在模板。如果没有办法注入这个对象,指令也就不能正常运行。这个*
其实就是告诉 Angular,我这个指令是结构指令,我需要自己维护 DOM 结构,所以我需要TemplateRef
的注入。为了注入TemplateRef
,Angular 需要定位到这个模板。也就是说,*
告诉 Angular,定位模板并且将其以TemplateRef
的形式注入。
现在,我们已经介绍过如何自定义指令。最后,我们来实现一个比较实用的指令:tooltip。这个指令的目的是,当我们鼠标划入某个元素时,会浮动显示一个提示信息,鼠标离开则自动消失。这个指令的实现如下:
import { Directive, ElementRef, HostListener, Input, Renderer2 } from '@angular/core'; @Directive({ selector: '[toolTip]' }) export class TooltipDirective { @Input() toolTip?: string; elToolTip: any; constructor( private readonly elementRef: ElementRef, private readonly renderer: Renderer2 ) { } @HostListener('mouseenter') onMouseEnter(): void { if (!this.elToolTip) { this.showHint(); } } @HostListener('mouseleave') onMouseLeave(): void { if (this.elToolTip) { this.removeHint(); } } removeHint(): void { if (this.toolTip) { this.renderer.removeClass(this.elToolTip, 'tooltip'); this.renderer.removeChild(document.body, this.elToolTip); this.elToolTip = null; } } showHint(): void { if (this.toolTip) { this.elToolTip = this.renderer.createElement('span'); const text = this.renderer.createText(this.toolTip); this.renderer.appendChild(this.elToolTip, text); this.renderer.appendChild(document.body, this.elToolTip); this.renderer.addClass(this.elToolTip, 'tooltip'); const hostPos = this.elementRef.nativeElement.getBoundingClientRect(); const top = hostPos.bottom + 10 ; const left = hostPos.left; this.renderer.setStyle(this.elToolTip, 'top', `${top}px`); this.renderer.setStyle(this.elToolTip, 'left', `${left}px`); } } }
输入属性toolTip
即要显示的提示信息,其类型是string|undefined
。鼠标进入和离开的事件,使用的是@HostListener
修饰器。该修饰器可以监听指令宿主(host)上的事件:当宿主发出mouseenter
事件时,调用showHint()
函数;当宿主发出mouseleave
事件时,调用removeHint()
函数。要显示的tooltip组件使用一个动态生成的span
。我们使用了 Angular 提供的Renderer2
来操作 DOM。Renderer2
的 API 很简单,很多都是顾名思义。在showHint()
函数中,首先我们动态创建了一个span
元素,然后又创建了文本元素,将文本作为span
的子节点。最后通过setStyle()
设置位置。
在 Angular 应用中,当然可以直接使用document.createElement()
去创建元素。不过,一般不建议直接在 Angular 中使用原始的document
API。Angular 提供了一个封装类Renderer2
,实现了一些较常用的 API,比如修改样式、属性,插入子元素等。避免在 Angular 中直接使用document
API,目的是为了跨平台。Renderer2
作为一个抽象的渲染器,可以识别当前运行的平台:如果是浏览器则使用document
API;如果是移动平台则使用对应的平台渲染机制。之所以叫Renderer2
,是因为在Angular 的早期版本中有一个已经被废弃的Renderer
存在。在未来版本中,也可能存在Renderer3
。
期间我们给生成的span
元素添加了一个 class tooltip
,该 class 定义如下:
.tooltip { display: inline-block; border-bottom: 1px dotted black; position: absolute; z-index: 9999; }
在removeHint()
函数中,我们依旧使用Renderer2
移除之前生成的span
元素。
最后,在 HTML 中的使用如下:
<button toolTip="Tip of the day">Show Tip</button>
运行结果如下:

1 个评论
竟然持续更新了这么多年,真的好强【一位从QT学习赶过来的小白,冒泡表示支持TWT