在Angular中使用DOM:新認知以及優化技術
原文:Working with DOM in Angular: unexpected consequences and optimization techniques
作者:Max Koretskyi原技術博文由Max Koretskyi撰寫發布,他目前於 ag-Grid 擔任開發者職位譯者:秋天;校對者:Sunny Liu
我最近在 NgConf workshop 中分享了 Angular 操作 DOM 的一些高級用法,從 templateRef 和 DOM query 到 view container 與 dynamic component。如果還沒有觀看這個分享視頻,那麼建議你抽時間看一下,分享中穿插了一系列的示例,可以幫助你更好的理解這些概念。另外,還有一個在 NgViking 中簡短的分享,也是關於這個本主題的。
如果你覺得視頻太長或者更喜歡閱讀文章而不是觀看視頻,那麼本篇文章已經總結了視頻中涉及的關鍵內容。我將首先解釋這些工具方法的用法,以及如何使用他們來在 Angular 中操作 DOM,隨後將介紹一些優化手段,這部分內容在 NgConf workshop 中沒有涉及。
你可以在 github 倉庫中找到文本的所有示例。
先了解一下視圖引擎( View Engine )
假設你需要從 DOM 中刪除一個子元素,目前已經存在了父組件,父組件的模板中包含有一個子組件 A ,現在要做的是把 A 從父組件中移除:
@Component({
...
template: `
<button (click)="remove()">Remove child component</button>
<a-comp></a-comp>
`
})
export class AppComponent {}
解決上述需求,一個錯誤的方法是:使用 Renderer 或者 native DOM API 來直接刪除 <a-comp> DOM 元素:
@Component({...})
export class AppComponent {
...
remove() {
this.renderer.removeChild(
this.hostElement.nativeElement, // parent App comp node
this.childComps.first.nativeElement // child A comp node
);
}
}
你可以從這裡看到代碼實現。如果使用瀏覽器的審查元素功能,查看頁面 DOM 節點情況,你會發現 <a-comp> 組件已經從 DOM 元素中移除了:

然而,如果你查看控制台的輸出,你會發現列印的結果中,仍然顯示子節點的數量為 1 而不是 0。變更檢測好像沒有檢測出子組件已經被移除了,下面是控制台輸出的日誌:

為什麼會出現這樣的狀況呢?因為 Angular 內部使用了一種數據結構來描述組件,通常稱它為 View (後文統一翻譯為視圖) 或者 ComponentView。下圖展示了視圖與 DOM 之間的關聯關係:

每個視圖由多個節點( view nodes )構成,這些節點都包含有 DOM 元素的引用,當我們直接更改 DOM 元素時,視圖節點並沒有改變。下圖演示了從 DOM 樹中刪除了組件 <a-comp> :

所有的變更檢測操作,包括 ViewChildren 都運行在視圖上,並非在 DOM 上。Angular 仍然會檢測到 <a-camp> 組件存在(譯者註:因為只是刪除了 DOM 中的元素,視圖中的引用仍在),列印出子組件數量仍為為 1。此外,變更檢測也仍然會把 <a-camp> 組件作為檢測對象。
上面的示例說明:不能僅僅從 DOM 中刪除子組件,你應該避免刪除任何由框架創建的 HTML 元素,否則框架無法感知到你刪除了哪些元素。不過,你可以刪除 Angular 框架無法感知到元素,例如由第三方庫創建的元素。
為了解決上述示例的「缺陷」,我們需要使用 Angular View Container(視圖容器)。
View Container
View Container 能夠保證發生在其內部的 DOM 操作更加安全(譯者註:保證 View 與 DOM 同步),Angular 中所有內置的指令中都有用到它。它是一種特殊類型的 view node,它既存在於視圖之中,也可以作為視圖的容器,掛載其他視圖:

