天天看點

探索使用 ViewContainerRef 的 Angular DOM 操控技術

探索使用 ViewContainerRef 的 Angular DOM 操控技術

​​https://indepth.dev/posts/1052/exploring-angular-dom-manipulation-techniques-using-viewcontainerref​​

每當我閱讀關于在 Angular 中處理 DOM 的文章的時候,我總是看到這些類型中的某些被提到:

  • ElementRef
  • TemplateRef
  • ViewContainerRef
  • ......

不幸的是,盡管它們在 Angular 的文檔中被說明了,我還是沒有找到對概念模型進行全面說明的文檔和示例,以及它們是如何被組合使用的。本文嘗試說明 Angular 的概念模型。

如果你正在尋找在 Angular 中使用 Renderer 和 View 來操作 DOM 的深入讨論,請查閱 ​​my talk at NgVikings​​​。或者閱讀深入讨論動态 DOM 操控的文章 ​​Working with DOM in Angular: unexpected consequences and optimization techniques​​

如果原來使用過 angular.js,你就會知道,處理 DOM 是非常簡單的事情。Angular 将 DOM 元素 element 傳遞給 link() 函數,你可以查詢元件模闆内的任何節點,增加或者删除子節點,修改樣式等等。不過,這種方式有一個重要的缺陷 - 它緊密耦合到浏覽器平台上。

新的 Angular 運作在多種平台上 - 浏覽器,移動平台,或者運作在 Web worker 上。是以需要一個抽象層來從平台特定的 API 中抽象出來架構的接口。在 Angular 中,這些抽象通過這些引用類型表示:

  • ElementRef
  • TemplateRef
  • ViewRef
  • ComponentRef
  • ViewContainerRef

在本文中,我們将深入演練這些抽象類型中每一個,并展示如何使用它們來操控 DOM。

@ViewChild

在開始說明這些 DOM 抽象之前,讓我們先了解一下,如何在 Component/Directive 類中通路這些抽象。Angular 提供了被稱為 DOM query 的機制。它使用了 @ViewChild 和 @ViewChildren 裝飾器。兩種的行為類似,隻是前一種隻傳回一個引用,而後一種以 QueryList 對象的形式傳回多個引用。在本文的示例中,我将主要使用 ViewChild 裝飾器,并忽略這個 @ 符号。

一般來說,這些裝飾器與 template reference variables 配套使用,template reference variable 是用來在模闆中簡單地引用 DOM 元素的方式。你可以想象它類似于 html 元素所提供的 id 特性。使用 template reference variable 來标記一個 DOM 元素,然後在類中使用 ViewChild 裝飾器來查詢到它。下面是一個基本的示例:

@Component({
    selector: 'sample',
    template: `
        <span #tref>I am span</span>
    `
})
export class SampleComponent implements AfterViewInit {
    @ViewChild("tref", {read: ElementRef}) tref: ElementRef;

    ngAfterViewInit(): void {
        // outputs `I am span`
        console.log(this.tref.nativeElement.textContent);
    }
}      

@ViewChild 基本的使用文法如下:

@ViewChild([reference from template], {read: [reference type]});      

在上面的示例中,你可以看到我指定了 ​

​tref​

​ 作為在 html 中的模闆引用名稱,并通過 ElementRef 類型關聯到該元素。第 2 個參數 read 并不總是必須的,因為 Angular 可以通過 DOM 元素的類型推斷出來引用類型。例如,如果它是一個簡單的 html 元素,例如這裡的 <span> 元素,Angular 就傳回一個 ElementRef。如果它是一個 <template> 元素,就會傳回一個 TemplateRef。有些引用,比如 ViewContainerRef 不能被推斷出來,就必須在 read 參數中指定。另外,ViewRef 是不能通過 DOM 傳回的,它必須手動構造出來。

好了,現在我們知道了如何查詢到這些引用,現在可以開始說明它們了。

ElementRef

它是最為基本的抽象了。如果你檢視它的類結構,你會發現它僅僅持有它關聯到的原生元素。對于通路原生 DOM 元素來說,它非常有用:

// outputs `I am span`
console.log(this.tref.nativeElement.textContent);      

不過,這樣的用法是不被 Angular 所鼓勵使用的。不僅是帶來的安全風險,它還将你的應用程式與渲染層綁定在一起,使得難以運作在其它平台上。我相信通路 nativeElement 不僅破壞了抽象,還使用了特定的 DOM API,比如 textContent。不過随我我們會看到,在 Angular 中的概念模型中,很難用到如此低級的操作。

