Angular

[Angular] 動態元件

周志衠 Jed Jhou 2020/11/16 16:01:42
1282

[Angular] 動態元件

Angular 開發上我們有常會使用 ngIf 控制元件或片段的內容檢視,大多情境簡單情況下使用是沒問題,而有時候需求上我們會遇到比較複雜的情況,例如廣告顯示。

廣告通常有很多種類型,像是簡單的靜態圖片,或是使用輪播方式呈現,在更複雜一點是商品頁面和活動頁面會隨著需求動態調整成靜態圖片顯示獲輪播方式顯示廣告,使用 ngIf 做顯示的控制會讓程式碼過於複雜且分散不同的頁面元件上不好維護,這時候我們可以使用官方提供的 ComponentFactoryResolver API 來達成動態替換元件功能。

範例環境準備

  • Angular 10.2.0
  • primeng 10.0.3

顯示元件

首先建立兩個 banner 元件來演示情境,第一個 banner 元件會套用 primeng 的 carousel 元件套用輪播效果,第二個單純顯示靜態圖片內容。

banner1.component.html

<p-carousel [value]="cars" styleClass="custom-carousel" [numVisible]="3" [numScroll]="1" [circular]="true"
  [autoplayInterval]="3000" [responsiveOptions]="responsiveOptions" (onPage)="banner1PageChange($event)">
  <ng-template let-car pTemplate="item">
    <div class="car-details">
      <div class="p-grid p-nogutter">
        <div class="p-col-12 car-data">
          <div class="car-title">{{car.brand}}</div>
          <div class="car-subtitle">{{car.year}} |&nbsp;{{car.color}}</div>
        </div>
      </div>
    </div>
  </ng-template>
</p-carousel>

banner1.component.ts

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

@Component({
  selector: 'app-banner1',
  templateUrl: './banner1.component.html',
  styleUrls: ['./banner1.component.scss']
})
export class Banner1Component implements OnInit {

  @Input() data: any;
  @Output() banner1PageChangeEmit = new EventEmitter();
  cars: any[];
  responsiveOptions: any;

  constructor() {

    this.responsiveOptions = [
      {
        breakpoint: '1024px',
        numVisible: 3,
        numScroll: 3
      },
      {
        breakpoint: '768px',
        numVisible: 2,
        numScroll: 2
      },
      {
        breakpoint: '560px',
        numVisible: 1,
        numScroll: 1
      }
    ];
  }

  ngOnInit(): void {
    this.cars = this.data.cars;
  }

  public banner1PageChange($event: any): void {

    // 發送事件
    this.banner1PageChangeEmit.emit($event);
  }
}

banner2.component.html

<img src="{{imgUrl}}" alt="">

banner2.component.ts

import { Component, OnInit, Input } from '@angular/core';

@Component({
  selector: 'app-banner2',
  templateUrl: './banner2.component.html',
  styleUrls: ['./banner2.component.scss']
})
export class Banner2Component implements OnInit {
  @Input() data: any;

  imgUrl: string;
  constructor() { }

  ngOnInit(): void {
    this.imgUrl = this.data.imageUrl;
  }
}

建立 banner 介面與類別

banner-item.ts

import { Type } from '@angular/core';

export class BannerItem {

  constructor(public component: Type<any>, public data: any) { }
}

ibanner.ts

export interface IBanner {
  data: any;
}

建立 banner service

透過 banner service 取得對應元件,未來有新的元件在此擴充即可,可以保持 view 的內容不會太過雜亂。

banner.service.ts

import { Injectable } from '@angular/core';
import { BannerItem } from './banner-item';
import { Banner1Component } from './banner1/banner1.component';
import { Banner2Component } from './banner2/banner2.component';

@Injectable({
  providedIn: 'root'
})
export class BannerService {

  constructor() { }

  getBanners(bannerType: string, data: any): BannerItem {
    switch (bannerType) {
      case 'banner1':
        return new BannerItem(Banner1Component, data);

      case 'banner2':
        return new BannerItem(Banner2Component, data);

      default:
        break;
    }
  }
}

建立 Directive

建立一個 Directive 並注入 ViewContainerRef 定義一個錨點告訴 Angular 要把元件插入在這裡。

banner.directive.ts

import { Directive, ViewContainerRef } from '@angular/core';

@Directive({
  selector: '[appBanner]'
})
export class BannerDirective {

  constructor(public viewContainerRef: ViewContainerRef) { }
}

建立容器元件

先前有提到我們想要在不同類型的 banner 中切換,接著建立一個 base-banner 元件當作抽象的 banner 容器,之後元件的變換都在這裡進行。

base-banner.component.html

