3

Angular 入门:指令

 6 months ago
source link: https://blackdn.github.io/2024-03-06-Angular-Intro-Directive-2024/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

“山上层层桃李花,云间烟火是人家。”

Angular:指令 Direvtive

既组件之后是指令,结果发现已经过去一个月了,哈哈哈哈
中间忙着给自己的博客缝缝补补,乐在其中
之后 Angualr 应该还会有一两篇,看看什么时候能憋出来
Angular组件传送门:Angular Component

指令 Directives

在某些情况下,我们需要用一些逻辑处理来选择性渲染我们的组件,比如根据特定条渲染组件、根据现有数据循环展示组件(列表)、根据用户行为或状态来更改组件样式等。
React 中,我们可以直接使用逻辑运算来展示组件,比如“根据用户是否登陆来决定是否弹窗”:isLogin && <NoLoginAlert />
Angualr 中,引入了 指令Directives ,以 HTML元素属性 的形式扩展了HTML的功能,允许我们声明式地向这些元素添加新行为。比如同样是“根据用户是否登陆来决定是否弹窗”的场景,在 Angular 中,我们在模版中可以这样实现:<no-login-alert *ngIf="isLogin"></no-login-alert>

ngIf 用于条件判断,ngFor 用于循环渲染等 ,由于 Angular 会将其中的内容视为 Javascript代码 执行,所以用到啥变量要留个心眼,确保变量在当前环境存在。

<no-login-alert *ngIf="isLogin"></no-login-alert>

<my-list *ngFor="let title of titleList"></my-list>

上面的 let title of titleList 就是Javascript的类似for-each代码,模板中的`` 指代的就是这里的title

不过要注意,ngIf 和ngfor 指令不能在一个元素中使用,会抛出 Can’t have multiple template bindings on one element. 的错误,必要时可以再包一层父组件,分别套用不同的指令,比如使用 <ng-container> 啥的。

自定义指令

自定义指令(Custom Directives) 通常以 directive 为后缀(如 custom-direct.directive.ts )
我们可以用CLI命令快速生成指令文件:ng generate directive highlightAngular 会自动帮我们创建 highlight.directive.ts 等一系列文件。
指令用@Directive标识,其中包含selector标识,并且导出一个 TypeScript 类

// hightlight.directive.ts
@Directive({
  selector: '[needHighlight]',
})
export class HighlightDirective {
  constructor(private el: ElementRef) {
    this.el.nativeElement.style.backgroundColor = 'yellow';
  }
}

上述指令会将包含的元素文字变为黄色以高亮显示,使用的时候在HTML标签中加入selector中的标识 needHighlight 即可

<p needHighlight>I am highlight.</p>

指令的分类

简单来说,我们可以将指令分为 内置指令(Built-in Directives)自定义指令,但这样分类未免有点脱裤子放屁的嫌疑
所以还是跟着官方文档,将指令分成这三类:

类型 描述
组件 带有模板的指令,最为常见
属性型指令 这类指令会更改元素、组件、或其他指令的外观或行为
结构型指令 这类指令通过添加和删除元素来更改页面布局。

是的没有错,我们之前学习了那么久的组件,官方将其视为指令的一个分类,即组件是一种特殊的指令
关于组件就不过多介绍了,让我们来看看属性型指令结构型指令
如果遇到常见的内置指令,会顺便介绍一下=v=

属性型指令

属性指令(Attribute Directives) 用以更改元素的外观或行为,比如上面我们自定义的 HighlightDirective 就是一个典型的属性型指令。
我们还可以在 HighlightDirective 基础上进行扩展,实现鼠标 hover 时设置高亮颜色:

@Directive({
  selector: '[needHighlight]',
})
export class HighlightDirective {
  constructor(private el: ElementRef) {}

  @HostListener('mouseenter') onMouseEnter() {
    this.highlight('yellow');
  }
  @HostListener('mouseleave') onMouseLeave() {
    this.highlight('');
  }

  private highlight(color: string) {
    this.el.nativeElement.style.backgroundColor = color;
  }
}

@HostListener('mouseenter') onMouseEnter() 表示鼠标悬停时执行的方法监听,这里我们调用 highlight() 方法将文字背景高亮,同理,在鼠标离开时,@HostListener('mouseleave') onMouseLeave() 取消背景色的高亮。

再进一步,我们将上述代码的颜色常量提取出来,成为一个变量, 这样就可以从外部传递,将高亮颜色作为可控变量。

