[譯] 探索 Angular 使用 ViewContainerRef 操作 DOM

lx1036發表於2018-03-20

原文連結:Exploring Angular DOM manipulation techniques using ViewContainerRef

如果想深入學習 Angular 如何使用 Renderer 和 View Containers 技術操作 DOM,可以查閱 YouTube 視訊 my talk at NgVikings

每次我讀到 Angular 如何操作 DOM 相關文章時,總會發現這些文章提到 ElementRefTemplateRefViewContainerRef 和其他的類。儘管這些類在 Angular 官方文件或相關文章會有涉及,但是很少會去描述整體思路,這些類如何一起作用的相關示例也很少,而本文就主要描述這些內容。

如果你來自於 angular.js 世界,很容易明白如何使用 angular.js 操作 DOM。angular.js 會在 link 函式中注入 DOM element,你可以在元件模板裡查詢任何節點(node),新增或刪除節點(node),修改樣式(styles),等等。然而這種方式有個主要缺陷:與瀏覽器平臺緊耦合

新版本 Angular 需要在不同平臺上執行,如 Browser 平臺,Mobile 平臺或者 Web Worker 平臺,所以,就需要在特定平臺的 API 和框架介面之間進行一層抽象(abstraction)。Angular 中的這層抽象就包括這些引用型別:ElementRefTemplateRefViewRefComponentRefViewContainerRef。本文將詳細講解每一個引用型別(reference type)和該引用型別如何操作 DOM。

@ViewChild

在探索 DOM 抽象類前,先了解下如何在元件/指令中獲取這些抽象類。Angular 提供了一種叫做 DOM Query 的技術,主要來源於 @ViewChild@ViewChildren 裝飾器(decorators)。兩者基本功能相同,唯一區別是 @ViewChild 返回單個引用,@ViewChildren 返回由 QueryList 物件包裝好的多個引用。本文示例中主要以 ViewChild 為例,並且描述時省略 @

通常這兩個裝飾器與模板引用變數(template reference variable)一起使用,模板引用變數僅僅是對模板(template)內 DOM 元素命名式引用(a named reference),類似於 html 元素的 id 屬性。你可以使用模板引用(template reference)來標記一個 DOM 元素,並在元件/指令類中使用 ViewChild 裝飾器查詢(query)它,比如:

@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 作為模板引用名稱,並將 ElementRef 與該元素聯絡起來。第二個引數 read 是可選的,因為 Angular 會根據 DOM 元素的型別推斷出該引用型別。例如,如果它(#tref)掛載的是類似 span 的簡單 html 元素,Angular 返回 ElementRef;如果它掛載的是 template 元素,Angular 返回 TemplateRef。一些引用型別如 ViewContainerRef 就不可以被 Angular 推斷出來,所以必須在 read 引數中顯式申明。其他的如 ViewRef 不可以掛載在 DOM 元素中,所以必須手動在建構函式中編碼構造出來。

現在,讓我們看看應該如何獲取這些引用,一起去探索吧。

ElementRef

這是最基本的抽象類,如果你檢視它的類結構,就發現它只包含所掛載的元素物件,這對訪問原生 DOM 元素很有用,比如:

// outputs `I am span`
console.log(this.tref.nativeElement.textContent);
複製程式碼

然而,Angular 團隊不鼓勵這種寫法,不但因為這種方式會暴露安全風險,而且還會讓你的程式與渲染層(rendering layers)緊耦合,這樣就很難在多平臺執行你的程式。我認為這個問題並不是使用 nativeElement 而是使用特定的 DOM API 造成的,如 textContent。但是後文你會看到,Angular 實現了操作 DOM 的整體思路模型,這樣就不再需要低階 API,如 textContent

使用 ViewChild裝飾的 DOM 元素會返回 ElementRef,但是由於所有元件掛載於自定義 DOM 元素,所有指令作用於 DOM 元素,所以元件和指令都可以通過 DI(Dependency Injection)獲取宿主元素的ElementRef 物件。比如:

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

所以元件通過 DI(Dependency Injection)可以訪問到它的宿主元素,但 ViewChild 裝飾器經常被用來獲取模板檢視中的 DOM 元素。然而指令卻相反,因為指令沒有檢視模板,所以主要用來獲取指令掛載的宿主元素。

TemplateRef

對於大部分開發者來說,模板概念很熟悉,就是跨程式檢視內一堆 DOM 元素的組合。在 HTML5 引入 template 標籤前,瀏覽器通過在 script 標籤內設定 type 屬性來引入模板,比如:

<script id="tpl" type="text/template">
  <span>I am span in template</span>
</script>
複製程式碼

這種方式不僅有語義缺陷,還需要手動建立 DOM 模型,然而通過 template 標籤,瀏覽器可以解析 html 並建立 DOM 樹,但不會渲染它,該 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 採用 template 標籤這種方式,實現了 TemplateRef 抽象類來和 template 標籤一起合作,看看它是如何使用的(譯者注:ng-template 是 Angular 提供的類似於 template 原生 html 標籤):

@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 方法。然而 createEmbeddedView 方法很有用,因為它可以建立一個檢視(view)並返回該檢視的引用物件 ViewRef

ViewRef

該抽象表示一個 Angular 檢視(View),在 Angular 世界裡,檢視(View)是一堆元素的組合,一起被建立和銷燬,是構建程式 UI 的基石。Angular 鼓勵開發者把 UI 作為一堆檢視(View)的組合,而不僅僅是 html 標籤組成的樹。

Angular 支援兩種型別檢視:

  • 嵌入檢視(Embedded View),由 Template 提供
  • 宿主檢視(Host View),由 Component 提供

建立嵌入檢視

模板僅僅是檢視的藍圖,可以通過之前提到的 createEmbeddedView 方法建立檢視,比如:

ngAfterViewInit() {
    let view = this.tpl.createEmbeddedView(null);
}
複製程式碼

建立宿主檢視

宿主檢視是在元件動態例項化時建立的,一個動態元件(dynamic component)可以通過 ComponentFactoryResolver 建立:

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

在 Angular 中,每一個元件繫結著一個注入器(Injector)例項,所以建立 ColorComponent 元件時傳入當前元件(即 SampleComponent)的注入器。另外,別忘了,動態建立元件時需要在模組(module)或宿主元件的 EntryComponents 屬性新增被建立的元件。

現在,我們已經看到嵌入檢視和宿主檢視是如何被建立的,一旦檢視被建立,它就可以使用 ViewContainer 插入 DOM 樹中。下文主要探索這個功能。

ViewContainerRef

檢視容器就是掛載一個或多個檢視的容器。

首先需要說的是,任何 DOM 元素都可以作為檢視容器,然而有趣的是,對於繫結 ViewContainer 的 DOM 元素,Angular 不會把檢視插入該元素的內部,而是追加到該元素後面,這類似於 router-outlet 插入元件的方式。

通常,比較好的方式是把 ViewContainer 繫結在 ng-container 元素上,因為 ng-container 元素會被渲染為註釋,從而不會在 DOM 中引入多餘的 html 元素。下面示例描述在組建模板中如何建立 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);
    }
}
複製程式碼

