Angular動態建立組件之Portals

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

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

動態建立組件

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

image.png

使用Angular API動態建立組件

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

第一步

先在視圖模板中定義一個佔位的區域,動態組件就要渲染在這個位置,起一個名稱#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完整邏輯api

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方法,用於建立模板bash

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

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

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

此外還能夠經過ngTemplateOutlet直接插入內嵌視圖模板,經過ngTemplateOutletContext指定模板的上下文參數dom

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

小結

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

CDK Portal 官方文檔介紹

這裏先對Portal相關的內容作一個簡單的說明,後面會有兩個使用示例,原本這塊內容準備放到最後的,最終仍是決定放在前面,能夠先對Portals有一個簡單的瞭解,若是其中有翻譯不許確請見諒。
地址:material.angular.io/cdk/portal/…

-------- 文檔開始
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能夠很大程度上簡化代碼邏輯。
示例倉儲:github.com/pubuzhixing…

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的實現,以爲這些用法比較巧妙,因此想分享出來。
示例倉儲:github.com/pubuzhixing…
組件庫倉儲:github.com/worktile/ng…

拓展

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工程師 楊振興

文章來源:Worktile技術博客

歡迎訪問交流更多關於技術及協做的問題。

文章轉載請註明出處。

相關文章
相關標籤/搜索