在Angular中具有可配置模板的可重用组件

在 Angular 中使用一些第三方 UI 库的时候,为了满足业务需求,经常会遇到需要通过 ng-template 来传递一些自定义的模板到第三方组件中的情况。比如 ngx-bootstrapTabs 组件,可以自定义 tab 的模板。

<div>
  <tabset>
    <tab>
      <ng-template tabHeading>
        <i><b>Tab 3</b></i>
      </ng-template>
      Tab with html tags in heading
    </tab>
  </tabset>
</div>
复制代码

今天这篇文章会探究一下背后的原理,并且一步步实现一个类似功能的组件。

Counter 组件

首先创建一个最基本的 Counter 组件。这个组件展示当前的值并且可以执行递增和递减的操作。此外,还可以自定义初始值,并且在值更改时发出事件。

初始代码如下:

import {
  Component,
  ChangeDetectionStrategy,
  Input,
  Output,
  EventEmitter,
} from '@angular/core';

@Component({
  selector: 'app-counter',
  templateUrl: './counter.component.html',
  styleUrls: ['./counter.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CounterComponent {
  @Input() value = 0;
  @Output() changed = new EventEmitter<number>();

  increment() {
    this.updateValue('inc');
  }

  decrement() {
    this.updateValue('dec');
  }

  private updateValue(action: 'inc' | 'dec') {
    const delta = action === 'inc' ? 1 : -1;
    this.value += delta;
    this.changed.emit(this.value);
  }
}

复制代码

html 内容如下:

<h2>Counter</h2>
<p class="mb-3">{{ value }}</p>
<div class="btns">
  <button class="mr-3" (click)="increment()">inc</button>
  <button (click)="decrement()">dec</button>
</div>

复制代码

可配置模板

在 Angular 中,可以借助 ng-template 元素创建视图模板。 生成模板后,可以借助内容投影(content projection)将其传递给可重用组件,最终我们会通过下面的写法来使用组件:

<app-custom-counter title="Fancy counter">
  <ng-template appCounterValue let-value>
    <span class="mr-2">Current value: {{value}}</span>
    <i class="fa fa-arrow-left"></i>
  </ng-template>

  <ng-template appCounterIncBtn let-increment>
    <button class="btn btn-success" (click)="increment()">
      inc <i class="fa fa-arrow-up"></i>
    </button>
  </ng-template>

  <ng-template appCounterDecBtn let-decrement>
    <button class="btn btn-danger" (click)="decrement()">
      dec <i class="fa fa-arrow-down"></i>
    </button>
  </ng-template>
</app-custom-counter>
复制代码

通过上面的写法可以看出,每个视图模板都有自己的指令,因此我们可以区分每个 ng-template 属于组件的哪一部分。

首先来实现上面的三个指令,每个指令的内容都很简单:只需要公开一个对视图模板的引用。

export interface CounterValueTplContext {
  $implicit: number;
}

export interface CounterBtnTplContext {
  $implicit: () => void;
}

复制代码
import { Directive, TemplateRef } from '@angular/core';
import { CounterValueTplContext } from './custom-counter.component';

@Directive({
  selector: '[appCounterValue]',
})
export class CounterValueDirective {
  constructor(readonly tpl: TemplateRef<CounterValueTplContext>) {}
}

复制代码
import { Directive, TemplateRef } from '@angular/core';
import { CounterBtnTplContext } from './custom-counter.component';

@Directive({
  selector: '[appCounterIncBtn]',
})
export class CounterIncBtnDirective {
  constructor(readonly tpl: TemplateRef<CounterBtnTplContext>) {}
}

复制代码
import { Directive, TemplateRef } from '@angular/core';
import { CounterBtnTplContext } from './custom-counter.component';

@Directive({
  selector: '[appCounterDecBtn]',
})
export class CounterDecBtnDirective {
  constructor(readonly tpl: TemplateRef<CounterBtnTplContext>) {}
}

复制代码

接下来,我们可以在组件中使用 ContentChild 装饰器获取对视图模板的引用,第一个参数是指令的类名:

import {
  Component,
  ChangeDetectionStrategy,
  Input,
  Output,
  EventEmitter,
  ContentChild,
  TemplateRef,
} from '@angular/core';

import { CounterValueDirective } from './counter-value.directive';
import { CounterIncBtnDirective } from './counter-inc-btn.directive';
import { CounterDecBtnDirective } from './counter-dec-btn.directive';

@Component({
  selector: 'app-custom-counter',
  templateUrl: './custom-counter.component.html',
  styleUrls: ['./custom-counter.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CustomCounterComponent {
  @ContentChild(CounterValueDirective, { static: true })
  counterValueDir!: CounterValueDirective;
  @ContentChild(CounterIncBtnDirective, { static: true })
  counterIncBtnDir!: CounterIncBtnDirective;
  @ContentChild(CounterDecBtnDirective, { static: true })
  counterDecBtnDir!: CounterDecBtnDirective;

  @Input() title = 'Counter';
  @Input() value = 0;
  @Output() changed = new EventEmitter<number>();

  get counterValueTpl(): TemplateRef<CounterValueTplContext> {
    return this.counterValueDir?.tpl;
  }

  get counterIncBtnTpl(): TemplateRef<CounterBtnTplContext> {
    return this.counterIncBtnDir?.tpl;
  }

  get counterDecBtnTpl(): TemplateRef<CounterBtnTplContext> {
    return this.counterDecBtnDir?.tpl;
  }

  get counterValueTplContext(): CounterValueTplContext {
    return { $implicit: this.value };
  }

  get counterIncBtnTplContext(): CounterBtnTplContext {
    return { $implicit: () => this.increment() };
  }

  get counterDecBtnTplContext(): CounterBtnTplContext {
    return { $implicit: () => this.decrement() };
  }

  increment() {
    this.updateValue('inc');
  }

  decrement() {
    this.updateValue('dec');
  }

  private updateValue(action: 'inc' | 'dec') {
    const delta = action === 'inc' ? 1 : -1;
    this.value += delta;
    this.changed.emit(this.value);
  }
}

复制代码

除了获取模板的 getter 之外,还有一些传递给视图模板的上下文对象的 getter,用来给 ng-template 传递数据。

最后在 html 模板中,我们通过 NgTemplateOutlet 指令,将视图模板渲染在组件的适当位置。在大多数第三方 UI 库中,如果没有传递自定义模板,就会使用默认的模板。

<h2>{{title}}</h2>
<div class="mb-3">
  <ng-container *ngTemplateOutlet="counterValueTpl || defaultValueTpl; context:counterValueTplContext">
  </ng-container>
</div>
<div class="d-flex justify-content-center">
  <div class="mr-3">
    <ng-container *ngTemplateOutlet="counterIncBtnTpl || defaultIncBtnTpl; context:counterIncBtnTplContext">
    </ng-container>
  </div>
  <div>
    <ng-container *ngTemplateOutlet="counterDecBtnTpl || defaultDecBtnTpl; context:counterDecBtnTplContext">
    </ng-container>
  </div>
</div>

<ng-template #defaultValueTpl>
  {{value}}
</ng-template>

<ng-template #defaultIncBtnTpl>
  <button (click)="increment()">inc</button>
</ng-template>

<ng-template #defaultDecBtnTpl>
  <button (click)="decrement()">dec</button>
</ng-template>
复制代码

到这里我们就基本实现了一个可以自定义模板的可重用 Angular 组件。

扩展

ng-template

在上面的实现中,传递给 ng-templatecontext 返回了一个对象:

  get counterValueTplContext(): CounterValueTplContext {
    return { $implicit: this.value };
  }
复制代码

这个对象的 $implicit 属性就是 let-value 对应的变量。我们也可以传递多个对象,比如;

get counterValueTplContext(): CounterValueTplContext {
  return { $implicit: this.value, unit: 'million' };
}
复制代码
<ng-template appCounterValue let-value let-unit='unit'>
  <span class="mr-2">Current value: {{ value }} {{ unit }}</span>
  <i class="fa fa-arrow-left"></i>
</ng-template>
复制代码

其中,let-value 没有赋值,会被自动赋值为 $implicit 的值。unit 对应返回的上下文对象中的 unit 属性。

使用 * Syntax

如果只需要传递一个值到 ng-template,可以使用更简化的语法:

<ng-container *appCounterValue="let value">
  <span class="mr-2">Current value: {{value}}</span>
  <i class="fa fa-arrow-left"></i>
</ng-container>
复制代码

string token

在给 ContentChild 装饰器传递第一个参数的时候,也可以传递一个字符串,这个字符串必须是唯一的模板引用变量。

// counter component class
@ContentChild('counterValue', { static: true, read: TemplateRef }) counterValueTpl: TemplateRef<any>;
  
// parent component view
<app-custom-counter title="Fancy counter">
  <ng-template #counterValue let-value>
    <div>
      <span class="mr-2">Current value: {{value}}</span> 
      <i class="fa fa-arrow-left"></i>
    </div>
  </ng-template>
  
  ...
</app-custom-counter>
复制代码

结论

在 Angular 中,针对不同的需求场景可以有多种解决方案。如果我们想重用组件的布局,使用这种方式是比较合适的。如果仅仅是想通过内容投影来传递组件,那么使用组件惰性实例化可能是更好的方式。在实际开发中,需要根据不同的场景选择合适的实现。

参考链接:

Reusable components with configurable templates in Angular

How to use ng-template & TemplateRef in Angular

What is let-* in Angular 2 templates?

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享