[Angular 基础] - 自定义指令,深入学习 directive
这篇笔记的前置笔记为 [Angular 基础] - 数据绑定(databinding),对 Angular 的 directives 不是很了解的可以先过一下这篇笔记
后面也会拓展一下项目,所以感兴趣的也可以补一下文后对应的项目:
- 第一个 Angular 项目 - 静态页面
- 第一个 Angular 项目 - 动态页面
创建新 directive
directive 的创建方式和 component 类似,这里选择用指令生成:
❯ ng g d directives/test --skip-tests
CREATE src/app/directives/test.directive.ts (137 bytes)
UPDATE src/app/app.module.ts (757 bytes)
运行这个指令就会在 src/app/directives
下创建一个新的 directive:
目前项目结构如下,这里 V 和 VM 层暂时不用去管,directive 会一个个过一遍
⚠️:如果手动生成 directive,同样需要在 app.module.ts
中导入对应的 directive:
@NgModule({
declarations: [
AppComponent,
BasicHighlightDirective,
BetterHighlightDirective,
],
imports: [BrowserModule, FormsModule],
providers: [],
bootstrap: [AppComponent],
})
export class AppModule {}
实现一个 attribute directive
一个空白的 directive 结构如下:
import { Directive } from '@angular/core';
@Directive({
selector: '[appBasicHighlight]',
})
export class BasicHighlightDirective {
constructor() {}
}
首先分析一下 directive 的结构,这里的 @Directive
与 @Component
一样都是装饰器,不过这里的使用比较简单,只是放入了一个 selector
。
selector
中的内容为绑定对应的 HTML Template 中的 attribute,也就是说 HTML template:
- ✅ 使用
appBasicHighlight
,就能够绑定对应的 directive - ❌ 不使用
[appBasicHighlight]
去进行属性绑定
这时候修改 app 的 VM 层:
<p appBasicHighlight>Style me with basic directive</p>
`
简单的 attribute directive
directive 的构造函数是比较重要的,它总共会提供 3 个参数用来操控对应的 DOM
使用 ElementRef
这里的 ElementRef 是对当前绑定指令的 HTML 元素的引用值,这里也就是 p
标签中的内容
通过直接修改 ElementRef 也是一种可以直接修改 DOM 元素的方式,使用方法如下:
@Directive({
selector: '[appBasicHighlight]',
})
export class BasicHighlightDirective implements OnInit {
constructor(private elementRef: ElementRef) {}
ngOnInit() {
console.log(this.elementRef);
this.elementRef.nativeElement.style.backgroundColor = 'pink';
}
}
效果如下:
这里简述一下修改:
-
implements OnInit
这一段算是补充吧尽管说 TS 的
implements
执行力比较差,不过我看了下官方文档都有用,就稍微标准化一下好了 -
ngOnInit
中执行对于样式的修改本案例使用
ngOnInit
或者直接在构造函数里修改样式其实没有什么特别大的区别,不过对于其他的情况,例如 HTML 中的内容是动态生成的情况下,直接在构造函数里就会造成内容的缺失。
通过这种方式就能够创建一个 attribute directive 了,不过直接使用 elementRef 并不是一个推荐的做法,下面会创建另一个 attribute directive,并使用推荐的方式去修改属性
复杂一些的 attribute directive
这里新建一个 directive,并将其命名为 better-highlight.directive
,同时在 app 的 V 层新建一个 p 标签,并添加对应的 attribute directive:
<p appBetterHighlight>Style me with better directive</p>
renderer
import { Directive } from '@angular/core';
@Directive({
selector: '[appBetterHighlight]',
})
export class BetterHighlightDirective {
constructor(private renderer: Renderer2) {}
}
这里要修改样式的方法是通过这个 renderer
去实现的
renderer: Renderer2
是 Angular 对 DOM 操作的一个 service 封装,其主要的优点在于提供统一的 API 使得其在浏览器坏境、SSR 环境以及 web worker 中都会有同样的表现。另外它也会对一些 HTML 元素进行清理,这样可以更觉有效的防止 XSS 攻击。
对于同样修改样式,这里依旧在 ngOnInit
中实现,实现的方式是通过调用 setStyle
进行:
@Directive({
selector: '[appBetterHighlight]',
})
export class BetterHighlightDirective implements OnInit {
// @Input
constructor(private elementRef: ElementRef, private renderer: Renderer2) {}
ngOnInit() {
this.renderer.setStyle(
this.elementRef.nativeElement,
'background-color',
'lightblue'
);
}
}
显示效果如下:
@HostListener
@HostListener
是 Angular 提供的,对当前元素所提供的事件绑定的一个装饰器,其语法如下:
@HostListener('event_name', ['$event'])
methodName(event: EventType): void {}
其中 :
-
event_name
为事件名称,如click
,mouseenter
等 -
$event
为对应的事件事件前添加
$
算是 Angular 约定俗成的一种规范了 -
methodName
,如其名,函数名 -
EventType
,事件类型,可以不传,TS 用来做规范的
这里用 mouseenter
和 mouseleave
为例,对背景颜色进行修改,修改后代码如下:
@Directive({
selector: '[appBetterHighlight]',
})
export class BetterHighlightDirective implements OnInit {
constructor(private elementRef: ElementRef, private renderer: Renderer2) {}
ngOnInit() {
this.renderer.setStyle(
this.elementRef.nativeElement,
'background-color',
'lightblue'
);
}
@HostListener('mouseenter', ['$event']) mouseover(eventData: MouseEvent) {
console.log(eventData);
this.renderer.setStyle(
this.elementRef.nativeElement,
'background-color',
'lightgreen'
);
}
@HostListener('mouseleave') mouseleave(eventData: Event) {
console.log(eventData);
this.renderer.setStyle(
this.elementRef.nativeElement,
'background-color',
'lightblue'
);
}
}
效果如下:
这里主要注意的是这么几个点:
-
如果不传递
['$event']
,那么函数也不会自动监听到对应的事件这也是为什么
mouseenter
的时候能抓到$event
,但是mouseleave
的时候抓不到的原因 -
Event
只是类型检查不是说没用,相反,如果确定类型的话,那么 TS 将会提供更好的类型检查和 intelligence 提示
但是如果要写比较 generic 的方案,可能还是直接用
Event
比较好
💡:我添加了 CSS transition,让背景色过渡的稍微自然点,不过这个不是什么重点
@HostBinding
这个时候看到组件内出现了很多的重复代码:
this.renderer.setStyle(
this.elementRef.nativeElement,
'background-color',
`${color}`
);
可以看到,这里唯一产生变化的只有需要被修改的颜色。
要解决这个问题,可以使用 Angular 提供的 @HostBinding
装饰器,它的语法为:
@HostBinding('property') propertyName: Type = value;
其中:
-
property
为想要绑定的元素属性这个案例中就是
style.backgroundColor
-
propertyName
为变量名 -
Type
为类型本案例为
string
,其他的案例可能会出现number
,boolean
,如果是可选项的话也可以为undefined
-
value
使用如下:
export class BetterHighlightDirective implements OnInit {
@HostBinding('style.backgroundColor') backgroundColor: string = 'lightblue';
constructor(private elementRef: ElementRef, private renderer: Renderer2) {}
ngOnInit() {
// 已经有了默认值,这下面的代码也可以注释掉了
// this.backgroundColor = 'lightblue';
}
@HostListener('mouseenter', ['$event']) mouseover(eventData: MouseEvent) {
console.log(eventData);
this.backgroundColor = 'lightgreen';
}
@HostListener('mouseleave') mouseleave(eventData: Event) {
console.log(eventData);
this.backgroundColor = 'lightblue';
}
}
最后展示的效果依旧是一样的
动态添加属性
这个时候可以注意到,现在唯一要修改的地方就是颜色,这个情况也可以使用变量去存储这个修改的颜色,同时前面可以添加 @Input
,这样可以让父元素动态重写颜色:
export class BetterHighlightDirective implements OnInit {
@Input() defaultColor: string = 'lightblue';
@Input() highlightColor: string = 'lightgreen';
@HostBinding('style.backgroundColor') backgroundColor: string =
this.defaultColor;
constructor(private elementRef: ElementRef, private renderer: Renderer2) {}
@HostListener('mouseenter', ['$event']) mouseover(eventData: MouseEvent) {
console.log(eventData);
this.backgroundColor = this.highlightColor;
}
@HostListener('mouseleave') mouseleave(eventData: Event) {
console.log(eventData);
this.backgroundColor = this.defaultColor;
}
}
在不重写默认颜色时,效果是一样的。
但是父元素也可以选择重写默认值:
<p
appBetterHighlight
class="bg-transition"
[defaultColor]="'lightyellow'"
highlightColor="violet"
>
Style me with better directive
</p>
效果如下:
这里又有两个点需要注意的:
-
highlightColor="violet"
这是一个特殊的语法缩写,本质上还是一个 property binding,而不是 HTML 所有的原生属性
我这里特地用了两种写法,只是为了添加一下 note。为了更好的阅读性和理解,还是推荐使用
[customPropertyName]="'value'"
的写法 -
背景颜色默认为蓝色
这就是前面提到的
ngOnInit
的作用,这个情况下 Angular 的组件需要经历一个初始化的状态,在这个初始化的状态,它会绑定对应的属性——包括来自外部的属性这一段代码里我特地把
ngOnInit
注释掉了,没有了这个初始化的状态,那么当前组件依旧接受默认值,也就是lightblue
,把ngOnInit
加回去,并添加对应的修改:ngOnInit() { this.backgroundColor = this.defaultColor; }
才能将默认的背景色重写为父元素传进来的
lightyellow
实现一个 structural directive
官方文档有一个实现了 unless
的 structural directive,也可以参考一下,我这里就写一个 loading spinner 了。
实现如下:
@Directive({
selector: '[appLoading]',
})
export class LoadingDirective {
private loadingSpinner: HTMLElement;
constructor(
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef,
private renderer: Renderer2,
private el: ElementRef
) {
this.loadingSpinner = this.renderer.createElement('div');
this.renderer.addClass(this.loadingSpinner, 'spinner');
}
@Input() set appLoading(isLoading: boolean) {
this.viewContainer.clear();
if (isLoading) {
this.renderer.appendChild(
this.el.nativeElement.parentElement,
this.loadingSpinner
);
} else {
this.renderer.removeChild(
this.el.nativeElement.parentElement,
this.loadingSpinner
);
this.viewContainer.createEmbeddedView(this.templateRef);
}
}
}
app 的 V 层:
<div *appLoading="isLoading">
some random syntax showing only when loading is false
</div>
VM 层修改变量,以及 CSS 我就不贴代码了
效果如下:
下面进入实现的分析部分
TemplateRef
即绑定 structural directive 的元素,在这个情况下,就是:
<div *appLoading="isLoading">
some random syntax showing only when loading is false
</div>
简单的理解就是,当满足特定条件时,这里需要渲染的内容
ViewContainerRef
ViewContainerRef
就是管理渲染内容的容器
Angular 没有 virtual DOM,但是又不想直接暴露 DOM 进行操作,因此就像 ElementRef
一样,它对将整个 视图(view) 进行了一个抽象,创建了 ViewContainerRef
以方便管理与 directive 绑定的,整个 DOM 的 view/template
setter
set
是一个 JavaScript 的语法糖,这也是 ES6 后出现的语法,与 Angular 无关。
这里 Angular 动态的将其 setter 和 @Input
进行绑定,并且提供一个更加直观且简洁明了的方式对当前与 @Input
绑定的值进行变化管理。如果不使用 setter 的话,也可以在对应的 ngOnChanges
和 ngOnInit
中监听值的变化,并且进行对应的操作。
整个 setter 中做的操作就分为两步:
-
当
isLoading = true
这个情况下需要渲染一个 loading spinner——这在构造函数中就已经创建好了,并且使用
renderer
去进行渲染当前的
nativeElement
指向的是<div *appLoading="isLoading"></div>
这个具体的元素因此这里的操作就是在
nativeElement
的父元素下,新增一个 loading spinner -
当
isLoading = false
这个情况下 loading spinner 被移除,原本的 template view 被渲染
💡:至于选用 renderer
就是因为在 attribute directive 部分已经讲过了,而且实现起来比较方便。我找了一下不用 renderer
渲染的方式,需要用到 ComponentFactoryResolver
,这个暂时就还没学上,等之后学上了再说
structural directive 幕后
如果看了官方文档就会知道,<div *ngIf="hero" class="name">{{hero.name}}</div>
这样的语法是一个缩写,本质上它的实现方法如下:
<ng-template [ngIf]="hero">
<div class="name">{{hero.name}}</div>
</ng-template>
随后就像上面写过的 structural directive 的实现一样,ngIf
通过属性绑定被 @Input
监听到,structural component 再根据业务逻辑进行制定渲染。
ng-template
本身也是一个 view 的 placeholder,它是不会被渲染的
下面是官方文档关于 ngFor
的实现:
<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>
也就是鉴于这样的转化,所以 structural directive 的语法一定是 *[directive_name]="[variable]"
这样的实现
第一个 Angular 项目——实现下拉框
项目里面没导入对应的 js 文件,所以现在 bootstrap 的 dropdown 是没办法被触发的。这里就是用 directive 去解决这个问题
新建 directive
❯ ng generate directive directives/dropdown --skip-tests
CREATE src/app/directives/dropdown.directive.ts (145 bytes)
UPDATE src/app/app.module.ts (1200 bytes)
实现 dropdown directive
这里需要了解一下 bootstrap 的 dropdown 是怎么被展开的——实际上是通过 open
这样一个 class 去实现的。因此,当 class 为 btn-group open
时,下拉框时展开的,当 class 为 btn-group
时,下拉框时关闭的。
所以这里需要实现一个 attribute directive,去管理 class 即可
最初的实现方法为:
export class DropdownDirective {
constructor(private el: ElementRef, private renderer: Renderer2) {}
@HostListener('click')
onClick() {
if (this.el.nativeElement.classList.contains('open')) {
this.renderer.removeClass(this.el.nativeElement, 'open');
} else {
this.renderer.addClass(this.el.nativeElement, 'open');
}
}
}
不过一个简化的方法是使用 @HostBinding
去进行操作:
export class DropdownDirective {
@HostBinding('class.open') isOpen = false;
@HostListener('click')
onClick() {
this.isOpen = !this.isOpen;
}
}
二者实现的效果是一样的:
补充一下点击 HTML 任何地方关闭 dropdown 的实现:
export class DropdownDirective {
@HostBinding('class.open') isOpen = false;
@HostListener('document:click', ['$event'])
onClick(evemt: MouseEvent) {
this.isOpen = this.el.nativeElement.contains(evemt.target)
? !this.isOpen
: false;
}
constructor(private el: ElementRef) {}
}
reference
没有特殊标注的都是来自官方文档的内容
- Structural directives
- TemplateRef
- ViewContainerRef