利用Angular動態元件,製作高彈性客製化頁面! (上)

利用 Angular 動態元件,製作高彈性客製化頁面! (上)

前言

大家好! 不知道各位有沒有看過儀表板 (Dashboard) 類型的網頁?
主要是由多個圖表或是數據視覺化的元件組成的頁面
例如這種:

(來源: ngx-admin)

我接觸到的業務會大量製作這類型的網頁
隨著遇到的需求越來越複雜,用到的功能也越來越進階
遇到比較特別的需求是
希望可以讓使用者自訂要顯示哪一些圖表,客製化屬於自己想看的頁面

這時候「先在 HTML 寫好要顯示什麼內容」這種常規的方式就沒有辦法滿足需求了
更不用說需求往往會在功能做完之後才出現 :)
所以本系列會和大家分享如何用 Angular 製作高彈性的頁面

本篇最後完成的結果如下:

https://huskylin.github.io/dynamic-component-demo-1/

了解這個功能後
也可以達成像是 CakeResume 的客製化履歷、 Wix 的拖拉式網站功能
(都是由小元件拖拉而成)

核心的知識會用到:

  1. ViewContainerRef
  2. ComponentFactory
  3. ComponentRef

本篇會先一步一步帶大家做出動態新增元件這個功能

架構

要做的部分可以拆成兩部分來理解

第一部分,要在頁面上的哪個地方來產生元件?

  1. 透過 directive 在 .html 檔 上標示之後要產生元件的地方
  2. 透過 ViewChild 或是 ViewChildren 來尋找標記的位置

    註: 如果只有一個地方要生成,用 ViewChild 即可
    如果有多個才需要 ViewChildren ( 陣列版的 ViewChild )

第二部分,製作要動態生成的元件

  1. 注入 ComponentFactoryResolver
  2. 用 resolveComponentFactory 方法,產生對應的元件

最後則是將這兩部分合併起來
在找到標記的位置上,將產生出的元件放進來

程式碼

先製作 Directive

dynamic-component-host.directive.ts

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

@Directive({
  selector: '[appDynamicComponentHost]',
})
export class DynamicComponentHostDirective {
  public viewContainerRef = this._viewContainerRef;
  constructor(private _viewContainerRef: ViewContainerRef) { }
}

第一部分,找到要標記要生成的位置

custom.component.ts

// 透過 ViewChild 或是 ViewChildren 來尋找標記的位置
@ViewChildren(DynamicComponentHostDirective) dcHosts: QueryList<DynamicComponentHostDirective>;
constructor() { }

createComponent(component) {
    this.dcHosts.forEach(host => {
      // 要拿來放元件,view 的參照位置
      const vcRef = host.viewContainerRef;
    });
  }

第二部分,製作要動態生成的元件

custom.component.ts

// 透過 ViewChild 或是 ViewChildren 來尋找標記的位置
@ViewChildren(DynamicComponentHostDirective) dcHosts: QueryList<DynamicComponentHostDirective>;
  constructor(
    private cfr: ComponentFactoryResolver,
    private utilsService: UtilsService,
  ) { }

createComponent(component) {
    this.dcHosts.forEach(host => {
      // 要拿來放元件,view 的參照位置
      const vcRef = host.viewContainerRef;
      // 從全部的元件中,選擇要製作出來的元件
      const allComponents = this.utilsService.getAllComponents();
      const targetComponent: any = allComponents.filter(e => e.name === component.name)[0].val;
      // 用 resolveComponentFactory 方法,產生對應的元件
      const componentFactory = this.cfr.resolveComponentFactory(targetComponent);
    });
  }

最後產生出元件

custom.component.ts

// 透過 ViewChild 或是 ViewChildren 來尋找標記的位置
@ViewChildren(DynamicComponentHostDirective) dcHosts: QueryList<DynamicComponentHostDirective>;
  constructor(
    private cfr: ComponentFactoryResolver,
    private utilsService: UtilsService,
  ) { }

createComponent(component) {
    this.dcHosts.forEach(host => {
      // 要拿來放元件,view 的參照位置
      const vcRef = host.viewContainerRef;
      // 從全部的元件中,選擇要製作出來的元件
      const allComponents = this.utilsService.getAllComponents();
      const targetComponent: any = allComponents.filter(e => e.name === component.name)[0].val;
      // 用 resolveComponentFactory 方法,產生對應的元件
      const componentFactory = this.cfr.resolveComponentFactory(targetComponent);
      // 在要正確的位置上,產生正確的元件
      const targetRef = vcRef.createComponent(componentFactory);
    });
  }

稍作延伸

上面的步驟可以簡單的做出動態產生的功能
個人在實際應用上,多做了一些延伸

  1. 製作一個容器元件,由容器元件來決定尺寸大小
    (因為原本的圖表元件大小為 height: 100%, width: 100%)
    這樣同一個元件在不同頁面中可以套用不同大小,使用上會更有彈性
  2. 利用 renderer2 替動態產生出來的元件,加上 CSS class
  3. 儲存產生的 componentRef,以供之後刪除、修改

