Dynamic component trong Angular

Dynamic component trong Angular

{tocify} $title={Table of Content}

Code Snippet

popup.service.tsimport { ComponentFactoryResolver, ComponentRef, Type, ViewContainerRef } from '@angular/core';

/**
 * This service is used to dynamically render the component
 */
export class PopupService<T> {
  private _componentRef: ComponentRef<T> | null = null;

  constructor(
    private _component: Type<T>,
    private _viewContainerRef: ViewContainerRef,
    private _componentFactoryResolver: ComponentFactoryResolver,
  ) {
  }

  open(): ComponentRef<T> {
    if (!this._componentRef) {
      this._componentRef = this._viewContainerRef.createComponent(
        this._componentFactoryResolver.resolveComponentFactory<T>(this._component),
        this._viewContainerRef.length,
        this._viewContainerRef.injector
      );
      (this._componentRef.instance as any).dismiss = this.close.bind(this);
    }
    return this._componentRef;
  }

  close(): void {
    if (this._componentRef) {
      this._viewContainerRef.remove(this._viewContainerRef.indexOf(this._componentRef.hostView));
      this._componentRef = null;
    }
  }

  closeAll(): void {
    if (this._componentRef) {
      this._viewContainerRef.clear();
      this._componentRef = null;
    }
  }
}

Cách sử dụng

1. Trong Dynamic Component khai báo một dismiss property sử dụng để destroy Dynamic Component

dialog.component.tsdismiss!: () => void;

2. Load Dynamic Component từ Container Component

Cách 1: Load Component theo cách thông thường

app.component.tsimport { ChangeDetectionStrategy, Component, ComponentFactoryResolver, OnInit, ViewContainerRef } from '@angular/core';
import { Observable } from 'rxjs';
import { DialogComponent } from './components/dialog/dialog.component';
import { PopupService } from './popup.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
  changeDetection: ChangeDetectionStrategy.Default
})
export class AppComponent implements OnInit {
  dialogComponent!: PopupService<any>;

  constructor(
    private componentFactoryResolver: ComponentFactoryResolver,
    private viewContainerRef: ViewContainerRef,
  ) {
  }

  ngOnInit() { }

  openDialogComponent() {
    if (!this.dialogComponent) {
      this.dialogComponent = new PopupService<DialogComponent>(
        DialogComponent, this.viewContainerRef, this.componentFactoryResolver
      );
    }

    const dialogComponentRef = this.dialogComponent.open();
    // Pass data to @Input() data of Dialog Component
    dialogComponentRef.instance.data = 'Test Dialog';
    // Get data from @Output() onSubmit: EventEmitter<any> = new EventEmitter<any>(); of Dialog Component
    (dialogComponentRef.instance.onSubmit as Observable<any>).subscribe((value: any) => {
      console.log(value);
    })
  }

  closeDialogComponent() {
    if (this.dialogComponent) {
      this.dialogComponent.close();
    }
  }
}

Cách 2: Lazy load dynamic component dùng async/await 💨 Remove import Dialog Component ở đầu file và Update openDialogComponent ở cách 1 như sau:

app.component.ts  async openDialogComponent() {
    // Lazy load component dynamic
    const { DialogComponent } = await import('./components/dialog/dialog.component');
    this.dialogComponent = new PopupService(
      DialogComponent, this.viewContainerRef, this.componentFactoryResolver
    );

    const dialogComponentRef = this.dialogComponent.open();
    // Pass data to @Input() data of Dialog Component
    dialogComponentRef.instance.data = 'Test Dialog';
    // Get data from @Output() onSubmit: EventEmitter<any> = new EventEmitter<any>(); of Dialog Component
    (dialogComponentRef.instance.onSubmit as Observable<any>).subscribe((value: any) => {
      console.log(value);
    })
  }

Cách 3: Lazy load dynamic component sử dụng Observable:

app.component.tsimport { ChangeDetectionStrategy, Component, ComponentFactoryResolver, ElementRef, OnInit, ViewChild, ViewContainerRef } from '@angular/core';
import { from, Observable, Subject, Subscription } from 'rxjs';
import { map } from 'rxjs/operators';
import { PopupService } from './popup.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
  changeDetection: ChangeDetectionStrategy.Default
})
export class AppComponent implements OnInit {
  @ViewChild('btnOpen', { static: true }) btnOpen!: ElementRef;
  dynamicComponent: PopupService<any> | null = null;
  stop$: Subject<Event> = new Subject();
  dynamicSubs!: Subscription;