如上圖所示,它能夠接納兩種類型的視圖:內嵌視圖和宿主視圖。
它們是 Angular 僅有的視圖類型,它們之間的最大不同是用於創建它們的載體(譯者註:API 的輸入不同)不同。內嵌視圖僅僅可以添加到 view container 中,而宿主視圖除此之外,還可以添加到任意 DOM 元素中(這個元素通常被稱之宿主元素)。
內嵌視圖從模板創建,使用 TemplateRef 方法,宿主視圖從視圖創建,使用 ComponentFactoryResolve 方法。例如,用來啟動 Angular 應用( AppComponent )的入口組件,在內部表示為附加到組件的宿主元素(<app-comp>)的宿主視圖。
View Cotainer 提供了動態創建、操作、移除視圖的 API,我們稱它為動態視圖,是相對框架中使用靜態組件模板創建的視圖。Angular 靜態視圖沒有使用到 view container,它是在子組件中的指定節點存放一個子視圖的引用。下圖是靜態視圖的示例:

從圖中得知,視圖中沒有 view container 節點,子視圖的引用直接關聯到 A組件。
動態操作視圖
在開始創建和添加視圖到 view container 之前,首先要把 view container 添加到組件的模板中,初始化它。任何元素都可以作為 view container,不過大部分情況下,都使用 <ng-container> 元素,因為它在渲染時被渲染為一個注釋節點,因此 DOM 樹中不會出現 <ng-container> 元素。
轉換元素為 view container,可以在 view query 時使用 {read: ViewContainerRef}:
@Component({
…
template: `<ng-container #vc></ng-container>`
})
export class AppComponent implements AfterViewChecked {
@ViewChild(vc, {read: ViewContainerRef}) viewContainer: ViewContainerRef;
}
獲得 view container 的引用後,那麼就可以用它來創建動態視圖了。
創建內嵌視圖(Embedded view)
創建內嵌視圖,需要一個 template,在 Angular 中,我們使用 <ng-template> 元素來包裝 DOM 元素,並且定義它為模板。並且可以通過 view query 的 {read: TemplateRef} 屬性得到這個模板的引用:
@Component({
...
template: `
<ng-template #tpl>
<!-- any HTML elements can go here -->
</ng-template>
`
})
export class AppComponent implements AfterViewChecked {
@ViewChild(tpl, {read: TemplateRef}) tpl: TemplateRef<null>;
}
獲得 template 引用後,可以使用 createEmbeddedView 方法來創建和添加一個內嵌視圖到 view container 之中。
@Component({ ... })
export class AppComponent implements AfterViewInit {
...
ngAfterViewInit() {
this.viewContainer.createEmbeddedView(this.tpl);
}
}
你可以在 ngAfterViewInit 生命周期鉤子中實現你的邏輯,因為 view query 在 ngAfterViewInit 周期時才初始化。此外,對於內嵌視圖,你可以定義一個上下文對象 (contenxt object),來綁定 <ng-template> 中的值。可以參考 createEmbeddedView() 和 NgTemplateOutlet 來理解這個上下文對象。(譯者註:NgTemplateOutlet 與 createEmbeddedView 功能上是相同的)
創建宿主視圖(host view)
創建宿主視圖,需要使用 component factory 方法,更多關於動態創建組件的知識,可以查看這篇文章:Here is what you need to know about dynamic components in Angular。
在 Angular中,我們使用 ComponentFactoryResolve service 來獲取 component factory 的引用:
@Component({ ... })
export class AppComponent implements AfterViewChecked {
...
constructor(private r: ComponentFactoryResolver) {}
ngAfterViewInit() {
const factory = this.r.resolveComponentFactory(ComponentClass);
}
}
}
當我們拿到 component factory 的引用後,可以使用它來初始化組件,創建宿主視圖並且添加它到 view container 中,即把 component factory 引用傳遞到 createComponet 方法:
@Component({ ... })
export class AppComponent implements AfterViewChecked {
...
ngAfterViewInit() {
this.viewContainer.createComponent(this.factory);
}
}
點擊這裡,可以查看完整的創建宿主視圖的示例。
移除一個視圖
任何添加到 view container 的視圖,都可以使用 remove 或者 detach 方法來移除它。這些方法在從 view container 中移除視圖的同時,也會從 DOM 刪除對應的元素。它們之間的區別是:remove 方法會銷毀視圖,此後這個視圖無法再次被添加到 view container 中;detach 方法移除的視圖,在後續還可以被重新添加到 view container 之中。這些特性,我們可以利用來優化 DOM 的操作。
所以正確的移除一個子組件或者 DOM 元素的方式,是需要首先創建嵌入視圖或者宿主視圖,並把它們添加到 view container 中,並且使用 view container 中提供的 remove 或者 detach 等方法來移除它。
優化技術
在開發中,會遇到這樣的場景:我們需要重複的隱藏和顯示某個組件或者模板元素,類似下圖展示的那樣:
如果我們簡單的利用上文中學到的知識來解決這個問題,代碼如下:
@Component({...})
export class AppComponent {
show(type) {
...
// a view is destroyed
this.viewContainer.clear();
// a view is created and attached to a view container
this.viewContainer.createComponent(factory);
}
}
這樣實現,會導致在切換隱藏和顯示組件時,頻繁地銷毀和重建視圖。
在這個特定的示例中,因為它是宿主視圖,所以銷毀和重新創建視圖使用了 component factory 和 createComponent 方法。如果我們使用 createEmbeddedView 和 TemplateRef,那麼就是內嵌視圖被銷毀和重建:
show(type) {
...
// a view is destroyed
this.viewContainer.clear();
// a view is created and attached to a view container
this.viewContainer.createEmbeddedView(this.tpl);
}
理想狀況下,我們僅需要創建一次視圖,在後續的隱藏/顯示過程中,可以復用這個視圖。ViewContainerRef API 中提供了這樣的方法,它支持添加視圖到 view container 且在移除視圖時不會徹底銷毀這個視圖。
ViewRef
ComponentFactory 和 TemplateRef 實現了 view 介面的創建視圖的方法,都可以用來創建一個視圖。事實上,view container 在調用 createComponent 和 createEmbbedView 方法時以及傳遞輸入數據時,隱藏了一些細節。我們也可以自己利用這些方法來創建宿主視圖或者內嵌視圖,並且獲得視圖的引用,在 Angular 中這個視圖的引用即是:ViewRef 以及它的子類。
創建一個宿主視圖
下面是使用 component factory 來創建一個宿主視圖,並且獲得返回的引用的示例:
aComponentFactory = resolver.resolveComponentFactory(AComponent);
aComponentRef = aComponentFactory.create(this.injector);
view: ViewRef = aComponentRef.hostView;
在宿主視圖下,create 方法返回的 ComponentRef 中包含有組件關聯的視圖,可由 hostView 屬性獲得。
獲得視圖引用後,我們就可以使用 insert 方法來添加到 view container 中。結合上文提到的 detech 方法的特性,可以得出優化的方法為:
showView2() {
...
// Existing view 1 is removed from a view container and the DOM
this.viewContainer.detach();
// Existing view 2 is attached to a view container and the DOM
this.viewContainer.insert(view);
}
再提示一下,我們這裡使用的是 detach 方法,而不是 clear 或者 remove 方法,為的就是保證視圖的引用沒有被銷毀,可以重複使用,不用再次創建。相關的示例在這裡。
創建一個內嵌視圖
內嵌視圖是利用 ng-template 進行創建的,創建的視圖,由 createEmbeddedView 方法返回:
view1: ViewRef;
view2: ViewRef;
ngAfterViewInit() {
this.view1 = this.t1.createEmbeddedView(null);
this.view2 = this.t2.createEmbeddedView(null);
}
這個例子與上一個示例類似,也可以通過 detach 和 insert 來實現優化,具體示例,可以參考這裡。
(譯者註:這裡概念容易弄混,上文與下文提到的兩種創建內嵌/宿主視圖的方式的區別就是:create 的行為時是否由 view container 發起,實際使用效果沒有差別。)
推薦閱讀:
TAG:Angular |
