Angular 動態載入 Template:使用 Dynamic Component 方案

主要需求:由於 Template 不是固定的,因此需要可以由後端指定 HTML,前台 angular 動態的載入 tempalte。

 

最主要的參考:Angular 2 dynamic template url with string variable?

這裡的 Component (DynamicTemplateComponent) 中,透過 template 指定 ng-container ,透過 ViewChild 可以動態指定:

<ng-container #dynamicTemplate></ng-container>

@ViewChild(‘dynamicTemplate’, {read: ViewContainerRef}) dynamicTemplate;

 

DynamicTemplateComponent 每次 routing 呼叫後,利用 ViewChild 自動生成對應的 dynamic component 與其 template 的頁面。 angular 提供 Dynamic Component Loader 可以達成,

 

但原始文章有一個主要問題在於無法透過 templateUrl,因為 angular cli 會直接解析內容。

解決方案是透過 template 指定 html 內容:

完整程式如下:

export class DynamicTemplateComponent implements AfterViewInit, OnInit {
  @ViewChild('dynamicTemplate', { read: ViewContainerRef }) dynamicTemplate;

  constructor(private compiler: Compiler, private injector: Injector, private ngModuleRef: NgModuleRef<any>,
    private route: ActivatedRoute, private data: LhcService) { }

  id: number;
  ngOnInit() {
    this.route.params.subscribe(p => {
      this.id = p["id"];
    })
  }

  ngAfterViewInit(): void {
    this.data.getTemplate(this.id).subscribe(data => {
      const tmpComponent = Component({ moduleId: module.id, template: data })
        (class {
          cancel() {
            window.history.back();
          }
        });
 
      const tmpModule = NgModule({ declarations: [tmpComponent] })(class { });
 
      this.compiler.compileModuleAndAllComponentsAsync(tmpModule)
        .then((factories) => {
          const factory = factories.componentFactories[0];
          const cmpRef = factory.create(this.injector, [], null, this.ngModuleRef);
          cmpRef.instance.name = 'dynamic';
          this.dynamicTemplate.insert(cmpRef.hostView);
        })
    });
  }

這裡要額外宣告 module.id,是 angular 告訴 webpack or systemjs 如何 package module 的 metadata,因為我們使用 angular cli (webpack) ,會自動注入 module.id 到 component 中,唯一需要注意的是 typescript 要事先宣告避免無法編譯:

declare var module: {
id: string;
}

如果產生的 component 要加入 處理 *ngFor 等 browser 的內容,要在 NgModule 中,宣告 BrowserModule:

const tmpModule = NgModule({
declarations: [tmpComponent],
imports: [BrowserModule]
})(class { });

其次,在產生的 component 中,很難使用 constructor injection,最好的方式是產生時候,直接將服務對應到 class property 中:

const tmpComponent = Component({ moduleId: module.id, template: data })
  (class {
    private data: LhcService;
    patients: Array<RegFile>;
 
    getPatient() {
      this.data.getAllPatients().subscribe(data => this.patients = data);
    }
    cancel() {
      window.history.back();
    }
  });

…

this.compiler.compileModuleAndAllComponentsAsync(tmpModule)
  .then((factories) => {
    const factory = factories.componentFactories[0];
    const cmpRef = factory.create(this.injector, [], null, this.ngModuleRef);
    cmpRef.instance.name = 'dynamic';
    cmpRef.instance.data = this.data;
    cmpRef.instance.callback = this.interactive;

    this.dynamicTemplate.insert(cmpRef.hostView);
  })

其中 compRef.instance 就是代表 dynamic component 的函數物件,這裡將 parent 的 data (lch.service)對應到 child 中,讓他可以直接使用。

此外,也可以加入 callback function(cmpRef.instance.callback = this.interactive),與 patient controller 互動:

在 parnet 中宣告:

interactive(patient: RegFile): Observable<string> {
console.log("dynamic compoent callback, show reg_no: " + patient.RegNo);
return new Observable((observe) => {
observe.next(patient.Name);
observe.complete();
})
}

重點在於回傳 Observable 讓 child component 可以接收:

callback: Function;
clickPatient(patient: RegFile) {
 this.callback(patient).subscribe(m => console.log("easy way from patient: " + m));
}

此外,class 的宣告也可使用繼承或者介面:

const tmpComponent = Component({ moduleId: module.id, template: data })
(class implements OnInit {
ngOnInit(): void {
console.log("ng onitit");
}

請注意,這裡的 class 不可以移出外部,原因是如果移出去,第一次會成功、但第二次就會造成以下的錯誤:

ERROR Error: Type InnerComponent is part of the declarations of 2 modules: class_1 and class_1! Please consider moving InnerComponent to a higher module that imports class_1 and class_1. You can also create a new NgModule that exports and includes InnerComponent then import that NgModule in class_1 and class_1.

此外,也要注意因為動態生成 component 功能畢竟有限,有些無法執行;例如 ngx-bootstrap 的 datepicker 就因為 module 匯入造成失敗,目前找不到原因,還好可以用 input type=’date’ 方式叫出瀏覽器預設的 date picker:

imports: [BrowserModule, FormsModule, BsDatepickerModule.forRoot()] ERROR TypeError: Cannot read property 'isDisabled' of undefined at Object.eval [as updateRenderer] (BsDatepickerDayDecoratorComponent_Host.ngfactory.js? [sm]:1)