  constructor(
    private componentFactoryResolver: ComponentFactoryResolver,
    private viewContainerRef: ViewContainerRef,
  ) {
  }

  ngOnInit() {
  }

  loadDialogComponent(): Promise<any> {
    return import('./components/dialog/dialog.component').then(
      m => m.DialogComponent
    );
  }

  openDialogComponent() {
    // Lazy load component dynamic
    from(this.loadDialogComponent()).pipe(
      map((component: any) => new PopupService(component, this.viewContainerRef, this.componentFactoryResolver))
    ).subscribe(popupService => {
      if (!this.dynamicComponent) {
        this.dynamicComponent = popupService;
      }

      const dialogComponentRef = this.dynamicComponent.open();
      this.dynamicSubs && this.dynamicSubs.unsubscribe();

      if (dialogComponentRef) {
        // Pass data to @Input() data of Dialog Component
        dialogComponentRef.instance.data = 'Test Dialog';
        // Get data from @Output() onSubmit: EventEmitter<any> = new EventEmitter<any>(); of Dialog Component
        this.dynamicSubs = (dialogComponentRef.instance.onSubmit as Observable<any>).subscribe((value: any) => {
          console.log(value);
        });
      }
    });
  }

  closeDialogComponent() {
    if (this.dynamicComponent) {
      this.dynamicComponent.closeAll();
    }
  }

  ngOnDestroy() {
    this.dynamicComponent = null;
  }
}

3. Cập nhật popup.service.ts để truyền projection content vào dynamic component.

popup.service.tsimport {
  ApplicationRef,
  ComponentFactoryResolver,
  ComponentRef,
  Injectable,
  Injector,
  Renderer2,
  TemplateRef,
  ViewContainerRef,
  ViewRef
} from '@angular/core';

export interface ProjectionContent {
  templateRef: TemplateRef<any>;
  context: any;
}

export class ContentRef {
  constructor(public nodes: any[], public viewRef?: ViewRef[], public componentRef?: ComponentRef<any>) { }
}

@Injectable({
  providedIn: 'root'
})
/**
 * This service is used to dynamically render the component
 */
export class PopupService {
  private _componentRef: ComponentRef<any> | null = null;
  private _contentRef: ContentRef = new ContentRef([]);
  private _component: any;
  private _viewContainerRef: ViewContainerRef | null = null;
  private _renderer: Renderer2 | null = null;
  private _applicationRef: ApplicationRef | null = null;

  constructor(
    private _componentFactoryResolver: ComponentFactoryResolver,
    private _injector: Injector,
  ) {
  }

  /**
   * Lazy load dynamic component. You need to use async function to lazy load component.
   * @param componentLoader 
   * componentLoader is a Promise to lazy load component
   * 
   * Example:
   * 
   * loadDialogComponent(): Promise<any> {
   *   return import('./components/dialog/dialog.component').then(
   *     m => m.DialogComponent
   *   );
   * }
   * 
   * @param containerRef : Change viewContainerRef
   */
  async loadComponent(componentLoader: Promise<any>, containerRef?: ViewContainerRef) {
    this._component = await componentLoader;
    if (containerRef) {
      this._viewContainerRef = containerRef;
    }
  }

  /**
   * You have to load this function first in case you need to run loadProjectionContent function.
   * @param renderer Renderer2 is injected to contructor of component container.
   * @param applicationRef ApplicationRef is injected to contructor of component container.
   */
  initializeContentService(renderer: Renderer2, applicationRef: ApplicationRef) {
    this._renderer = renderer;
    this._applicationRef = applicationRef;
  }