@Directive({
  selector: '[needHighlight]',
})
export class HighlightDirective {
  @Input() needHighlight = '';
  constructor(private el: ElementRef) {}

  @HostListener('mouseenter') onMouseEnter() {
    this.highlight(this.needHighlight);
  }
  @HostListener('mouseleave') onMouseLeave() {
    this.highlight('');
  }

  private highlight(color: string) {
    this.el.nativeElement.style.backgroundColor = color;
  }
}

传入的变量名可以和指令的标识同名,这里都是 needHighlight,使用的时候可以一步到位,给组件指定指令的同时传入变量,算是个小小的语法糖吧:

<!-- highlightColor = "red" -->
<p [needHighlight]="highlightColor">I am highlight.</p>

其中的 highlightColor 就是属于父组件的外部变量了
如果还有更多的传入属性,可以继续在后面加,比如我们再加一个默认颜色的传入变量:

  // ...
  @Input() appHighlight = '';
  @Input() defaultHighlightColor = '';

  @HostListener('mouseenter') onMouseEnter() {
    this.highlight(this.appHighlight || this.defaultHighlightColor);
  }
  //...
}
<!-- defaultColor = "yellow" -->
<p appHighlight [defaultHighlightColor]="defaultColor">I am highlight.</p>

内置属性型指令

常见的内置属性型指令有这些,我们来看看他们的功能和使用方式

指令 作用
NgClass 添加或删除CSS类
NgStyle 添加或删除一组 HTML 样式
NgModel 为表单属性添加双向数据绑定

NgClass

NgClass 其实就是为组件添加 class 属性:

<!-- isError = true -->
<div [ngClass]="isError ? 'errorText' : 'normalText'">Hello World</div>

上述例子中,最后咱们的 <div> 会获得 errorText 属性,如果我们在 css 里设置了:

.normalText {
  color: black;
}

.errorText {
  color: red;
}