如同其他抽象類一樣,ViewContainer 通過 element 屬性繫結 DOM 元素,比如上例中,繫結的是 會被渲染為註釋的 ng-container 元素,所以輸出也將是 template bindings={}

操作檢視

ViewContainer 提供了一些操作檢視 API:

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
}
複製程式碼

從上文我們已經知道如何通過模板和元件建立兩種型別檢視,即嵌入檢視和元件檢視。一旦有了檢視,就可以通過 insert 方法插入 DOM 中。下面示例描述如何通過模板建立嵌入檢視,並在 ng-container 標記的地方插入該檢視(譯者注:從上文中知道是追加到ng-container後面,而不是插入到該 DOM 元素內部)。

@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);
    }
}
複製程式碼

通過上面的實現,最後的 html 看起來是:

<sample>
    <span>I am first span</span>
    <!--template bindings={}-->
    <span>I am span in template</span>

    <span>I am last span</span>
    <!--template bindings={}-->
</sample>
複製程式碼

可以通過 detach 方法從檢視中移除 DOM,其他的方法可以通過方法名知道其含義,如通過索引獲取檢視引用物件,移動檢視位置,或者從檢視容器中移除所有檢視。

建立檢視

ViewContainer 也提供了手動建立檢視 API :

class ViewContainerRef {
    element: ElementRef
    length: number

    createComponent(componentFactory...): ComponentRef<C>
    createEmbeddedView(templateRef...): EmbeddedViewRef<C>
    ...
}
複製程式碼

上面兩個方法是個很好的封裝,可以傳入模板引用物件或元件工廠物件來建立檢視,並將該檢視插入檢視容器中特定位置。

ngTemplateOutlet 和 ngComponentOutlet

儘管知道 Angular 操作 DOM 的內部機制是好事,但是要是有某種快捷方式就更好了啊。沒錯,Angular 提供了兩種快捷指令:ngTemplateOutletngComponentOutlet。寫作本文時這兩個指令都是實驗性的,ngComponentOutlet 也將在版本 4 中可用(譯者注:現在版本 5.* 也是實驗性的,也都可用)。如果你讀完了上文,就很容易知道這兩個指令是做什麼的。

ngTemplateOutlet

該指令會把 DOM 元素標記為 ViewContainer,並插入由模板建立的嵌入檢視,從而不需要在元件類中顯式建立該嵌入檢視。這樣,上面例項中,針對建立嵌入檢視並插入 #vc DOM 元素的程式碼就可以重寫:

@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

這個指令與 ngTemplateOutlet 很相似,區別是 ngComponentOutlet 建立的是由元件例項化生成的宿主檢視,不是嵌入檢視。你可以這麼使用:

<ng-container *ngComponentOutlet="ColorComponent"></ng-container>
複製程式碼

總結

看似有很多新知識需要消化啊,但實際上 Angular 通過檢視操作 DOM 的思路模型是很清晰和連貫的。你可以使用 ViewChild 查詢模板引用變數來獲得 Angular DOM 抽象類。DOM 元素的最簡單封裝是 ElementRef;而對於模板,你可以使用 TemplateRef 來建立嵌入檢視;而對於元件,可以使用 ComponentRef 來建立宿主檢視,同時又可以使用 ComponentFactoryResolver 建立 ComponentRef。這兩個建立的檢視(即嵌入檢視和宿主檢視)又會被 ViewContainerRef 管理。最後,Angular 又提供了兩個快捷指令自動化這個過程:ngTemplateOutlet 指令使用模板建立嵌入檢視;ngComponentOutlet 使用動態元件建立宿主檢視。

相關文章