  /**
   * Add the projection content to component
   * @param projectionContent
   * This parameter is an array of the ProjectionContent[{templateRef, context}] in the order of ng-content defined in dynamic component
   * 
   * @usageNote
   * In dynamic component template you defined:
   * 
   * Example 1:
   * **************************************************************************
   * *  <ng-content></ng-content>
   * *  
   * *  You will pass [TemplateRef]
   * *  Where:
   * *    TemplateRef is a reference to the template defined by ng-template
   * **************************************************************************
   * 
   * Example 2:
   * 
   * **************************************************************************
   * *  <ng-content selector="header"></ng-content>
   * *  <ng-content selector="content"></ng-content>
   * *  <ng-content selector="footer"></ng-content>
   * *
   * *  You will pass [TemplateRef of <ng-template #header></ng-template>, TemplateRef of <ng-template #header></ng-template>, TemplateRef of <ng-template #header></ng-template>]
   * *
   * *  If you only want to pass projection content for header and footer:
   * *  [TemplateRef of <ng-template #header let-value="value"></ng-template>, null, TemplateRef of <ng-template #header></ng-template>]
   * *  ===> [{templateRef: headerRef, context: {value: 'a'}}, null, {templateRef: footerRef}]
   * **************************************************************************
   * 
   */
  loadProjectionContent(projectionContent?: string | ProjectionContent[] | any[]) {
    if (!this._renderer || !this._applicationRef) {
      console.warn('You have to call "initializeContentService" function before running "loadProjectionContent" function.');
    } else {
      this._contentRef = this._getContentRef(projectionContent);
    }
  }

  open(): ComponentRef<any> {
    if (!this._component) {
      console.warn('You have to call "loadComponent" function before running "open" function.');
    }

    if (!this._componentRef && this._component && this._viewContainerRef) {
      this._componentRef = this._viewContainerRef.createComponent(
        this._componentFactoryResolver.resolveComponentFactory<any>(this._component),
        this._viewContainerRef?.length,
        this._injector,
        this._contentRef.nodes,
      );
      (this._componentRef.instance as any).dismiss = this.close.bind(this);
    }
    return this._componentRef as ComponentRef<any>;
  }

  close(): void {
    if (this._componentRef && this._viewContainerRef) {
      this._viewContainerRef.remove(this._viewContainerRef.indexOf(this._componentRef.hostView));
      this._componentRef = null;
    }

    if (this._contentRef?.viewRef) {
      this._contentRef.viewRef?.forEach((viewRef: ViewRef) => {
        this._applicationRef?.detachView(viewRef);
        viewRef?.destroy();
      });
      this._contentRef = new ContentRef([]);;
    }
  }

  closeAll(): void {
    if (this._componentRef && this._viewContainerRef) {
      this._viewContainerRef.clear();
      this._componentRef = null;
    }

    if (this._contentRef?.viewRef) {
      this._contentRef.viewRef?.forEach((viewRef: ViewRef) => {
        this._applicationRef?.detachView(viewRef);
        viewRef?.destroy();
      });
      this._contentRef = new ContentRef([]);;
    }
  }

  clear() {
    this._componentRef = null;
    this._contentRef = new ContentRef([]);;
    this._component = null;
    this._viewContainerRef = null;
    this._renderer = null;
    this._applicationRef = null;
  }

  private _getContentRef(content?: string | ProjectionContent[] | any[]): ContentRef {
    if (!content) { // If content is false
      return new ContentRef([]);
    } else if (content instanceof Array) {
      const viewRefs: any[] = [];

      const projectionNodes = content.map((projectionContent: ProjectionContent) => {
        if (!projectionContent) {
          return projectionContent;
        }

        const { templateRef, context = {} } = projectionContent;
        // Instantiates an embedded view and return a reference as ViewRef
        const viewRef = templateRef.createEmbeddedView(context);
        // ApplicationRef contains reference to the root view and can be used to manually run change detection using tick function.
        // But since this component is not child of any other component, you have to manually attach it to ApplicationRef so you get change detection.
        // Attach the view to the ApplicationRef so that you get change detection. You will still have no Input and ngOnChanges operations, but the DOM update will be working fine.
        this._applicationRef?.attachView(viewRef);
        return viewRef.rootNodes;
      });

      // Return the root node under which it should be added.
      return new ContentRef(projectionNodes, viewRefs);
    } else { // If content is a string
      return new ContentRef([[this._renderer?.createText(`${content}`)]]);
    }
  }
}

💨 Cách sử dụng:

