Angular 学习之路 15 – 自定义指令

前面我们已经说过,Angular 的指令分为组件指令、结构指令和属性指令。我们已经详细介绍过 Angular 为我们内置的三种结构指令:ngIfngFor以及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实现。我们通过ElementRefnativeElement属性获取其包装的底层 DOM 元素,然后通过nativeElementclassList属性,给这个 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>
my-class 指令

这里,我们首先在 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保存条件结果。当ifValuetrue时,将显示myIf作用到的元素,否则则不显示。

构造函数注入两个参数,分别为ViewContainerRefTemplateRef<any>类型。顾名思义,ViewContainerRef是一种引用类,凡是可以嵌入其它元素的元素,都是一个 view container,也就是视图容器。TemplateRef同样是引用类,用于访问页面中定义的模板。

输入属性@Input()的名字还是同指令名相同,原因前面已经介绍过。只不过这里的输入属性使用的是set函数。TypeScript 的set函数与 C# 基本一致,使用关键字set修饰,参数只能有一个,不允许有返回值。set函数的使用与普通变量一样,单从语法上看没有任何区别。只不过普通变量仅是简单赋值,而set函数则会执行该函数调用。那么这里,当输入属性myIf赋值后,ifValue将保存条件结果,然后调用updateView()函数。此处之所以需要使用set函数,是因为我们希望只要条件值一改变,就应该更新视图,也就是重新调用updateView()

updateView()函数是整个指令的核心。当ifValue值为true时,使用ViewContainerRefcreateEmbeddedView()函数,将模板插入到容器;如果为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 中使用原始的documentAPI。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>

运行结果如下:

One Response

  1. angel 2021年7月2日

Leave a Reply