1. 容器元件

custom.component.ts

承上
...
// 可以自訂不同尺寸的容器元件
wrappers = {
    small: SmallComponent,
    medium: MediumComponent,
    big: BigComponent,
};
createComponent(component) {
      ...
      // 製作被選擇要出來的元件,targetComponent 目標功能元件, wrapperComponent 決定大小的外框元件
      const allComponents = this.utilsService.getAllComponents();
      const targetComponent: any = allComponents.filter(e => e.name === component.name)[0].val;
      const wrapperComponent: any = this.wrappers.small;
      const wrapperFactory = this.cfr.resolveComponentFactory(wrapperComponent);
      const componentFactory = this.cfr.resolveComponentFactory(targetComponent);
      // 先產生目標圖表元件
      const targetRef = vcRef.createComponent(componentFactory);
      // 在參照位置產生外框元件,並且透過 ng-content,將目標圖表元件放進來
      const wrapperRef = vcRef.createComponent(wrapperFactory, vcRef.length, undefined, [[targetRef.location.nativeElement]]);
      }

關於在 viewContainerRef.createComponent() 時使用 ng-content
可以進一步參考這篇 SatckOverflow
這可以讓你動態產生的元件做出更複雜的變化

2. 加上 CSS class

renderer2 可以幫助我們在事後才替元件加上 class、改變樣式
用在動態元件上面也是很方便

承上
...
// 注入renderer2
constructor(private renderer2: Renderer2) { }

createComponent(component) {
      ...
      const wrapperRef = vcRef.createComponent(wrapperFactory, vcRef.length, undefined, [[targetRef.location.nativeElement]]);
      // 用 renderer2 在產生出來的元件上 加上 class
      this.renderer2.addClass(wrapperRef.location.nativeElement, YOUR_CLASSNAME_HERE);
}

3. 儲存產生的 componentRef

替每次產生出來的元件加上 uniqueKey
方便之後在頁面上動態刪除元件
(這邊另外開一個 custom.service 來操作)

承上
...
// 簡單用一個 counter 來作為 uniqueKey
childUniqueKey: number = 0;

createComponent(component) {
      ...
      // 在元件的instance內增加一個 uniqueKey 屬性,用來記錄唯一值
      wrapperRef.instance['uniqueKey'] = ++this.childUniqueKey;
      // 儲存 wrapper 跟 target 的 ref,之後更新資料、刪除元件時會用
      this.customService.pushWrapperRefs(wrapperRef);
      this.customService.pushChartRefs(targetRef);
}

綜合以上的程式碼

  createComponent(component, isNew) {
    this.dcHosts.forEach(host => {
      // 要拿來放元件,view 的參照位置
      const vcRef = host.viewContainerRef;
      // 製作被選擇要出來的元件,targetComponent 目標功能元件, wrapperComponent 決定大小的外框元件
      const allComponents = this.utilsService.getAllComponents();
      const targetComponent: any = allComponents.filter(e => e.name === component.name)[0].val;
      const wrapperComponent: any = this.wrappers['small'];
      const wrapperFactory = this.cfr.resolveComponentFactory(wrapperComponent);
      const componentFactory = this.cfr.resolveComponentFactory(targetComponent);
      // 先產生目標圖表元件
      const targetRef = vcRef.createComponent(componentFactory);
      // 在參照位置產生外框元件,並且透過 ng-content,將目標圖表元件放進來
      const wrapperRef = vcRef.createComponent(wrapperFactory, vcRef.length, undefined, [[targetRef.location.nativeElement]]);
      // 用 renderer2 在產生出來的元件上 加上 class
      this.renderer2.addClass(wrapperRef.location.nativeElement, YOUR_CLASSNAME_HERE);
      // 記錄 component 唯一值
      wrapperRef.instance['uniqueKey'] = ++this.childUniqueKey;
      // 儲存 wrapper 跟 target 的 ref,之後更新資料、刪除元件時會用
      this.customService.pushWrapperRefs(wrapperRef);
      this.customService.pushChartRefs(targetRef);
    });
  }

成果

https://huskylin.github.io/dynamic-component-demo-1/
大家可以上去玩玩看

這裡用三個圖表元件來做簡單的示範
可以在原先空白的頁面上
自定義想要顯示哪些圖表、如何排列
如果每個圖表有不同的篩選條件時(例如:不同時間範圍、地區)
更可以達到交叉分析的效果,在數據分析上非常實用

完整的程式碼在Github 專案

這次做了動態新增的部分
下一篇會延續本篇,完成下列功能

  • 刪除已經產生出的元件
  • 在創立 component 時,傳入 Input 資料

2/19 更新,下一篇在這裡 利用Angular動態元件,製作高彈性客製化頁面! (下)

參考資料