app.component.tsimport { ApplicationRef, ChangeDetectionStrategy, Component, ComponentFactoryResolver, ElementRef, EventEmitter, Injector, OnInit, Renderer2, TemplateRef, ViewChild, ViewContainerRef } from '@angular/core';
import { Observable, Subject, Subscription } from 'rxjs';
import { PopupService } from './popup.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
  changeDetection: ChangeDetectionStrategy.Default
})
export class AppComponent implements OnInit {
  @ViewChild('btnOpen', { static: true }) btnOpen!: ElementRef;
  @ViewChild('dialogHeader', { static: true }) dialogHeader!: TemplateRef<any>;
  @ViewChild('dialogContent', { static: true, read: TemplateRef }) dialogContent!: TemplateRef<any>;
  @ViewChild('dialogFooter', { static: true, read: TemplateRef }) dialogFooter!: TemplateRef<any>;
  stop$: Subject<Event> = new Subject();
  dynamicSubs!: Subscription;
  submitDialog: EventEmitter<string> = new EventEmitter();

  constructor(
    private componentFactoryResolver: ComponentFactoryResolver,
    private viewContainerRef: ViewContainerRef,
    private injector: Injector,
    private renderer: Renderer2,
    private applicationRef: ApplicationRef,
    private popupService: PopupService
  ) {
  }

  ngOnInit() {
  }

  get modalService() {
    return this.popupService;
  }

  loadDialogComponent(): Promise<any> {
    return import('./components/dialog/dialog.component').then(
      m => m.DialogComponent
    );
  }

  async openDialogComponent() {
    await this.popupService.loadComponent(this.loadDialogComponent(), this.viewContainerRef);
    this.popupService.initializeContentService(this.renderer, this.applicationRef);
    this.popupService.loadProjectionContent(
      [
        { templateRef: this.dialogHeader },
        { templateRef: this.dialogContent },
        { templateRef: this.dialogFooter },
        null, null
      ]
    );
    const dialogComponentRef = this.popupService.open();
    this.dynamicSubs && this.dynamicSubs.unsubscribe();

    if (dialogComponentRef) {
      // Pass data to @Input() data of Dialog Component
      dialogComponentRef.instance.data = 'Test Dialog';
      // Get data from @Output() onSubmit: EventEmitter<any> = new EventEmitter<any>(); of Dialog Component
      this.dynamicSubs = (dialogComponentRef.instance.onSubmit as Observable<any>).subscribe((value: any) => {
        console.log(value);
      });
    }
  }

  closeDialogComponent() {
    if (this.popupService) {
      this.popupService.closeAll();
    }
  }

  ngOnDestroy() {
    this.popupService.clear();
  }
}
app.component.html<button #btnOpen (click)="openDialogComponent()">Open Dialog</button>
<button (click)="closeDialogComponent()">Close Dialog</button>
<button>counter</button>

<ng-template #dialogHeader let-value="value">Dialog header {{value}}</ng-template>
<ng-template #dialogContent>
    <div>This is a content body of the dialog</div>
</ng-template>
<ng-template #dialogFooter>
    <button (click)="modalService.close()">Close</button>
</ng-template>
dialog.component.html<div #dialogTest class="modal modal-show" tabindex="-1" role="dialog">
  <div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
    <div class="modal-content">
      <div class="modal-header">
        <h5 class="modal-title">
          <ng-content select=".header"></ng-content>
        </h5>
        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" (click)="dismiss()"></button>
      </div>
      <div class="modal-body">
        {{data}}
        <ng-content select=".content"></ng-content>
      </div>
      <div class="modal-footer">
        <button (click)="submit()">Submit</button>
        <ng-content select=".footer"></ng-content>
      </div>
      <div>
        <ng-content select="[custom-attribute]"></ng-content>
      </div>
    </div>
  </div>
  <ng-content></ng-content>
</div>
dialog.component.tsimport { ChangeDetectionStrategy, Component, ContentChild, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core';
import { AlertService } from 'src/app/alert.service';

@Component({
  selector: 'app-dialog',
  templateUrl: './dialog.component.html',
  styleUrls: ['./dialog.component.css'],
  exportAs: 'appDialog',
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class DialogComponent implements OnInit {
  _data: any = '';
  @Input()
  get data() {
    return this._data;
  }
  set data(value: any) {
    this._data = value;
  }

  @Output() onSubmit: EventEmitter<any> = new EventEmitter<any>();

  dismiss!: () => void;

  constructor(private alertService: AlertService) {
  }

  ngOnInit() { }

  submit() {
    this.onSubmit.emit('Submitted. Please wait...');
  }
}

Đăng nhận xét

Mới hơn Cũ hơn