• [Angular 基础] - 自定义指令,深入学习 directive


    [Angular 基础] - 自定义指令,深入学习 directive

    这篇笔记的前置笔记为 [Angular 基础] - 指令(directives),对 Angular 的 directives 不是很了解的可以先过一下这篇笔记

    后面也会拓展一下项目,所以感兴趣的也可以补一下文后对应的项目:

    创建新 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)
    
    • 1
    • 2
    • 3

    运行这个指令就会在 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 {}
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    实现一个 attribute directive

    一个空白的 directive 结构如下:

    import { Directive } from '@angular/core';
    
    @Directive({
      selector: '[appBasicHighlight]',
    })
    export class BasicHighlightDirective {
      constructor() {}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    首先分析一下 directive 的结构,这里的 @Directive@Component 一样都是装饰器,不过这里的使用比较简单,只是放入了一个 selector

    selector 中的内容为绑定对应的 HTML Template 中的 attribute,也就是说 HTML template:

    • ✅ 使用 appBasicHighlight,就能够绑定对应的 directive
    • ❌ 不使用 [appBasicHighlight] 去进行属性绑定

    这时候修改 app 的 VM 层:

    <p appBasicHighlight>Style me with basic directivep>
    `
    
    • 1
    • 2

    简单的 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';
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    效果如下:

    在这里插入图片描述

    这里简述一下修改:

    • 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 directivep>
    
    • 1
    renderer
    import { Directive } from '@angular/core';
    
    @Directive({
      selector: '[appBetterHighlight]',
    })
    export class BetterHighlightDirective {
      constructor(private renderer: Renderer2) {}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    这里要修改样式的方法是通过这个 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'
        );
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    显示效果如下:

    在这里插入图片描述

    @HostListener

    @HostListener 是 Angular 提供的,对当前元素所提供的事件绑定的一个装饰器,其语法如下:

    @HostListener('event_name', ['$event'])
    methodName(event: EventType): void {}
    
    • 1
    • 2

    其中 :

    • event_name 为事件名称,如 click, mouseenter

    • $event 为对应的事件

      事件前添加 $ 算是 Angular 约定俗成的一种规范了

    • methodName,如其名,函数名

    • EventType,事件类型,可以不传,TS 用来做规范的

    这里用 mouseentermouseleave 为例,对背景颜色进行修改,修改后代码如下:

    @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'
        );
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34

    效果如下:

    在这里插入图片描述

    这里主要注意的是这么几个点:

    • 如果不传递 ['$event'],那么函数也不会自动监听到对应的事件

      这也是为什么 mouseenter 的时候能抓到 $event,但是 mouseleave 的时候抓不到的原因

    • Event 只是类型检查

      不是说没用,相反,如果确定类型的话,那么 TS 将会提供更好的类型检查和 intelligence 提示

      但是如果要写比较 generic 的方案,可能还是直接用 Event 比较好

    💡:我添加了 CSS transition,让背景色过渡的稍微自然点,不过这个不是什么重点

    @HostBinding

    这个时候看到组件内出现了很多的重复代码:

    this.renderer.setStyle(
      this.elementRef.nativeElement,
      'background-color',
      `${color}`
    );
    
    • 1
    • 2
    • 3
    • 4
    • 5

    可以看到,这里唯一产生变化的只有需要被修改的颜色。

    要解决这个问题,可以使用 Angular 提供的 @HostBinding 装饰器,它的语法为:

    @HostBinding('property') propertyName: Type = value;
    
    • 1

    其中:

    • 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';
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    最后展示的效果依旧是一样的

    动态添加属性

    这个时候可以注意到,现在唯一要修改的地方就是颜色,这个情况也可以使用变量去存储这个修改的颜色,同时前面可以添加 @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;
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    在不重写默认颜色时,效果是一样的。

    但是父元素也可以选择重写默认值:

    <p
      appBetterHighlight
      class="bg-transition"
      [defaultColor]="'lightyellow'"
      highlightColor="violet"
    >
      Style me with better directive
    p>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    效果如下:

    在这里插入图片描述

    这里又有两个点需要注意的:

    • highlightColor="violet"

      这是一个特殊的语法缩写,本质上还是一个 property binding,而不是 HTML 所有的原生属性

      我这里特地用了两种写法,只是为了添加一下 note。为了更好的阅读性和理解,还是推荐使用 [customPropertyName]="'value'" 的写法

    • 背景颜色默认为蓝色

      这就是前面提到的 ngOnInit 的作用,这个情况下 Angular 的组件需要经历一个初始化的状态,在这个初始化的状态,它会绑定对应的属性——包括来自外部的属性

      这一段代码里我特地把 ngOnInit 注释掉了,没有了这个初始化的状态,那么当前组件依旧接受默认值,也就是 lightblue,把 ngOnInit 加回去,并添加对应的修改:

      ngOnInit() {
        this.backgroundColor = this.defaultColor;
      }
      
      • 1
      • 2
      • 3

      才能将默认的背景色重写为父元素传进来的 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);
        }
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32

    app 的 V 层:

    <div *appLoading="isLoading">
      some random syntax showing only when loading is false
    div>
    
    • 1
    • 2
    • 3

    VM 层修改变量,以及 CSS 我就不贴代码了

    效果如下:

    在这里插入图片描述

    下面进入实现的分析部分

    TemplateRef

    即绑定 structural directive 的元素,在这个情况下,就是:

    <div *appLoading="isLoading">
      some random syntax showing only when loading is false
    div>
    
    • 1
    • 2
    • 3

    简单的理解就是,当满足特定条件时,这里需要渲染的内容

    ViewContainerRef

    ViewContainerRef 就是管理渲染内容的容器

    Angular 没有 virtual DOM,但是又不想直接暴露 DOM 进行操作,因此就像 ElementRef 一样,它对将整个 视图(view) 进行了一个抽象,创建了 ViewContainerRef 以方便管理与 directive 绑定的,整个 DOM 的 view/template

    setter

    set 是一个 JavaScript 的语法糖,这也是 ES6 后出现的语法,与 Angular 无关。

    这里 Angular 动态的将其 setter 和 @Input 进行绑定,并且提供一个更加直观且简洁明了的方式对当前与 @Input 绑定的值进行变化管理。如果不使用 setter 的话,也可以在对应的 ngOnChangesngOnInit 中监听值的变化,并且进行对应的操作。

    整个 setter 中做的操作就分为两步:

    1. isLoading = true

      这个情况下需要渲染一个 loading spinner——这在构造函数中就已经创建好了,并且使用 renderer 去进行渲染

      当前的 nativeElement 指向的是

      这个具体的元素

      因此这里的操作就是在 nativeElement 的父元素下,新增一个 loading spinner

    2. isLoading = false

      这个情况下 loading spinner 被移除,原本的 template view 被渲染

    💡:至于选用 renderer 就是因为在 attribute directive 部分已经讲过了,而且实现起来比较方便。我找了一下不用 renderer 渲染的方式,需要用到 ComponentFactoryResolver,这个暂时就还没学上,等之后学上了再说

    structural directive 幕后

    如果看了官方文档就会知道,

    {{hero.name}}
    这样的语法是一个缩写,本质上它的实现方法如下:

    <ng-template [ngIf]="hero">
      <div class="name">{{hero.name}}div>
    ng-template>
    
    • 1
    • 2
    • 3

    随后就像上面写过的 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>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    也就是鉴于这样的转化,所以 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)
    
    • 1
    • 2
    • 3

    实现 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');
        }
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    不过一个简化的方法是使用 @HostBinding 去进行操作:

    export class DropdownDirective {
      @HostBinding('class.open') isOpen = false;
    
      @HostListener('click')
      onClick() {
        this.isOpen = !this.isOpen;
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    二者实现的效果是一样的:

    在这里插入图片描述


    补充一下点击 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) {}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    在这里插入图片描述

    reference

    没有特殊标注的都是来自官方文档的内容

  • 相关阅读:
    算法通关村第16关【黄金】| 滑动窗口与堆结合
    注解&反射学习笔记
    速卖通平台的API支持哪些开发语言和工具?
    CefSharp结合VUE3搭建网页资源下载器
    煤炭行业数智化供应商管理系统解决方案:数据驱动,供应商智慧平台助力企业降本增效
    基于Flask+Echarts的中药材价格分析与可视化项目
    【学习笔记】Node--从0基础到实战企业官网
    java入门,登录注册案例
    “遥感新纪元:GPT技术引领地球观测的智慧革新“
    轻松掌握视频制作技巧:批量分割长视频的详细步骤
  • 原文地址:https://blog.csdn.net/weixin_42938619/article/details/136189141