Angular動態創建組件之Portals


這篇文章主要介紹使用Angular api 和 CDK Portals兩種方式實現動態創建組件,另外還會講一些跟它相關的知識點,如:Angular多級依賴注入、ViewContainerRef,Portals可以翻譯為 門戶 ,我覺得放到這里叫 入口 更好,可以理解為動態創建組件的入口,類似於小程序或者Vue中的Slot.

cdk全名Component Development Kit 組件開發包,是Angular官方在開發基於Material Design的組件庫時抽象出來單獨的一個開發包,里面封裝了一些開發組件時的公共邏輯並且跟Material Design 設計無關,可以用來封裝自己的組件庫或者直接在業務開發中使用,里面代碼抽象程度非常高,非常值得學習,現在我用到的有Portals、Overlay(打開浮層相關)、SelectionModel、Drag and Drop等.
官方:https://material.angular.io/
中文翻譯:https://material.angular.cn

動態創建組件

想想應用的路由,一般配置路由地址的時候都會給這個地址配置一個入口組件,當匹配到這個路由地址的時候就在指定的地方渲染這個組件,動態創建組件類似,在最頁面未接收到用戶行為的時候,我不知道頁面中這塊區域應該渲染那個組件,當頁面加載時根據數據庫設置或者用戶的操作行為才能確定最終要渲染的組件,這時候就要用代碼動態創建組件把目標組件渲染到正確的地方。
示例截圖

image.png

 

使用Angular API動態創建組件

該路由的入口組件是PortalsEntryConponent組件,如上面截圖所示右側有一塊虛線邊框的區域,里面具體的渲染組件不確定。

第一步

先在視圖模板中定義一個占位的區域,動態組件就要渲染在這個位置,起一個名稱#virtualContainer
文件portals-entry.component.html

<div class="portals-outlet" >
    <ng-container #virtualContainer>
    </ng-container>
</div>

第二步

通過ViewChild取到這個container對應的邏輯容器
文件portals-entry.component.ts

 @ViewChild('virtualContainer', { read: ViewContainerRef })
  virtualContainer: ViewContainerRef;

第三步

處理單擊事件,單擊按鈕時動態創建一個組件,portals-entry.component.ts完整邏輯

import { TaskDetailComponent } from '../task/task-detail/task-detail.component';
@Component({
  selector: 'app-portals-entry',
  templateUrl: './portals-entry.component.html',
  styleUrls: ['./portals-entry.component.scss'],
  providers: [
  ]
})
export class PortalsEntryComponent implements OnInit {
  @ViewChild('virtualContainer', { read: ViewContainerRef })
  virtualContainer: ViewContainerRef;
  constructor(
    private dynamicComponentService: DynamicComponentService,
    private componentFactoryResolver: ComponentFactoryResolver,
    private injector: Injector,
  ) { }
  ngOnInit() {
  }
  openTask() {
    const task = new TaskEntity();
    task.id = '1000';
    task.name = '寫一篇關於Portals的文章';
    const componentFactory = this.componentFactoryResolver.resolveComponentFactory(TaskDetailComponent);
    const componentRef = this.virtualContainer.createComponent<TaskDetailComponent>(
      componentFactory,
      null,
      this.virtualContainer.injector
    );
    (componentRef.instance as TaskDetailComponent).task = task; // 傳遞參數
  }
}

代碼說明

  1. openTask()方法綁定到模板中按鈕的單擊事件
  2. 導入要動態創建的組件TaskDetailComponent
  3. constructor注入injector、componentFactoryResolver 動態創建組件需要的對象,只有在組件上下文中才可以拿到這些實例對象
  4. 使用api創建組件,現根據組件類型創建一個ComponentFactory對象,然后調用viewContainer的createComponent創建組件
  5. 使用componentRef.instance獲取創建的組件實例,這里用來設置組件的task屬性值

其它

ViewContainerRef除了createComponent方法外還有一個createEmbeddedView方法,用於創建模板