最后咱们的 Hello World 就会变成红色(color: red

此外,我们可以通过键值对的方式,来让 NgClass 同时设置多个 class

// in ts file
  myClasses: Record<string, boolean> = {};

  constructor() {
    this.initClasses();
  }

  initClasses() {
    this.myClasses = {
      invisible: false,
      titleText: true,
      errorText: true,
    };
  }

可以看到,我们设置了一个名为 myClassesRecord 对象,代表键为 string,值为 boolean 的 一系列键值对。
ngClass 接收这个对象后,其中的键就代表 css 的类名,如果其值为 true,则添加这个类名;如果为 false,则删除这个类名
在实践中,通常会把 initClasses() 方法放到 ngOnInit() 中执行,这里为了方便演示放在构造方法中

<div class="invisible" [ngClass]="myClasses">Hello World</div>

上面这个例子中,虽然 <div> 一开始有 invisible 的类名,但是由于在 myClasses 我们设置了 invisible: false,所以实际上这个类名会被删除。最后这个 <div> 的类名则为 titleTexterrorText

NgStyle

NgClass 是为元素添加 class 属性,而 NgStyle 则是直接为元素添加 css 样式:

  myStyles: Record<string, string> = {};

  constructor() {
    this.initStyles();
  }

  initStyles() {
    this.myStyles = {
      'font-weight': this.isTitle ? 'bold' : 'normal',
      'color': this.isError ? 'red' : 'black'
    };
  }

记得在模版中绑定 ngStyle 属性:

<div [ngStyle]="myStyles">Hello World</div>

NgModel

按照官方文档的话来说,NgModel 是这样的:

根据领域对象创建一个 FormControl 实例,并把它绑定到一个表单控件元素上。
这个 FormControl 实例将会跟踪值、用户交互和控件的验证状态,以保持视图与模型的同步。 如果用在某个父表单中,该指令还会把自己注册为这个父表单的子控件。

一下子可能看晕了,别着急,我也看不懂=。=
简单来说,就是说这个 NgModel 常和表单一起使用,用于双向数据绑定

举个栗子🌰,我们有一个属性 name 以及修改它的方法:

/**
@Component({
  standalone: true,
  imports: [..., FormsModule],
})
**/

  name: string = '';
  setName() {
    this.name = 'BlackDn';
  }

这里要注意一点,如果用的是 Standalone 代码结构,直接 import NgModel 可能会报错,因为 NgModel 不是 standalone 模块,可以导入 FormsModule 代替。
然后在模板中,有如下内容:

<input
  [(ngModel)]="name"
  #model="ngModel"
  required />
<p>Value: </p>
<p>Value: </p>
<button (click)="setName()">Set value</button>
NgModel Demo

我们将这个 namengModel 双向绑定,并且将 ngModel 赋予了本地变量 model
因为 NgModel 有很多继承自 NgControlAbstractControlDirective 的属性,如 valuevaliddisabled 等。通过这个本地变量,我们能轻松地在其他地方获取这些变量,就好比我们后面的 model.valid
由于 NgModel 所在的 <input> 添加了 required 约束,所以当我们啥也不填的时候,不满足这个约束,model.valid = false。一旦填了啥内容,约束满足,model.valid 就变成了 true
此外,双向数据绑定也很直观,name 的值随着我们在 input 填入内容而同步修改,是一个方向的绑定;我们点击按钮后,执行 setName 方法修改 name,同时也修改了我们 input 里的内容,又是一个方向的绑定。

简单来说,NgModel 指令可以显示数据属性,并在用户进行更改时更新该属性。(数据绑定嘛,这里的 Model 应该就是代表 MVC 里的 Model

结构型指令

结构型指令(Structural directives) 是通过添加和删除 DOM 元素来更改 DOM 布局的指令,也就是直接通过渲染/不渲染元素来影响页面的布局和内容。

结构型指令常以星号*作为前缀,我们的 NgIfNgFor 就是典型的结构型指令。
这个星号*前缀实际上是一种简写,Angular 会将星号转换为 <ng-template> 并包裹内部元素:

<!-- 我们写的 -->
<div *ngIf="showTitle" class="titleText"></div>

<div
  *ngFor="let item of itemList; let i=index; let odd=odd; trackBy: trackById"
  [class.odd]="odd">
  (): 
</div>

<!-- 转换后的 -->
<ng-template [ngIf]="showTitle">
  <div class="titleText"></div>
</ng-template>

<ng-template ngFor let-hero [ngForOf]="itemList"
  let-i="index" let-odd="odd" [ngForTrackBy]="trackById">
  <div [class.odd]="odd">
    ({{i}}): {{item.content}}
  </div>
</ng-template>

我们发现,结构指令相关的内容会被移到 <ng-template> 上,而其他属性都保留在原元素上。

不过随之而来的一个问题是,当一个元素遇到多个结构型指令,Angular 并不能判断应该优先执行哪一个指令。
比如我将 *ngFor*ngIf 放在同一个元素上,我是应该 判断是否要渲染(if) -> 循环渲染全部内容(for),还是应该 直接进入循环(for) -> 判断每个item是否要渲染(if)
为了避免这种情况,Angular 采取了最简单粗暴的方式:规定一个元素只能有一个结构型指令(One structural directive per element ) 。这也是为什么我们在前面提到,ngIf 和ngfor 在一个元素中使用,会抛出 Can’t have multiple template bindings on one element. 的错误。 因此,当遇到上述情况的时候,Angular 更推荐使用 <ng-container>,在执行指令的同时,避免无用的元素渲染。

自定义结构型指令

我们打算写一个和 *ngIf 相反的指令,即 HideDirective。当其传入的值为 true 时,隐藏其内容,否则正常显示。
我们通过 CLI 命令 ng generate directive hide 自动创建了 hide.directive.ts ,其中导出了 HideDirective

@Directive({
  selector: '[appHide]'
})
export class HideDirective {
  constructor() { }
}

因为比较简单,所以直接给出结果,然后再逐步分析:

export class HideDirective {
  private hasView = false;

  constructor(
	private viewContainer: ViewContainerRef,
    private templateRef: TemplateRef<any>
  ) {}

  @Input() set appHide(shouldHide: boolean) {
    if (shouldHide && this.hasView) {
      this.viewContainer.clear();
      this.hasView = false;
    } else if (!shouldHide && !this.hasView) {
      this.viewContainer.createEmbeddedView(this.templateRef);
      this.hasView = true;
    }
  }
}

首先,我们加了一个 hasView 的私有变量,用于判断当前组件是否已经被渲染出来。
其次,在构造方法中,我们引入了一个 ViewContainerRefTemplateRef 。前者是一个视图容器,可以动态地创建或销毁视图;而后者是一个视图模板,需要通过 ViewContainerRef 来将模板实例化,从而插入到 DOM 中,在页面上展示。就像后面使用的这样:this.viewContainer.createEmbeddedView(this.templateRef)
因为结构型指令本质是在元素外包裹一个 <ng-template>,所以这里的 ViewContainerRef 代表的模板就是我们的元素内容

然后,我们定义了一个输入变量 appHide,它和当前指令的 selector 同名,并且使用 set 标识,让指令接受到这个变量后执行操作,不用写额外的方法进行处理。
操作内容主要是对 appHide 的值(即 shouldHide)和 hasView 进行判断:如果当前组件已经展示又需要隐藏,就调用 this.viewContainer.clear() 将其隐藏;如果当前组件已经隐藏又需要展示,就通过 ViewContainerRefTemplateRef 将其渲染。

用的时候,为元素添加 *appHide 属性即可:

<!-- 隐藏元素 -->
<div *appHide="true">Hello World</div> 
<!-- 显示元素 -->
<div *appHide="false">Hello World</div> 

内置结构型指令

指令 作用
NgIf 判断是否渲染元素
NgFor 根据数据循环渲染元素
NgSwitch 切换选择元素

这几个常见的内置结构型指令中,NgIfNgFor 就不多介绍了,我们来看一下 NgSwitch

NgSwitch

NgSwitch 对标的是 switch-case-default 语句块,相对应的,除了 ngSwitch 属性外,我们还需要结合 NgSwitchCaseNgSwitchDefault
先举例子再讲解:

  <div [ngSwitch]="author">
	<p *ngSwitchCase="'BlackDn'">Author: BlackDn</p>
	<p *ngSwitchCase="'Mike'">Author: Mike</p>
	<p *ngSwitchCase="'Daylight'">Author: Daylight</p>
	<p *ngSwitchDefault>Author: Unknown</p>
  </div>

首先我们为最外层的 <div> 添加了 [ngSwitch]="author",这个 author 是一个变量,我们称之为 开关值(switch value)。 然后为其中的元素添加 *ngSwitchCase*ngSwitchDefault
如果某个 *ngSwitchCase 的值和开关值相同,那么就会显示对应的元素,其他元素则不显示;如果没有 *ngSwitchCase 能匹配上,那么就显示 *ngSwitchDefault 的元素。
比如我是 author: string = 'BlackDn',那么就显示 <p *ngSwitchCase="'BlackDn'">Author: BlackDn</p>,如果是 BlackDnMikeDaylight之外的值,就显示 <p *ngSwitchDefault>Author: Unknown</p>

指令组合API

指令组合API(Directive Composition API)Angular 15 推出的新特性,简单来说,它允许我们在一个组件/指令中组合多个其他的指令。不过,只有使用独立结构,即声明 standalone: true 的指令才能被组合。

在组件装饰器 @Component 中,我们可以用 hostDirectives 指定当前元素应用的指令,这些指令会被组合成一个指令应用在当前组件上,从而不需要在模板中声明
我们称 hostDirectives 组合而成的指令为宿主指令,成其应用的组件元素为宿主元素

// in BoldDirective
@Directive({
  selector: '[appBold]',
  standalone: true, // 必须是true
})
export class BoldDirective {
  constructor(private elementRef: ElementRef) {
    this.elementRef.nativeElement.style.fontWeight = 'bold';
  }
}

// in component
@Component({
  selector: 'app-my-text',
  template: `<p>my-text works!</p>`,
  hostDirectives: [BoldDirective], // 使用hostDirectives
})
export class MyTextComponent {}
//等价于:
@Component({
  selector: 'app-my-text',
  imports: [BoldDirective], //因为采用standalone结构,需要imports导入
  template: `<p appBold>my-text works!</p>`, // 在模板中添加selector
})
export class MyTextComponent {}

单单一个指令没什么好组合的,所以我们多加一个 TextColorDirective

// in TextColorDirective
@Directive({
  selector: '[appTextColor]',
  standalone: true,
})
export class TextColorDirective {
  constructor(private elementRef: ElementRef) {
    this.elementRef.nativeElement.style.color = 'green';
  }
}

// in component
@Component({
  selector: 'app-my-text',
  template: `<p>my-text works!</p>`,
  hostDirectives: [BoldDirective, TextColorDirective],
})
export class MyTextComponent {}

效果就是,我们的 <p>my-text works!</p> 既被设置了 font-weight = 'bold' 又被设置了 color = 'green',两个指令同时作用在当前组件上。

组合指令的输入/输出属性

首先,我们修改一下 TextColorDirective,让它的颜色从外界接收:

// in TextColorDirective
export class TextColorDirective {
  constructor(private elementRef: ElementRef) {}
  
  @Input() set textColor(color: string) {
    this.elementRef.nativeElement.style.color = color || 'green';
  }
}

我们通过 @Input() set textColor 来从外部接收一个颜色,如果外部没指定颜色就给个默认值 'green'
这里之所以要用 setter 而不继续放在构造方法中,是因为构造方法只执行一次,且会在属性传入前执行。这会导致我们的颜色传不进来,一直是默认值绿色。

由于默认情况下,回到 MyTextComponent,我们扩展一下 hostDirectives

@Component({
  // ...
  hostDirectives: [
    BoldDirective,
    {
      directive: TextColorDirective,
      inputs: ['textColor'],
    },
  ], 
})
export class MyTextComponent {}

我们用一个对象来包裹,directive 指定指令,inputs 指定输入变量,在父组件中就可以通过inputs 指定的变量来传入参数:

  <app-my-text [textColor]="'red'"></app-my-text>

当然支持别名,可以改成 inputs: ['textColor: color'],那么父组件中就用后面的别名 color

  <app-my-text [color]="'red'"></app-my-text>

当然还可以用 outputs 指定输出的回调方法:

// in TextColorDirective
export class TextColorDirective {
  // ...
  @Output() log = new EventEmitter();
  ngOnInit(): void {
    setTimeout(() => {
      this.log.emit();
    }, 3000);
  }
}

// in component
@Component({
  // ...
  hostDirectives: [
    BoldDirective,
    {
      directive: TextColorDirective,
      inputs: ['textColor: color'],
      outputs: ['log']
    },
  ], 
})
export class MyTextComponent {}

因为 Directive 没有UI界面,所以这里的回调就用 setTimeout 延迟执行。
在父组件中用的时候也同样是通过 outputs 绑定的 log 传递:

// in father component
@Component({
  template: `<app-my-text [color]="'red'" (log)="onLog()"></app-my-text>`,
})
export class FatherComponent {
  onLog() {
    console.log('message from father');
  }
}

然后,只要绑定指令的宿主元素被渲染出来,就会执行对应的回调方法,3秒后会在 console 看到 'message from father'

简单来说,通过 组合指令API,我们让指令的特性成为组件的特性,不再需要给元素绑定指令的 selector,我们可以将更多功能解构成指令,再通过指令去组合,为各个组件添加功能,让代码更容易复用,更简洁,维护更高效。

组合出一个新的指令

既然 组件是一种特殊的指令,那么我们也可以将多个指令组合成一个新的指令:

@Directive({
  selector: '[appBoldTextWithColor]',
  hostDirectives: [
    BoldDirective,
    {
      directive: TextColorDirective,
      inputs: ['textColor'],
    },
  ],
})
export class BoldTextWithColorDirective {}

我们这里将 BoldDirectiveTextColorDirective 组合成一个新的指令 BoldTextWithColorDirective,在组件中,只需要指定这个新的指令,就可以同时应用两个指令的功能:

@Component({
  selector: 'app-my-text',
  template: ` <p>my-text works!</p> `,
  hostDirectives: [BoldTextWithColorDirective],
})
export class MyTextComponent {
  //...
}

在父组件中引用子组件时,我们根据 BoldTextWithColorDirective 中的内容,传入 textColor 即可:

<!-- 保持不变 -->
<app-my-text [textColor]="'red'"></app-my-text>

指令组合API小结

指令组合API(Directive Composition API) 这个特性的提出,是响应了 Github 上的一个 Proposal IssueProposal: Need ability to add directives to host elements in component declaration.
内容比较复杂,简单来说就是一个组件想要集成多个指令的功能,如果不使用指令组合API,通常我们会尝试以下方法:

  • 继承:由于 Typescript 不支持多类继承(只能继承一个类),所以组合多个指令就比较困难
  • 硬着头皮写一个新的指令,但是这多少会带来代码重复,不好不好
  • 使用 MixinMixin模式 常用来实现多重继承,在各个语言都可以看到它的身影,虽然 Mixin 一定程度上能够满足我们的需求,但是需要花一定的成本学习 Mixin 语法,还要不停地为代码打上 @mixin 标识,同样不够优雅

因此,指令组合API(Directive Composition API) 用更简洁的语法带来了更强大的扩展性,给我的感觉就是让指令成为类似 Java接口Interface 的一样的存在,让我们能够自由组合,非常的面向对象。



About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK