Angular動態建立元件之Portals

wow_worktile發表於2019-03-19

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

cdk全名Component Development Kit 元件開發包,是Angular官方在開發基於Material Design的元件庫時抽象出來單獨的一個開發包,裡面封裝了一些開發元件時的公共邏輯並且跟Material Design 設計無關,可以用來封裝自己的元件庫或者直接在業務開發中使用,裡面程式碼抽象程度非常高,非常值得學習,現在我用到的有Portals、Overlay(開啟浮層相關)、SelectionModel、Drag and Drop等.
官方:material.angular.io/
中文翻譯: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,是不是很好理解。
示例倉儲: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技術部落格

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

文章轉載請註明出處。


相關文章