@ViewChild('customTemplate')
customTemplate: TemplateRef<any>;
this.virtualContainer.createEmbeddedView(this.customTemplate, { name: 'pubuzhixing' });

createEmbeddedView方法的第二個參數,用於指定模板的上下文參數,看下模板定義及如何使用參數

<ng-template #customTemplate let-name="name">
  <p>自定義模板,傳入參數name:{{name}}</p>
</ng-template>

此外還可以通過ngTemplateOutlet直接插入內嵌視圖模板,通過ngTemplateOutletContext指定模板的上下文參數

<ng-container [ngTemplateOutlet]="customTemplate" [ngTemplateOutletContext]="{ name:'pubuzhixing' }"></ng-container>

小結

分析下Angular動態創建組件/內嵌視圖的API,動態創建組件首先需要一個被創建的組件定義或模板聲明,另外需要Angular上下文的環境來提供這個組件渲染在那里以及這個組件的依賴從那獲取,viewContainerRef是動態組件的插入位置並且提供組件的邏輯范圍,此外還需要單獨傳入依賴注入器injector,示例直接使用邏輯容器的injector,是不是很好理解。
示例倉儲:https://github.com/pubuzhixing8/angular-cdk-demo

CDK Portal 官方文檔介紹

這里先對Portal相關的內容做一個簡單的說明,后面會有兩個使用示例,本來這塊內容准備放到最后的,最終還是決定放在前面,可以先對Portals有一個簡單的了解,如果其中有翻譯不准確請見諒。
地址:https://material.angular.io/cdk/portal/overview

-------- 文檔開始
portals 提供渲染動態內容到應用的可伸縮的實現,其實就是封裝了Angular動態創建組件的過程

Portals

這個Portal指是能動態渲染一個指定位置的 UI塊 到頁面中的一個 open slot 。
UI塊 指需要被動態渲染的內容,可以是一個組件或者是一個模板,而 open slot 是一個叫做PortalOutlet的開放的占位區域。
Portals和PortalOutlets是其它概念中的低級的構造塊,像overlays就是在它基礎上構建的

 Portal<T> 包括動態組件的抽象類,可以是TemplatePortal(模板)或者ComponentPortal(組件)
方法 描述
attach(PortalOutlet): T 把當前Portal附加到宿主上
detach(): void 把Portal從宿主上拆離
isAttached: boolean 當前Portal是否已經附加到宿主上
 PortalOutlet 動態組件的宿主
方法 描述
attach(Portal): any 附加指定Portal
detach(): any 拆離當前附加Portal
dispose(): void 永久釋放宿主資源
hasAttached: boolean 當前是否已經裝在Portal

代碼片段說明

CdkPortal

 <ng-template cdkPortal>
  <p>The content of this template is captured by the portal.</p>
</ng-template>
<!-- OR -->
<!-- 通過下面的結構指令語法可以得到同樣的結果 -->
<p *cdkPortal>
  The content of this template is captured by the portal.
</p>

可以通過ViewChild、ViewChildren獲取到該Portal,類型應該是CdkPortal,如下所示:

 // 模板中的Portal
@ViewChild(CdkPortal) templateCDKPortal: TemplatePortal<any>;

ComponentPortal
組件類型的Portal,需要當前組件在NgModule的entryComponents中配置才能動態創建該組件。

 this.userSettingsPortal = new ComponentPortal(UserSettingsComponent);

CdkPortalOutlet
使用指令可以把portal outlet添加到一個ng-template,cdkPortalOutlet把當前元素指定為PortalOutlet,下面代碼把userSettingsPortal綁到此portal-outlet上

  <!-- Attaches the `userSettingsPortal` from the previous example. -->
<ng-template [cdkPortalOutlet]="userSettingsPortal"></ng-template>

----- 文檔完畢

Portals使用示例

這里首先使用新的api完成和最上面示例一樣的需求,在同樣的位置動態渲染TaskDetailComponent組件。

第一步

同樣是設置一個宿主元素用於渲染動態組件,可以使用指令cdkPortalOutlet掛載一個PortalOutlet在這個ng-container元素上