<ng-template appBanner></ng-template>

ng-template 元素就是剛才製作的指令將應用到的地方。
我們將剛剛製作的 BannerDirective 的 selector “appBanner” 套用上,Angular 就會知道我們的動態元件要載入在此。

解析元件內容

這裡接收了輸入屬性 banner 物件,我們使用 Angular 提供的 ComponentFactoryResolver 解析產生元件實體。
使用 @ViewChild 取得剛剛建立的 Directive,先執行一次 clear() 清除舊的內容,再使用 createComponent() 建立新的元件,即達成動態建立元件目的。

import { Component, ComponentFactoryResolver, Input, OnInit, OnChanges, ViewChild } from '@angular/core';
import { BannerItem } from '../banner-item';
import { BannerDirective } from '../banner.directive';
import { Banner1Component } from '../banner1/banner1.component';
import { IBanner } from '../ibanner';

@Component({
  selector: 'app-base-banner',
  templateUrl: './base-banner.component.html',
  styleUrls: ['./base-banner.component.scss']
})
export class BaseBannerComponent implements OnInit, OnChanges {

  @Input() banner: BannerItem;
  @ViewChild(BannerDirective, { static: true }) appBanner: BannerDirective;

  constructor(private componentFactoryResolver: ComponentFactoryResolver) { }

  ngOnInit(): void {
    this.loadBannerComponent();
  }

  ngOnChanges(): void {
    // 一旦檢測到該元件或指令的輸入屬性發生了變化,Angular 就會呼叫它的 ngOnChanges() 方法。
    this.loadBannerComponent();
  }

  loadBannerComponent(): void {

    const bannerItem = this.banner;
    const componentFactory = this.componentFactoryResolver.resolveComponentFactory(bannerItem.component);

    const viewContainerRef = this.appBanner.viewContainerRef;
    viewContainerRef.clear();

    const componentRef = viewContainerRef.createComponent(componentFactory);

    // 將資料傳遞給元件
    (componentRef.instance as IBanner).data = bannerItem.data;

    if (componentRef.componentType === Banner1Component) {

      // 接收元件內事件
      componentRef.instance.banner1PageChangeEmit.subscribe((pageIndex: any) => {
        console.log(pageIndex);
      });
    }
  }
}

組合

於 app.component 套用結果

app.component.html

<div class="p-d-inline-flex">
  <app-base-banner [banner]="banner"></app-base-banner>
</div>
<div class="p-d-inline-flex">
  <p-radioButton name="group1" value="banner1" label="Type1" [(ngModel)]="bannerType" inputId="preopt1">
  </p-radioButton>
  <p-radioButton name="group1" value="banner2" label="Type2" [(ngModel)]="bannerType" inputId="preopt2">
  </p-radioButton>
</div>

app.component.ts

import { Component, OnInit } from '@angular/core';
import { BannerItem } from './banner/banner-item';
import { BannerService } from './banner/banner.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
  title = 'ngDynamicComponentDemo';

  banner: BannerItem;
  private _bannerType = 'banner1';
  private banner1Data = {
    bannerType: 'banner1',
    cars: [
      { brand: 'VW', year: 2012, color: 'Orange' },
      { brand: 'Audi', year: 2011, color: 'Black' },
      { brand: 'Renault', year: 2005, color: 'Gray' },
      { brand: 'BMW', year: 2003, color: 'Blue' },
      { brand: 'Mercedes', year: 1995, color: 'Orange' },
      { brand: 'Volvo', year: 2005, color: 'Black' },
      { brand: 'Honda', year: 2012, color: 'Yellow' },
      { brand: 'Jaguar', year: 2013, color: 'Orange' },
      { brand: 'Ford', year: 2000, color: 'Black' },
      { brand: 'Fiat', year: 2013, color: 'Red' }
    ]
  };

  private banner2Data = {
    bannerType: 'banner2',
    imageUrl: 'https://fakeimg.pl/350x200/?text=Hello',
  };

  private data: any;

  get bannerType() {
    return this._bannerType;
  }

  set bannerType(value) {
    this._bannerType = value;
    this.setDynamicBannerComponent();
  }

  constructor(private bannerService: BannerService) { }

  ngOnInit(): void {
    this.setDynamicBannerComponent();
  }

  setDynamicBannerComponent(): void {

    switch (this._bannerType) {
      case 'banner1':
        this.data = this.banner1Data;
        break;

      case 'banner2':
        this.data = this.banner2Data;
        break;
      default:
        break;
    }

    this.banner = this.bannerService.getBanners(this._bannerType, this.data);
  }
}

完整範例

參考資料

周志衠 Jed Jhou