ElementRef 可以通過任何 DOM 元素通過 ViewChild 裝飾器獲得。

因為所有的 Component 都是寄宿在一個自定義的 DOM 元素之中,而所有的指令都需要通過 DOM 元素來應用,是以,Component 和 Directive 可以通過依賴注入而得到一個其關聯寄宿元素的 ElementRef 的執行個體。

@Component({
    selector: 'sample',
    ...
export class SampleComponent{
    constructor(private hostElement: ElementRef) {
        //outputs <sample>...</sample>
        console.log(this.hostElement.nativeElement.outerHTML);
    }      

是以,Component 是通過 DI 來通路其計算的元素,而 ViewChild 裝飾器更多用于獲得 Component 内部模闆中的 DOM 元素的引用。不過,對指令則不是,它們是沒有模闆的,指令直接工作在它們應用的元素之上。

TemplateRef

大多數的 Web 開發者都應該熟悉 template。它是一組 DOM 元素,可以跨整個應用程式在視圖中重用。在 HTML5 标準引入 <template> 之前,很多模式是通過使用 script 在浏覽器中實作的,這需要使用 type 一些變體。

<script id="tpl" type="text/template">
  <span>I am span in template</span>
</script>      

這種方式存在多種缺陷,比如語義和需要手動建立 DOM 模型。使用 <template>,浏覽器可以解析其中的 HTML 并建立 DOM 樹,而不需要渲染它。以後它可以通過 content 屬性通路。

<script>
    let tpl = document.querySelector('#tpl');
    let container = document.querySelector('.insert-after-me');
    insertAfter(container, tpl.content);
</script>
<div class="insert-after-me"></div>
<ng-template id="tpl">
    <span>I am span in template</span>
</ng-template>      

Angular 擁抱這種方式,并通過 TemplateRef 類來使用 <template>。下面是如何使用的示例。

@Component({
    selector: 'sample',
    template: `
        <ng-template #tpl>
            <span>I am span in template</span>
        </ng-template>
    `
})
export class SampleComponent implements AfterViewInit {
    @ViewChild("tpl") tpl: TemplateRef<any>;

    ngAfterViewInit() {
        let elementRef = this.tpl.elementRef;
        // outputs `template bindings={}`
        console.log(elementRef.nativeElement.textContent);
    }
}      

Angular 架構會從 DOM 中删除 <template> 元素,然後在它的位置插入一個注釋。這裡是渲染的結果:

<sample>
    <!--template bindings={}-->
</sample>      

對于 TemplateRef 類型,它隻是一個簡單的類。通過屬性 elementRef 持有其宿主元素的引用,還有一個方法:createEmbeddedView()。不過,該方法非常有用,因為它支援我們建立 View 并傳回一個對該 View 的引用 ViewRef。

ViewRef

在 Angular 中,ViewRef 表示 Angular 視圖 (View) 的抽象表示。在 Angular 的世界中,View 是應用程式的基本建構塊。它是在一起被建立或者銷毀的最小元素組機關。Angular 哲學鼓勵開發者将 UI 界面看作 View 的聚合。而不要看作标準的 HTML 元素樹。

Angular 支援兩種 View:

  • Embedded View,指 Template
  • Host View,指 Component

建立 embedded view

Template 用來簡單地持有一個 View 的藍圖。View 可以通過 createEmbeddedView() 方法,通過 template 執行個體化出來。

指通過 <template>元素來建立出來模闆,然後通過 createEmbeddedView() 方法執行個體化出來。
ngAfterViewInit() {
    let view = this.tpl.createEmbeddedView(null);
}      

建立 host view

Host View 通過元件動态執行個體化。元件可以使用 ComponentFactoryResolver() 方法動态建立出來。

constructor(private injector: Injector,
            private r: ComponentFactoryResolver) {
    let factory = this.r.resolveComponentFactory(ColorComponent);
    let componentRef = factory.create(injector);
    let view = componentRef.hostView;
}      

在 Angular 中,每個 Component 都綁定到一個 Injector 的執行個體上,是以,當建立這個 Component 的時候,我們将目前元件的 Injector 傳遞進去。另外,不要忘了,動态執行個體化的 Component 需要加入到 Module 或者托管的 Component 的 EntryComponents 中。

是以,我們已經看到了可以建立的 embeded 和 host 兩種 View。一旦 View 被建立出來,它就可以使用 ViewContainer 插入到 DOM 中。下一節我們就介紹它的功能。

ViewContainerRef

ViewContainerRef 表示可以容納一個或者多個 View 的容器。

首先需要提醒的是,任何 DOM 元素都可以作為 View 的容器。有趣的是,Angular 不是将 View 插入到元素中,而是綁定到元素的 ViewContainer 中。這類似于 router-outlet 如何插入 Component。

通常,比較好的将一個位置标記為 ViewContainer 的方式,是建立一個 <ng-container> 元素。它會被渲染為一條 comment,是以不會帶來多于的 HTML 元素到 DOM 中。下面是一個示例,示範了在 Component 的模闆中建立 ViewContainer。

@Component({
    selector: 'sample',
    template: `
        <span>I am first span</span>
        <ng-container #vc></ng-container>
        <span>I am last span</span>
    `
})
export class SampleComponent implements AfterViewInit {
    @ViewChild("vc", {read: ViewContainerRef}) vc: ViewContainerRef;

    ngAfterViewInit(): void {
        // outputs `template bindings={}`
        console.log(this.vc.element.nativeElement.textContent);
    }
}      

與其它的 DOM 抽象類似,ViewContainer 也通過 element 屬性綁定以特定的 DOM 元素。在上面的示例中,就是 ng-container 元素,它被渲染為一個 comment,是以輸出就成為 ​

​template bindings={}​

​。

操控 Views

ViewContainer 提供了一系列便捷的 API 來操作 View。

class ViewContainerRef {
    ...
    clear() : void
    insert(viewRef: ViewRef, index?: number) : ViewRef
    get(index: number) : ViewRef
    indexOf(viewRef: ViewRef) : number
    detach(index?: number) : ViewRef
    move(viewRef: ViewRef, currentIndex: number) : ViewRef
}      

前面我們已經看到過,如何手工建立兩種類型的 View,分别是通過 <template> 和 Component。一旦建立了 View,我們就可以使用 insert() 方法将它們插入到容器中。下面是使用 <template> 建立嵌入的 View,并插入到使用 ng-container 元素指定的特定位置。

@Component({
    selector: 'sample',
    template: `
        <span>I am first span</span>
        <ng-container #vc></ng-container>
        <span>I am last span</span>
        <ng-template #tpl>
            <span>I am span in template</span>
        </ng-template>
    `
})
export class SampleComponent implements AfterViewInit {
    @ViewChild("vc", {read: ViewContainerRef}) vc: ViewContainerRef;
    @ViewChild("tpl") tpl: TemplateRef<any>;

    ngAfterViewInit() {
        let view = this.tpl.createEmbeddedView(null);
        this.vc.insert(view);
    }
}      

從 DOM 中删除 View,也就是從 ViewContainer 中删除 View,可以使用 detach() 方法。所有其它方法都是自解釋的,可以通過下标獲得相關的 View,将 View 移動到其它位置,或者删除 Container 中所有的 View。

建立 View

ViewContainer 還提供了一個自動建立 View 的 API

class ViewContainerRef {
    element: ElementRef
    length: number

    createComponent(componentFactory...): ComponentRef<C>
    createEmbeddedView(templateRef...): EmbeddedViewRef<C>
    ...
}      

它們是上面手工建立方式的簡單封裝。通過 Template 或者 Component 建立 View,并插入到特定的位置。

ngTemplateOutlet 和 ngComponentOutlet

盡管了解底層是如何工作的很重要,通常期望的使用方式總是簡單。有兩個指令實作快捷操作

  • ngTemplateOutlet
  • ngComponentOutlet

非常好了解它們的作用。

ngTemplateOutlet

它将一個 DOM 元素标記為 ViewContainer,建立 <template> 的 View 執行個體,并将這個 Embeded View 插入到其中,而不需要在 Component 類中顯式用代碼完成。這意味着,上面的示例可以重寫為如下形式。

@Component({
    selector: 'sample',
    template: `
        <span>I am first span</span>
        <ng-container [ngTemplateOutlet]="tpl"></ng-container>
        <span>I am last span</span>
        <ng-template #tpl>
            <span>I am span in template</span>
        </ng-template>
    `
})
export class SampleComponent {}      

ngComponentOutlet

<ng-container *ngComponentOutlet="ColorComponent"></ng-container>      

總結