<div class="portals-outlet">
   <ng-container #virtualContainer cdkPortalOutlet>
   </ng-container>
</div>

第二步

與 使用Angular API動態創建組件 一節使用同一個邏輯元素作為宿主,只不過這里的獲取容器的類型是CdkPortalOutlet,代碼如下

@ViewChild('virtualContainer', { read: CdkPortalOutlet })
virtualPotalOutlet: CdkPortalOutlet;

第三步

創建一個ComponentPortal類型的Portal,並且將它附加上面獲取的宿主virtualPotalOutlet上,代碼如下

  portalOpenTask() {
    this.virtualPotalOutlet.detach();
    const taskDetailCompoentPortal = new ComponentPortal<TaskDetailComponent>(
      TaskDetailComponent
    );
    const ref = this.virtualPotalOutlet.attach(taskDetailCompoentPortal);
    // 此處同樣可以 通過ref.instance傳遞task參數
  }

小結

這里是使用ComponentPortal的示例實現動態創建組件,Portal還有一個子類TemplatePortal是針對模板實現的,上節 CDK Portal 官方文檔介紹 中有介紹,這里就不在贅述了。總之使用Portals可以很大程度上簡化代碼邏輯。
示例倉儲:https://github.com/pubuzhixing8/angular-cdk-demo

Portals 源碼分析

上面只是使用Portal的最簡單用法,下面討論下它的源碼實現,以便更好的理解

ComponentPortal

首先我們先看一下ComponentPortal類的創建,上面的例子只是指定了一個組件類型作為參數,其實它還有別的參數可以配置,先看下ComponentPortal的構造函數定義

export class ComponentPortal<T> extends Portal<ComponentRef<T>> {   
  constructor(
      component: ComponentType<T>,
      viewContainerRef?: ViewContainerRef | null,
      injector?: Injector | null,
      componentFactoryResolver?: ComponentFactoryResolver | null) {
    super();
    this.component = component;
    this.viewContainerRef = viewContainerRef;
    this.injector = injector;
    this.componentFactoryResolver = componentFactoryResolver;
  } 
}

ComponentPortal構造函數的另外兩個參數 viewContainerRef 和 injector
viewContainerRef 參數非必填默認附到PortalOutlet上,如果傳入viewContainerRef參數,那么ComponentPortal就會附到該viewContaierRef上,而不是當前PortalOutlet所在的元素上。
injector 參數非必填,默認使用PortalOutlet所在的邏輯容器的injector,如果傳入injector,那么動態創建的組件就使用傳入的injector作為注入器。

BasePortalOutlet

BasePortalOutlet提供了附加ComponentPortal和TemplatePortal的部分實現,我們看下attach方法的部分代碼(僅僅展示部分邏輯)

  /** Attaches a portal. */
  attach(portal: Portal<any>): any {
    if (!portal) {
      throwNullPortalError();
    }
    if (portal instanceof ComponentPortal) {
      this._attachedPortal = portal;
      return this.attachComponentPortal(portal);
    } else if (portal instanceof TemplatePortal) {
      this._attachedPortal = portal;
      return this.attachTemplatePortal(portal);
    }
    throwUnknownPortalTypeError();
  }

attach處理前先根據Portal的類型是確實是組件還是模板,然后再進行相應的處理,其實最終還是調用了ViewContainerRef的createComponent或者createEmbeddedView方法,對這塊感興趣看查看源代碼文件portal-directives.ts

DomPortalOutlet

DomPortalOutlet可以把一個Portal插入到一個Angular應用上下文之外的DOM中,想想我們前面的例子,無論自己實現還是使用CdkPortalOutlet都是把一個模板或者組件插入到一個Angular上下文中的宿主ViewContainerRef中,而DomPortalOutlet就是 脫離Angular上下文的宿主,可以把Portal渲染到任意dom中,我們常常有這種需求,比如彈出的模態框、Select浮層。
在cdk中Overlay用到了DomPortalOutlet,然后material ui的MatMenu也用到了DomPortalOutlet,MatMenu比較容易理解,簡單看下它是如何創建和使用的DomPortalOutle(查看全部

if (!this._outlet) {
    this._outlet = new DomPortalOutlet(this._document.createElement('div'),
    this._componentFactoryResolver, this._appRef, this._injector);
}
const element: HTMLElement = this._template.elementRef.nativeElement;
element.parentNode!.insertBefore(this._outlet.outletElement, element);
this._portal.attach(this._outlet, context);

上面的代碼先創建了DomPortalOutlet類型的對象_outlet,DomPortalOutlet是一個DOM宿主它不在Angular的任何一個ViewContainerRef中,現在看下它的四個構造函數參數

參數名 類型 說明
outletElement Element 創建的document元素
_componentFactoryResolver ComponentFactoryResolver 剛開始一直不理解這個實例對象是干什么的,后來查了資料,它大概的作用是對要創建的組件或者模板進行編譯
_appRef ApplicationRef 當前Angular應用的一個關聯對象
_defaultInjector Injector 注入器對象

說明:這節講的 脫離Angular上下文 是不太准確定,任何模板或者組件都不能脫離Angular的運行環境,這里應該是脫離了實際渲染的Component Tree,單獨渲染到指定dom中。

復雜示例

為ComponentPortal傳入PortalInjector對象,PortalInjector實例對象配置一個其它業務組件的injector並且配置tokens,下面簡單說明下邏輯結構,有興趣的可看完整示例

業務組件TaskListComponent

文件task-list.component.ts

@Component({,
  selector: 'app-task-list',
  templateUrl: './task-list.component.html',
  styleUrls: ['./task-list.component.scss'],
  providers: [TaskListService]
})
export class TaskListComponent implements OnInit {
  constructor(public taskListService: TaskListService) {}
}

組件級提供商配置了TaskListService

定義TaskListService

用於獲取任務列表數據,並保存在屬性tasks中

TaskListComponent模板

在模板中直接綁定taskListService.tasks屬性數據

修改父組件PortalsEntryComponent

因為PortalOutlet是在父組件中,所以單擊任務列表創建動態組件的邏輯是從父組件響應的
portals-entry.component.ts

   @ViewChild('taskListContainer', { read: TaskListComponent })
  taskListComponent: TaskListComponent; 
  ngOnInit() {
    this.taskListComponent.openTask = task => {
      this.portalCreatTaskModel(task);
    };
  }
portalCreatTaskModel(task: TaskEntity) {
    this.virtualPotalOutlet.detach();
    const customerTokens = new WeakMap();
    customerTokens.set(TaskEntity, task);
    const portalInjector = new PortalInjector(
      this.taskListViewContainerRef.injector,
      customerTokens
    );
    const taskModelCompoentPortal = new ComponentPortal<TaskModelComponent>(
      TaskModelComponent,
      null,
      portalInjector
    );
    this.virtualPotalOutlet.attach(taskModelCompoentPortal);
  }

給ComponentPortal的構造函數傳遞了PortalInjector類型的參數portalInjector,PortalInjector繼承自Injector

PortalInjector構造函數的兩個參數

  1. 第一個參數是提供一個基礎的注入器injector,這里使用了taskListViewContainerRef.injector,taskListViewContainerRef就是業務TaskListComponent組件的viewContainerRef
    @ViewChild('taskListContainer', { read: ViewContainerRef })
    taskListViewContainerRef: ViewContainerRef;
    
    也就是新的組件的注入器來自於TaskListComponent
  2. 第二個參數是提供一個tokens,類型是WeakMap,其實就是key/value的鍵值對,只不過它的key只能是引用類型的對象,這里把類型TaskEntity作為key,當前選中的實例對象作為value,就可以實現對象的傳入,使用set方法 customerTokens.set(TaskEntity, task); 。

新的任務詳情組件TaskModelComponent

task-model.component.ts

  constructor(
    public task: TaskEntity,
    private taskListService: TaskListService
  ) {}

沒錯,是通過注入器注入的方式獲取TaskEntity實例和服務TaskListService的實例taskListService。

小結

這個例子相對復雜,只是想說明可以給動態創建的組件傳入特定的injector。

總結

想寫Portals的使用主要是看了我們組件庫中模態框ThyDialog的實現,覺得這些用法比較巧妙,所以想分享出來。
示例倉儲:https://github.com/pubuzhixing8/angular-cdk-demo
組件庫倉儲:https://github.com/worktile/ngx-tethys

拓展

ViewContainerRef

angula.cn解釋:表示可以將一個或多個視圖附着到組件中的容器,可以包含宿主視圖(當用 createComponent() 方法實例化組件時創建)和內嵌視圖(當用 createEmbeddedView() 方法實例化 TemplateRef 時創建)。
我這里的理解ViewContainerRef是Angular中的一個邏輯單元,簡單理解它與組件或者頁面中的html元素一一對應只是邏輯形態不同,它也有層級只是層級與組件樹的層級不是一一對應,這點個人感覺有些難理解,就拿Portals里面ComponentPortal的實現來說,構造函數里面可以傳入一個viewContainerRef,代碼片段

/**
 * A `ComponentPortal` is a portal that instantiates some Component upon attachment.
 */
export class ComponentPortal<T> extends Portal<ComponentRef<T>> {
  /**
   * [Optional] Where the attached component should live in Angular's *logical* component tree.
   * 可選參數 關聯的組件應該寄宿的邏輯組件樹的位置
   * This is different from where the component *renders*, which is determined by the PortalOutlet.
   * 這跟組件真正渲染的位置是不同的,真正的位置由PortalOutlet決定
   * The origin is necessary when the host is outside of the Angular application context.
   * 當宿主是在Angular上下文環境之外這個參數是必填項
   */
  viewContainerRef?: ViewContainerRef | null;
  constructor(
      component: ComponentType<T>,
      viewContainerRef?: ViewContainerRef | null,
      injector?: Injector | null,
      componentFactoryResolver?: ComponentFactoryResolver | null) {
    // ...
  }
}

對其中viewContainerRef的注釋進行了簡單的翻譯,但還是不知道它是怎么實現邏輯組件樹與真實渲染組件樹設置不同層級,經過自己的嘗試當設置viewContainerRef后,組件就渲染在了傳入的viewContainerRef里面。
屬性 element 和 injector
element 的類型是ElementRef,用來標識本容器在父容器中的位置與html中的元素一一對應
injector 的類型是Injector,它是容器的一個依賴注入器對象,我們在組件的constructor中注入的服務以及獲取關聯的對象都要通過它來查找,在ViewContainer的邏輯樹中注入器對象有一個 注入器冒泡 機制,當一個組件申請獲得一個依賴時,Angular 先嘗試用該組件容器自己的注入器來滿足它,在該組件的容器中找不到實例並且也沒有配置注入器提供商(providers),他就會在把這個申請轉給它父組件的注入器來處理。所以在動態創建組件的時候可以單獨配置這個injector可以子組件傳遞數據、共享實例對象。

WeakMap

最初因為不了解WeakMap而對這個實現疑惑不解,查了WeakMap的相關資料。

WeakMap 對象是一組鍵/值對的集合,其中的鍵是弱引用的。其鍵名必須是對象,而值可以是任意的。

鍵名是對象的弱引用,當對象被回收后,WeakMap自動移除對應的鍵值對,WeakMap結構有助於防止內存泄漏。 

可以與Map對比理解,Map中key可以是各種類型,而WeakMap必須是對象。
這樣WeakMap就可以用來在不修改原引用類型對象的基礎上,而擴充該對象的屬性值,並且不影響引用類型對象的垃圾回收,隨該對象的消失,擴充屬性隨之消失。

 

Worktile官網:www.worktile.com 

本文作者:Worktile高級工程師 楊振興

文章首發於「Worktile官方博客」,轉載請注明來源。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM